package main import ( "context" "encoding/json" "errors" "strings" "testing" "time" "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/identity" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) // The grants step on a store (novox/hq issue 490): the machine holding the bus is sent its new user list, // and nothing else — not the new build of a module it runs, not a module newly assigned there. // // The restic shape: anchor holds the bus and runs restic at c1, as does laptop; restic's c2 gives it a // health tool. The grants step's send to anchor composes restic at c1, carries the user list that grants // laptop's node-engine c2's tool, and records that anchor still runs c1. A module newly assigned to anchor // makes the step fail, said, rather than install it unjudged. func TestTheGrantsStepSendsTheUserListAndNothingElse(t *testing.T) { open := aMesh(t) ctx := t.Context() inv := open.inventory start := time.Now().Add(-time.Hour) build := func(module, commit string, at time.Time, manifest map[string]any) link.BuildResult { manifest["module"], manifest["version"] = module, "1" raw, _ := json.Marshal(manifest) return link.BuildResult{ID: link.NewBuildID(at), Repository: "novox/mesh-catalog", Path: "modules/" + module, On: "anchor", Module: module, Commit: commit, Manifest: raw, Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}} } for _, b := range []link.BuildResult{ build("nats", "n1", start, natsFixture()), build("restic", "c1", start.Add(time.Second), resticFixture(false)), build("wallpaper", "w1", start.Add(2*time.Second), wallpaperFixture()), } { if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil { t.Fatal(err) } } for _, a := range [][2]string{{"anchor", "nats"}, {"anchor", "restic"}, {"laptop", "restic"}} { if _, err := inv.Assign(ctx, a[0], a[1]); err != nil { t.Fatal(err) } } for _, n := range []string{"anchor", "laptop"} { if _, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: "node." + n, Kind: inventory.BusNode, Node: n}); err != nil { t.Fatal(err) } } wasEpoch := epochForActs epochForActs = func(context.Context) (uint64, error) { return 7, nil } t.Cleanup(func() { epochForActs = wasEpoch }) // A send to anchor composed as sendToEach composes it: numbered, planned, declared, stamped. compose := func(under context.Context) (catalogue.Resolution, sendable) { t.Helper() numbered, err := allot(under, inv, "anchor") if err != nil { t.Fatal(err) } plan, settings, err := planFor(under, open, "anchor") if err != nil { t.Fatal(err) } gens, err := generators(under, open) if err != nil { t.Fatal(err) } declared, err := declarationWith(under, open, "anchor", plan, settings, gens, Allocating) if err != nil { t.Fatal(err) } numbered.stamp(&declared) return plan, declared } record := func(declared sendable) { t.Helper() body, err := declared.Body() if err != nil { t.Fatal(err) } if _, err := recordSent(ctx, inv, "anchor", body, declared.Builds, declared.Epoch, sentRecordOf(ctx, declared)); err != nil { t.Fatal(err) } } // Kept for anchor alone. laptop is never recorded as sent, on purpose: composing anchor resolves laptop // too (who is on the private network), and a step that kept every machine's builds refused anchor's // composition for want of laptop's last send (repo-check of #230 at 0f853e93). kept := keepingEveryBuild(keepingRecorded(ctx), "anchor") // What anchor was last sent: everything at the builds of before the merge, as an ordinary send sends it. _, before := compose(keepingRecorded(ctx)) record(before) plan, declared := compose(kept) if only, err := grantsOnlyIn(ctx, open, "anchor", plan, declared); err != nil || only != "" { t.Fatalf("with nothing changed, the step reads %q, %v", only, err) } // The merge: restic's c2 gives it a health tool. if _, _, err := takeIn(ctx, inv, build("restic", "c2", start.Add(time.Minute), resticFixture(true))); err != nil { t.Fatal(err) } // The step's send to anchor: restic as anchor runs it, c1, and the user list granting c2's tool. generation, err := inv.AssignmentGeneration(ctx) if err != nil { t.Fatal(err) } plan, declared = compose(kept) for _, m := range plan.Modules { if m.Module == "restic" && len(m.Tools) > 0 { t.Fatalf("the grants step composed restic's new build on anchor: tools %v", m.Tools) } } if declared.Builds["restic"] != "c1" { t.Fatalf("the step's send records it carries restic %q; want c1, which anchor runs", declared.Builds["restic"]) } if !strings.Contains(declared.BusUsers, "mesh.mod.restic.tool.backup_health.laptop") { t.Errorf("the step's declaration does not grant laptop's node-engine restic's new tool:\n%s", declared.BusUsers) } // The generation it carries is the current one (novox/hq issue 234), and the step does not raise it. if declared.composedFrom != generation { t.Errorf("the step's send was composed from generation %d; the mesh is at %d", declared.composedFrom, generation) } // The guard, on the very declaration the send sends: only the user list differs from the last send. if only, err := grantsOnlyIn(ctx, open, "anchor", plan, declared); err != nil || only != "" { t.Fatalf("the step reads as changing more than the user list: %q, %v", only, err) } // And the gate's refusal still reads the step's send: it moves nothing there. if names, err := ungatedIn(kept, open, []string{"anchor"}, ""); err != nil || strings.Join(names, ",") != "anchor" { t.Fatalf("the step's send is refused as a move: %v, %v", names, err) } // Recorded as the send records it: anchor still runs restic c1, its gate still to come there. record(declared) if sent, _, err := inv.SentBuilds(ctx, "anchor"); err != nil || sent["restic"] != "c1" { t.Fatalf("after the step anchor reads as sent restic %q (%v); want c1", sent["restic"], err) } if after, err := inv.AssignmentGeneration(ctx); err != nil || after != generation { t.Fatalf("the step moved the assignment generation from %d to %d (%v)", generation, after, err) } // A module newly assigned to anchor: the step would install it, unjudged, so it is refused unsent. if _, err := inv.Assign(ctx, "anchor", "wallpaper"); err != nil { t.Fatal(err) } plan, declared = compose(kept) only, err := grantsOnlyIn(ctx, open, "anchor", plan, declared) if err != nil { t.Fatal(err) } if !strings.Contains(only, "wallpaper newly assigned") || !strings.Contains(only, "+wallpaper") { t.Fatalf("a new assignment on the bus's machine reads %q; want the step refused, naming it", only) } // And the send itself refuses it, unsent: judged where it is composed, before the bus is dialled. identity.ForTest(t) if _, err := sendToEach(kept, open, []string{"anchor"}); !errors.Is(err, errNotGrantsOnly) || !strings.Contains(err.Error(), "wallpaper") { t.Fatalf("the grants step's send of a new assignment was not refused by the send: %v", err) } } // fixtureImage is the image every fixture container of the grants step's tests runs. var fixtureImage = "registry.invalid:5000/restic/backup@sha256:" + strings.Repeat("b", 64) // resticFixture is restic's manifest in the shape of mesh-catalog #210: at c2 (withTool) its backup judged by // its new tool `backup_health`, beside a check of another kind it does not run itself — a tool check alone // is refused (ADR 0227 rule 8, ADR 0240 rule 2) — and at c1 neither. func resticFixture(withTool bool) map[string]any { backup := map[string]any{"id": "backup", "type": "container", "name": "restic-backup", "image": fixtureImage} measure := map[string]any{"id": "measure", "type": "container", "name": "restic-measure", "image": fixtureImage} m := map[string]any{"resources": []any{backup, measure}} if withTool { backup["health"] = map[string]any{"kind": catalogue.HealthTool, "tool": "backup_health"} measure["health"] = map[string]any{"kind": "runtime"} m["tools"] = []string{"backup_health"} } return m } // natsFixture is the bus's module: it holds mesh-broker and is sent the user list. func natsFixture() map[string]any { return map[string]any{"bus-users": "/var/lib/nats-module/conf/accounts.conf", "claims": []any{map[string]any{"name": catalogue.BrokerSeat, "scope": catalogue.ScopeMesh}}} } // wallpaperFixture is a module the bus's machine is newly assigned. func wallpaperFixture() map[string]any { return map[string]any{"resources": []any{ map[string]any{"id": "paper", "type": "container", "name": "wallpaper", "image": fixtureImage}}} }