diff --git a/cmd/mesh-builder/main.go b/cmd/mesh-builder/main.go index b6fa0b6..57eddb0 100644 --- a/cmd/mesh-builder/main.go +++ b/cmd/mesh-builder/main.go @@ -47,7 +47,8 @@ It consumes build requests and answers with what it made. Nothing is listened on is dialled except the broker. MESH_BROKER_AMQP where the broker is, with this builder's own credential - MESH_REGISTRY host:port to publish artifacts to + MESH_REGISTRY host:port to publish artifacts to, when the mesh has not said + MESH_BINDING a file the mesh wrote saying where the artifact store is MESH_WORKSPACE where to clone and build (default: a temporary directory) ` @@ -67,10 +68,9 @@ func run() error { if amqpURL == "" { return fmt.Errorf("no MESH_BROKER_AMQP: a builder with no broker has nothing to build") } - registry := strings.TrimSpace(os.Getenv("MESH_REGISTRY")) - if registry == "" { - return fmt.Errorf( - "no MESH_REGISTRY: a built artifact nobody can fetch is not built") + registry, err := whereToPublish() + if err != nil { + return err } workspace := os.Getenv("MESH_WORKSPACE") if workspace == "" { @@ -212,3 +212,51 @@ func short(commit string) string { } return commit } + +// whereToPublish is the artifact store this builder uses. +// +// **Preferably from the mesh.** A builder that is a module requires an artifact store, and the +// mesh writes it a file saying which machine answers that and on what port — the same binding any +// consumer of any provision gets. Reading it means the address is not a setting somebody keeps in +// step by hand, and moving the store is an ordinary reassignment rather than an edit on every +// build machine. +// +// The environment variable remains for a builder run by a person, which is how this started and +// how it is still run while being developed. +func whereToPublish() (string, error) { + binding := strings.TrimSpace(os.Getenv("MESH_BINDING")) + if binding == "" { + registry := strings.TrimSpace(os.Getenv("MESH_REGISTRY")) + if registry == "" { + return "", fmt.Errorf("neither MESH_BINDING nor MESH_REGISTRY: a built artifact " + + "nobody can fetch is not built") + } + return registry, nil + } + + raw, err := os.ReadFile(binding) + if err != nil { + return "", fmt.Errorf("cannot read what the mesh said about the artifact store: %w", err) + } + var told struct { + From string `json:"from"` + At string `json:"at"` + Serves map[string]any `json:"serves"` + } + if err := json.Unmarshal(raw, &told); err != nil { + return "", fmt.Errorf("%s is not a binding: %w", binding, err) + } + if told.At == "" { + // The provider is not on the private network, so there is no name to reach it by. Said + // rather than falling back to the machine's own name, which would publish to a store on + // the wrong machine and be found out much later. + return "", fmt.Errorf( + "%s says the artifact store is on %q and gives no address for it", binding, told.From) + } + port, ok := told.Serves["port"] + if !ok { + return "", fmt.Errorf("%s says nothing about which port the artifact store answers on", + binding) + } + return fmt.Sprintf("%s:%v", told.At, port), nil +} diff --git a/cmd/mesh-builder/where_test.go b/cmd/mesh-builder/where_test.go new file mode 100644 index 0000000..7a87246 --- /dev/null +++ b/cmd/mesh-builder/where_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Where a builder publishes. +// +// Preferably from the mesh: a builder that is a module requires an artifact store, and the mesh +// writes it a binding saying which machine answers and on what port. Reading it means the address +// is not a setting somebody keeps in step by hand. + +func binding(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "artifact-store.json") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestTheMeshSaysWhereToPublish(t *testing.T) { + t.Setenv("MESH_BINDING", binding(t, `{"binding":1,"provision":"artifact-store", + "from":"anchor","at":"anchor.internal","serves":{"port":5000,"scheme":"http"}}`)) + where, err := whereToPublish() + if err != nil { + t.Fatal(err) + } + if where != "anchor.internal:5000" { + t.Fatalf("got %q", where) + } +} + +func TestABindingWithNoAddressIsRefused(t *testing.T) { + // The provider is not on the private network, so there is no name to reach it by. Falling + // back to anything would publish to a store on the wrong machine and be found out much later. + t.Setenv("MESH_BINDING", binding(t, `{"binding":1,"from":"anchor","serves":{"port":5000}}`)) + _, err := whereToPublish() + if err == nil { + t.Fatal("a binding with nowhere to reach was accepted") + } + if !strings.Contains(err.Error(), "anchor") { + t.Fatalf("the failure does not name the machine: %v", err) + } +} + +func TestABindingWithNoPortIsRefused(t *testing.T) { + t.Setenv("MESH_BINDING", binding(t, `{"binding":1,"from":"a","at":"a.internal","serves":{}}`)) + if _, err := whereToPublish(); err == nil { + t.Fatal("a binding saying nothing about a port was accepted") + } +} + +func TestTheVariableStillWorksForABuilderRunByAPerson(t *testing.T) { + // Which is how this started and how it is still run while being developed. + t.Setenv("MESH_BINDING", "") + t.Setenv("MESH_REGISTRY", "127.0.0.1:5000") + where, err := whereToPublish() + if err != nil { + t.Fatal(err) + } + if where != "127.0.0.1:5000" { + t.Fatalf("got %q", where) + } +} + +func TestNeitherIsRefusedRatherThanGuessed(t *testing.T) { + t.Setenv("MESH_BINDING", "") + t.Setenv("MESH_REGISTRY", "") + if _, err := whereToPublish(); err == nil { + t.Fatal("a builder with nowhere to publish reported somewhere") + } +} diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index f49b78b..0a1bbb4 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -145,7 +145,7 @@ func usage() { pin which node this one gets a provision from unpin put that question back plan [--files|--json] what that node would run, and why - push [] send a node everything it should be + push [] [--behind] send a node everything it should be, or only those that need it version what this binary is Each context reaches its own store through its own credential (novox/hq ADR 0008), named @@ -1406,8 +1406,28 @@ func planCommand(ctx context.Context, args []string) error { // half-configured for as long as that lasts — and the two are computed from the same picture of // the mesh, so sending them apart would let them disagree. func pushCommand(ctx context.Context, args []string) error { + set := flag.NewFlagSet("push", flag.ContinueOnError) + // Only the machines that need it. + // + // **A command rather than a timer, to begin with.** Something that re-pushes on a schedule is + // a scheduler over this, and building the scheduler first would mean two paths to one act + // with nothing to compare them against. A person can run this; so can cron; so can whatever + // eventually watches. + behind := set.Bool("behind", false, + "only machines whose last declaration was refused or partly failed") + positionals, err := parseAround(set, args) + if err != nil { + return err + } + args = positionals if len(args) > 1 { - return errors.New("push [] — one node, or all of them") + return errors.New("push [] [--behind] — one node, or all of them") + } + if len(args) == 1 && *behind { + // Naming a machine and asking for the ones that need it are two different requests, and + // guessing which was meant would sometimes push to a machine somebody did not name. + return errors.New("push or push --behind, not both: one names a machine and the " + + "other asks which machines need one") } inv, err := openInventory(ctx) if err != nil { @@ -1428,6 +1448,25 @@ func pushCommand(ctx context.Context, args []string) error { if err != nil { return err } + + // Which machines are not in the state they were sent, when that is what was asked for. + var needsOne map[string]inventory.Doing + if *behind { + wrong, err := inv.NotDoingWhatTheyWereTold(ctx) + if err != nil { + return err + } + needsOne = map[string]inventory.Doing{} + for _, d := range wrong { + needsOne[d.Node] = d + } + if len(needsOne) == 0 { + // Said rather than doing nothing quietly. "Nothing needed one" and "this did not run" + // must never look the same. + fmt.Println("every machine is doing what it was told") + return nil + } + } gens, err := generators(ctx, inv) if err != nil { return err @@ -1453,6 +1492,20 @@ func pushCommand(ctx context.Context, args []string) error { if len(args) == 1 && n.Name != args[0] { continue } + if *behind { + doing, needs := needsOne[n.Name] + if !needs { + continue + } + // A machine that has been failing the same way for a long time is not going to stop + // because it was asked again. Said, and pushed to anyway — refusing would leave no + // way to retry after fixing the cause, and this is a command somebody ran. + if since := time.Since(doing.At); since > 6*time.Hour { + fmt.Printf("%s has been %s since %s; pushing again anyway, but the cause is "+ + "unlikely to be timing\n", + n.Name, doing.Outcome, doing.At.Local().Format("2006-01-02 15:04")) + } + } plan, settings, err := planFor(ctx, inv, n.Name) if err != nil { refusals = append(refusals, fmt.Sprintf("%s:\n%v", n.Name, err))