From 85c89b16ef6231140c72f620b13f0ff7375a87ab Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 19:33:27 +0100 Subject: [PATCH] =?UTF-8?q?The=20grants=20part=20composes=20what=20the=20c?= =?UTF-8?q?atalogue's=20placement=20places=20beside=20the=20assignments,?= =?UTF-8?q?=20and=20holds=20a=20seat=20held=20once=20for=20the=20mesh=20wh?= =?UTF-8?q?ere=20placement=20says=20(issue=201126,=20ADR=200549=20=C2=A73,?= =?UTF-8?q?=20G5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/mesh-grants/go.mod | 2 +- modules/mesh-grants/go.sum | 2 + modules/mesh-grants/grants_test.go | 37 ++++++++++++++++ modules/mesh-grants/internal/grants/follow.go | 2 +- modules/mesh-grants/internal/grants/inputs.go | 43 ++++++++++++++++++- .../mesh-grants/internal/grants/records.go | 23 ++++++++++ modules/mesh-grants/module.json | 1 + 7 files changed, 106 insertions(+), 4 deletions(-) diff --git a/modules/mesh-grants/go.mod b/modules/mesh-grants/go.mod index 6d8f4e9..375f060 100644 --- a/modules/mesh-grants/go.mod +++ b/modules/mesh-grants/go.mod @@ -3,7 +3,7 @@ module mesh-grants go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142434-65ee43f07583 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026182519-53c2def1a520 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-grants/go.sum b/modules/mesh-grants/go.sum index 4cc1a88..af1c231 100644 --- a/modules/mesh-grants/go.sum +++ b/modules/mesh-grants/go.sum @@ -1,5 +1,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142434-65ee43f07583 h1:ikD/ggf9/5k23sxJOX1PFcGzb4M3+QeC66AT0MyQ9Zs= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142434-65ee43f07583/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026182519-53c2def1a520 h1:twiziR88X6ttKmw5JTd3aW6xb5bMZZ3Bl7WS2QOP3PE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026182519-53c2def1a520/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 77842e3..3e72e77 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -708,6 +708,43 @@ func TestGrants(t *testing.T) { } }) + // 1126 (ADR 0549 §1, §3): once the catalogue places the nodes, a seat held once for the mesh is held where placement + // says, not on every machine running a module that claims it; what placement adds is composed beside the assignments. + t.Run("a mesh seat is held where placement says", func(t *testing.T) { + store := catalogue.Manifest{Module: "store", Version: "1", + DefinesSeats: []catalogue.SeatDeclaration{{Name: "store-seat", Scope: catalogue.ScopeMesh, Serves: []catalogue.Verb{{Name: "databases"}}}}, + Claims: []catalogue.Claim{{Name: "store-seat", Scope: catalogue.ScopeMesh}}} + in := grants.Inputs{Nodes: []string{"a", "b"}, Assigned: map[string][]string{"a": {"store"}, "b": {"store"}}, + Manifests: map[string]catalogue.Manifest{"store": store}} + holds := func(recs buscompose.Records, node string) int { + n := 0 + for _, d := range recs.Assigned[node] { + n += len(d.Holds) + } + return n + } + if recs, _ := grants.Records(in); holds(recs, "a") != 1 || holds(recs, "b") != 1 { + t.Fatalf("before placement the seat is held on a %d and b %d times, want both", holds(recs, "a"), holds(recs, "b")) + } + in.Placed = map[string]delivery.Placement{ + "a": {Node: "a", Modules: []delivery.PlacedModule{{Module: "store"}}, Holds: []delivery.Holding{{Claim: "store-seat", Scope: "mesh", Module: "store"}}}, + "b": {Node: "b", Modules: []delivery.PlacedModule{{Module: "store"}}}} + if recs, _ := grants.Records(in); holds(recs, "a") != 1 || holds(recs, "b") != 0 { + t.Errorf("placed, the seat is held on a %d and b %d times, want a alone", holds(recs, "a"), holds(recs, "b")) + } + b := fixture() + pl, _ := delivery.EncodePlacement(delivery.Placement{Node: "b", Modules: []delivery.PlacedModule{{Module: "mod-y"}, {Module: "mod-x"}}}) + b.states[grants.ReadPlacement] = map[string][]byte{"placement.b": pl} + read := grants.ReadInputs(b, &grants.Manifests{}) + if !slices.Contains(read.Assigned["b"], "mod-x") || read.Placed["b"].Node != "b" || slices.ContainsFunc(read.Gaps, func(g grants.Gap) bool { return g.Input == "seat holdings" }) { + t.Errorf("read: assigned %v, placed %v, gaps %v", read.Assigned, read.Placed, read.Gaps) + } + b.refused[grants.ReadPlacement] = true + if read := grants.ReadInputs(b, &grants.Manifests{}); slices.ContainsFunc(read.Gaps, func(g grants.Gap) bool { return g.Input == grants.ReadPlacement && g.Failed }) { + t.Errorf("placement not on the bus is a failed read: %v", read.Gaps) + } + }) + t.Run("compare excuses a retired verb and a multiply claimed mesh seat's added subscriptions on its machines, nothing else", func(t *testing.T) { const retired, store, storeInfo = "mesh.seat.mesh-delivery.tool.mark-ready", "mesh.seat.mesh-store.tool.databases", "$SRV.INFO.mesh-store.>" two := map[string][]string{"mesh-store": {"a", "n"}} diff --git a/modules/mesh-grants/internal/grants/follow.go b/modules/mesh-grants/internal/grants/follow.go index 938a243..3d19217 100644 --- a/modules/mesh-grants/internal/grants/follow.go +++ b/modules/mesh-grants/internal/grants/follow.go @@ -18,7 +18,7 @@ type Watcher interface { // Followed are the states whose change composes again: placement, the indexer's manifests by module, the resolved // config values, the node agents' haves (a machine's first have, and the bus node's loaded list) and the assigner's // wants (the manifest each machine is wanted to run), and the vault's hashes of the bus accounts. -var Followed = []string{ReadAssignments, ReadModules, ReadResolved, ReadHave, ReadWants, ReadAccounts} +var Followed = []string{ReadAssignments, ReadModules, ReadResolved, ReadHave, ReadWants, ReadAccounts, ReadPlacement} // Follower composes again on every change it hears, never on a timer. It never stops for a refusal: a watch it cannot // open is said, composed without, and opened again at the next wake (any change it does hear, or a reconnect). diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index fdb6c03..0634fe5 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -34,11 +34,14 @@ const ( ReadBuilds = "mesh-delivery-builder.builds" // ReadAccounts is the vault's bcrypt hashes of the bus accounts it holds, by bus user (ADR 0547 clause 9). ReadAccounts = "mesh-vault.bus-accounts" + // ReadPlacement is the indexer's placement, read from the catalogue (ADR 0549 §2, §3): each node's modules and the seats + // they hold. + ReadPlacement = "mesh-delivery-indexer.placement" ) // Reads is every state the part reads, in the manifest's order. var Reads = []string{ReadAssignments, ReadControllerGrant, ReadModules, ReadManifests, ReadResolved, ReadHave, ReadWants, ReadBuilds, - ReadAccounts} + ReadAccounts, ReadPlacement} // The bus module's tools the part calls, as its manifest's invokes name them: read-only, each. const ( @@ -103,8 +106,12 @@ type resolved struct { type Inputs struct { // Nodes is every machine seen: in a node agent's have or a standing assignment. Nodes []string - // Assigned is each machine's standing assignments, sorted. + // Assigned is each machine's standing assignments and what the catalogue's placement puts there, sorted: while both + // stand (ADR 0549 §5) a module either names keeps its membership. Assigned map[string][]string + // Placed is each node's placement as the indexer read it from the catalogue (ADR 0549 §2); empty before any is read. + // Where it is, a seat held once for the mesh is held where placement says, not wherever a module claiming it runs. + Placed map[string]delivery.Placement // Manifests is each module's manifest at the indexer's trunk, by module. Manifests map[string]catalogue.Manifest // Digests is each module's manifest digest, by module. @@ -230,6 +237,38 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { in.BusNode = a.Node } } + // The catalogue's placement (ADR 0549 §3): what it places is composed beside the assignments, and the seats held once + // for the mesh are held where it says. Not on the bus yet is a standing gap, never a failed read, so the list is still + // composed and sealed from the assignments alone. + if keys, err := r.Keys(ReadPlacement); err != nil { + in.Gaps = append(in.Gaps, Gap{Input: ReadPlacement, Why: "not read yet: the assignments alone place modules (" + err.Error() + ")"}) + } else { + for _, k := range keys { + raw, found, err := r.Get(ReadPlacement, k) + if err != nil || !found { + continue + } + p, err := delivery.DecodePlacement(raw) + node, kerr := delivery.ParsePlacementKey(k) + if err != nil || kerr != nil || p.Node != node { + in.Gaps = append(in.Gaps, Gap{Input: ReadPlacement + " " + k, Why: "not a placement, so the node is composed from its assignments alone"}) + continue + } + if in.Placed == nil { + in.Placed = map[string]delivery.Placement{} + } + in.Placed[node] = p + nodes[node] = true + for _, m := range p.Assigned() { + if !slices.Contains(in.Assigned[node], m) { + in.Assigned[node] = append(in.Assigned[node], m) + } + } + } + } + if len(in.Placed) > 0 { + in.Gaps = slices.DeleteFunc(in.Gaps, func(g Gap) bool { return g.Input == "seat holdings" }) + } haveKeys, err := r.Keys(ReadHave) if err != nil { in.Gaps = append(in.Gaps, Gap{Input: ReadHave, Why: "refused or absent: " + err.Error(), Failed: true}) diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index e53beab..e92e11c 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -7,6 +7,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" ) // Records is what the bus composer composes from, rebuilt from the bus (mesh-controller internal/inventory busrecords.go, @@ -59,6 +60,9 @@ func Records(in Inputs) (buscompose.Records, []Gap) { continue } d := declaredFor(m, seats, declarers) + if len(in.Placed) > 0 { + d.Holds = heldWherePlaced(d.Holds, in.Placed[node], m.Module) + } for _, sr := range catalogue.ReadsGranted(m, here, node) { d.KeyedReads = append(d.KeyedReads, buscompose.KeyedRead{Read: sr.Read, Key: sr.Key}) } @@ -299,3 +303,22 @@ func hearsOf(m catalogue.Manifest, declared map[string]catalogue.Manifest, node } return out, touches, onEvents } + +// heldWherePlaced is a module's holds on a node as the catalogue's placement says them (ADR 0549 §1, §3): a seat held once +// for the mesh is held there only when the node's placement names this module holding it, so a module claiming such a seat +// on two machines holds it on the one placement names, not on both; a seat of every node is held wherever the module runs. +func heldWherePlaced(holds []buscompose.Seat, p delivery.Placement, module string) []buscompose.Seat { + var out []buscompose.Seat + for _, h := range holds { + if h.Scope != "" && h.Scope != catalogue.ScopeMesh { + out = append(out, h) + continue + } + if slices.ContainsFunc(p.Holds, func(x delivery.Holding) bool { + return x.Claim == h.Name && x.Module == module && x.Kind == h.Kind + }) { + out = append(out, h) + } + } + return out +} diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 710c0ce..65d06b0 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -10,6 +10,7 @@ "mesh-delivery-builder.builds", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", + "mesh-delivery-indexer.placement", "node-engine.have", "mesh-vault.bus-accounts" ],