package main import ( "context" "encoding/json" "errors" "fmt" "slices" "strings" "testing" "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) // The grants reach the bus before the gate (novox/hq issue 490). // // On 2026-10-11 a merge gave restic, assigned to every machine, a new health tool. Its walk sent restic to // its first machine and judged it there; that machine's node-engine asked the new tool and the bus refused // it, because the grant was in the bus's user list, which only the machine holding the bus carries — and // that machine runs restic too, so it was left out of the send while restic's new build waited there for // the very gate that could not pass. A person pushed it by hand, carrying restic's new build there unjudged. // aSend is one send the walk made: to which machines, and whether every build there was kept. type aSend struct { names []string keepsAll bool judged []string } // sendsRecorded replaces the walk's send and the reading of the bus's user list for one test: the machine // holding the bus is novox, and its list is behind as behind says. func sendsRecorded(t *testing.T, holder string, behind bool, refuse error) *[]aSend { t.Helper() var sends []aSend wasSend, wasBehind := sendRollout, brokerBehindOf t.Cleanup(func() { sendRollout, brokerBehindOf = wasSend, wasBehind }) brokerBehindOf = func(_ context.Context, _ *stores, names []string) (string, bool, error) { if slices.Contains(names, holder) { return holder, false, nil } return holder, behind, nil } sendRollout = func(ctx context.Context, _ *stores, names []string) ([]string, error) { s := aSend{names: append([]string(nil), names...), keepsAll: len(names) == 1 && everyBuildKept(ctx, names[0])} for n := range scopeOf(ctx).judged { s.judged = append(s.judged, n) } sends = append(sends, s) if refuse != nil && s.keepsAll { return nil, refuse } return names, nil } return &sends } // The restic shape: a new tool on a module on every machine, its first machine ace, the bus on novox. func TestAWalkSendsTheGrantsToTheBusBeforeTheFirstMachineIsJudged(t *testing.T) { sends := sendsRecorded(t, "novox", true, nil) sent, grants, err := sendJudged(t.Context(), nil, "ace") if err != nil { t.Fatal(err) } if len(*sends) != 2 { t.Fatalf("the walk made %d send(s), want the grants step and then the first machine: %+v", len(*sends), *sends) } first, then := (*sends)[0], (*sends)[1] if strings.Join(first.names, ",") != "novox" || !first.keepsAll || len(first.judged) != 0 { t.Errorf("the first send is %+v, want novox alone, every build there kept, judging nothing", first) } if strings.Join(then.names, ",") != "ace" || then.keepsAll || strings.Join(then.judged, ",") != "ace" { t.Errorf("the second send is %+v, want ace, judged", then) } if strings.Join(sent, ",") != "ace" { t.Errorf("the gate's machines are %v, want ace alone: the bus's machine is not judged", sent) } if grants == nil || grants.Node != "novox" || grants.At == nil || grants.Failed != "" { t.Fatalf("the gate keeps %+v as its grants step", grants) } line := gateLine(&inventory.PlanGate{Machines: sent, Grants: grants}) if !strings.Contains(line, "grants step: novox sent the bus's user list first, its builds kept") { t.Errorf("plans says the gate as %q", line) } } // Nothing more when there is nothing to carry: the list unchanged, or the first machine holds the bus. func TestAWalkTakesNoGrantsStepWhenTheListIsAlreadyThere(t *testing.T) { for _, c := range []struct { name string node string behind bool }{{"the list unchanged", "ace", false}, {"the first machine holds the bus", "novox", true}} { t.Run(c.name, func(t *testing.T) { sends := sendsRecorded(t, "novox", c.behind, nil) _, grants, err := sendJudged(t.Context(), nil, c.node) if err != nil { t.Fatal(err) } if len(*sends) != 1 || (*sends)[0].keepsAll || grants != nil { t.Errorf("the walk made %+v with grants %+v, want the judged send alone", *sends, grants) } }) } } // A grants step that cannot be sent is said on the gate, and the walk goes on as it did before. func TestAGrantsStepThatFailsIsSaidAndTheSendGoesOn(t *testing.T) { sends := sendsRecorded(t, "novox", true, errors.New("a bus upgrade waits for a person")) sent, grants, err := sendJudged(t.Context(), nil, "ace") if err != nil { t.Fatal(err) } if strings.Join(sent, ",") != "ace" || len(*sends) != 2 { t.Errorf("the walk made %+v", *sends) } if grants == nil || grants.Failed == "" || grants.At != nil { t.Fatalf("the gate keeps %+v", grants) } if line := gateLine(&inventory.PlanGate{Machines: sent, Grants: grants}); !strings.Contains(line, "grants step to novox FAILED, sent without it: a bus upgrade waits for a person") { t.Errorf("plans says the gate as %q", line) } } // A step whose send would change more than the user list is refused unsent by the send (sendToEach): the // gate says it FAILED with what differed, and the walk goes on. func TestAGrantsStepThatWouldCarryMoreIsNotSent(t *testing.T) { refusal := fmt.Errorf("%w: its send would change more than the bus's user list: -postgres.login-n8n", errNotGrantsOnly) sends := sendsRecorded(t, "novox", true, refusal) sent, grants, err := sendJudged(t.Context(), nil, "ace") if err != nil { t.Fatal(err) } if strings.Join(sent, ",") != "ace" || len(*sends) != 2 { t.Errorf("the walk made %+v, want the refused step and then ace's judged send", *sends) } if grants == nil || grants.At != nil || !strings.Contains(grants.Failed, "-postgres.login-n8n") { t.Fatalf("the gate keeps %+v", grants) } } // The step's exemption is the list's content alone: its path or mode moving is a change like any other. func TestOnlyTheUserListsContentIsExempt(t *testing.T) { list := "nats.bus-users" file := func(fields map[string]any) sentResource { r := map[string]any{"id": list, "type": "file", "path": "/conf/accounts.conf", "mode": "0600", "content": "a"} for k, v := range fields { r[k] = v } body, _ := json.Marshal(map[string]any{"resources": []any{r}}) s, err := summarize(body) if err != nil { t.Fatal(err) } return s[0] } was := file(nil) if other := otherThanTheList(diffSent([]sentResource{was}, []sentResource{file(map[string]any{"content": "b"})}), list); len(other) != 0 { t.Errorf("the list's new content reads as more: %v", other) } for _, f := range []map[string]any{{"mode": "0644"}, {"path": "/elsewhere"}, {"content": "b", "owner": "nats"}} { other := otherThanTheList(diffSent([]sentResource{was}, []sentResource{file(f)}), list) if len(other) != 1 || !strings.HasPrefix(other[0], "~"+list) { t.Errorf("the list's resource changed by %v reads %v; want it refused", f, other) } } } // The step keeps the builds of the machine it sends alone: the others its composition resolves are composed // as any send composes them. func TestTheGrantsStepKeepsOneMachinesBuilds(t *testing.T) { ctx := keepingEveryBuild(t.Context(), "novox") if !everyBuildKept(ctx, "novox") || everyBuildKept(ctx, "ace") || everyBuildKept(t.Context(), "novox") { t.Error("the grants step keeps the builds of a machine it does not send") } } // The store test's manifests are manifests the controller takes in: checked here, where no store is needed, // so a fixture the module rules refuse fails before the store test is run (repo-check of #230 at 325575b2). func TestTheGrantsStepFixturesAreManifests(t *testing.T) { for name, m := range map[string]map[string]any{"nats": natsFixture(), "restic-c1": resticFixture(false), "restic-c2": resticFixture(true), "wallpaper": wallpaperFixture()} { m["module"], m["version"] = strings.SplitN(name, "-", 2)[0], "1" raw, err := json.Marshal(m) if err != nil { t.Fatal(err) } if _, err := catalogue.ParseManifest(raw); err != nil { t.Errorf("%s: %v", name, err) } } } // The delivery plan says the step before the merge. func TestAChangePlanSaysTheGrantsStep(t *testing.T) { const catalogue_ = "http://forge.internal:20000/novox/mesh-catalog.git" entry := func(name string, on ...string) inventory.Entry { e := fromRepo(name, catalogue_, "modules/"+name) e.Source.BuiltFrom = "old" e.On = on return e } nats := entry("nats", "novox") nats.Manifest.BusUsers = "/etc/nats/users.conf" nats.Manifest.Claims = []catalogue.Claim{{Name: catalogue.BrokerSeat, Scope: catalogue.ScopeMesh}} restic := entry("restic", "ace", "novox", "shanks") only := entry("bus-tools", "novox") entries := []inventory.Entry{nats, restic, only} rollsOut := func(string) (inventory.Upgrade, bool) { return inventory.Upgrade{RollOut: true}, true } plan := func(paths ...string) link.ChangePlan { m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "head", Paths: paths} return changePlanOf("novox/mesh-catalog", "main", "head", reachOfMerge(m, entries, nil, nil), entries, rollsOut) } text := planText(plan("modules/restic/module.json")) if !strings.Contains(text, "grants step: if this changes what the bus's user list says (a new tool, subject or "+ "user), novox is sent that list alone, every build there kept, before restic is judged on its first machine") { t.Errorf("the change plan does not say the grants step:\n%s", text) } // A module only on the bus's machine goes there with the list in its own send: no step of its own. if text := planText(plan("modules/bus-tools/module.json")); strings.Contains(text, "grants step") { t.Errorf("a module on the bus's machine alone reads:\n%s", text) } }