diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index ea058081..5ba770c3 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -1281,6 +1281,91 @@ func TestGrants(t *testing.T) { } }) + // Issue 1966, ADR 0660: the live case. The assigner's record still names the catalogue module as the holder of + // mesh-catalog, whose manifest claims it no more, and the indexer claims it: the rule of mesh-sdk (the node-engine's + // too) passes the seat to the indexer, the user list grants the seat's permissions to it, the pass is said, and the list + // is not held back as the loss of the seat's last holder. + t.Run("a holder on record whose manifest left the seat passes it to its only claimant", func(t *testing.T) { + seat, ok := catalogue.SeatNamed("mesh-catalog") + if !ok { + t.Fatal("mesh-catalog is not a seat") + } + var verbs []string + for _, v := range seat.Serves { + verbs = append(verbs, v.Name) + } + claimer := func(module string) catalogue.Manifest { + return catalogue.Manifest{Module: module, Version: "1", RunsAs: module, OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/" + module + "/broker"}}, Claims: []catalogue.Claim{{Name: "mesh-catalog", Scope: catalogue.ScopeMesh, Serves: verbs}}, + Provides: []catalogue.Offer{{Name: seat.Delivers, Scope: catalogue.ScopeMesh}}} + } + left := catalogue.Manifest{Module: "mesh-catalog", Version: "1", RunsAs: "mesh-catalog", OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh-catalog/broker"}}} + rows := []struct { + name string + running map[string]catalogue.Manifest // by `.` + extra []string // more claimants, assigned on node b + want []string // the modules holding the seat afterwards + said string + }{ + {"the trunk of the holder dropped the seat", nil, nil, []string{"mesh-delivery-indexer"}, "composed as held by mesh-delivery-indexer"}, + {"the holder's running build still claims it", map[string]catalogue.Manifest{"a.mesh-catalog": claimer("mesh-catalog")}, nil, []string{"mesh-catalog"}, ""}, + {"a second claimant", nil, []string{"other"}, nil, "seat mesh-catalog --to /"}, + } + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { + in := grants.Inputs{Nodes: []string{"a", "b"}, + Assigned: map[string][]string{"a": {"mesh-catalog"}, "b": {"mesh-delivery-indexer"}}, + Manifests: map[string]catalogue.Manifest{"mesh-catalog": left, "mesh-delivery-indexer": claimer("mesh-delivery-indexer")}, + Running: map[string]catalogue.Manifest{}, + Placed: map[string]meshmoduleassigner.Assignments{ + "a": {Node: "a", Modules: []meshmoduleassigner.PlacedModule{{Module: "mesh-catalog"}}, + Holds: []meshmoduleassigner.Holding{{Claim: "mesh-catalog", Scope: "mesh", Module: "mesh-catalog"}}}, + "b": {Node: "b", Modules: []meshmoduleassigner.PlacedModule{{Module: "mesh-delivery-indexer"}}}}} + maps.Copy(in.Running, row.running) + for _, c := range row.extra { + in.Manifests[c] = claimer(c) + in.Assigned["b"] = append(in.Assigned["b"], c) + } + before := in.Placed + recs, gaps := grants.ListRecords(in) + users, refusedU, err := grants.ComposeEach(recs) + if len(refusedU) != 0 { + t.Fatalf("refused %v", refusedU) + } + if err != nil { + t.Fatal(err) + } + h := grants.HoldingsOf(in) + var got []string + for _, u := range users { + if h.ByUsers([]grants.User{u})["mesh-catalog"] { + _, module, _ := strings.Cut(u.User, ".") + got = append(got, module) + } + } + slices.Sort(got) + if !slices.Equal(got, row.want) { + t.Errorf("granted the seat's permissions to %v, want %v", got, row.want) + } + var said []string + for _, g := range gaps { + said = append(said, g.Why) + } + if row.said == "" && len(gaps) != 0 || !strings.Contains(strings.Join(said, "\n"), row.said) { + t.Errorf("the pass is said as %q, want %q", said, row.said) + } + // The in-force list carries the seat (granted to the holder on record); the composed one must too, or the + // list is held back as the last holder's loss. Two claimants grant none, and that is held back and said. + lost := h.LastHolders(map[string]bool{"mesh-catalog": true}, h.ByUsers(users)) + if (len(lost) != 0) != (len(row.want) == 0) { + t.Errorf("the seat's last holder would be lost: %v", lost) + } + if !reflect.DeepEqual(before, in.Placed) { + t.Errorf("the assigner's record was written: %+v", in.Placed) + } + }) + } + }) + // 1126 (ADR 0549 §1, §3): once the catalogue places the nodes, a seat held once for the mesh is held where assignments // says, not on every machine running a module that claims it; what assignments adds is composed beside the assignments. t.Run("a mesh seat is held where assignments says", func(t *testing.T) { diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 47bedaf3..86da3a79 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -3,11 +3,11 @@ package grants import ( "fmt" "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" + "maps" "reflect" "slices" "strings" - "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-sdk/go/catalogue" "mesh-grants/internal/buscompose" ) @@ -50,6 +50,10 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { out.BusState[module] = m.BusState } } + // The holders on record, after the seats whose holder has left are passed (ADR 0660): the one rule of mesh-sdk, the + // node-engine's too, read with the manifest each node runs, else the trunk's. Composed, never recorded. + holders, passed := holdersAfterPass(in) + gaps = append(gaps, passed...) values := func(provider, setting string) []any { var vs []any for _, node := range in.Nodes { @@ -83,7 +87,7 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { declare := func(m catalogue.Manifest) buscompose.Declared { d := declaredFor(m, seats, declarers) if len(in.Placed) > 0 { - d.Holds = heldWherePlaced(d.Holds, in.Placed, node, m.Module) + d.Holds = heldWherePlaced(d.Holds, holders, node, m.Module) } for _, sr := range catalogue.ReadsGranted(m, here, node) { d.KeyedReads = append(d.KeyedReads, buscompose.KeyedRead{Read: sr.Read, Key: sr.Key}) @@ -421,31 +425,71 @@ 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 assignments says them (ADR 0549 §1, §3). The assignments' -// holds are the holders on record, as catalogue.World's Holdings are: a seat held once for the mesh that some node's -// assignments holds is held only where assignments names it (this node's naming this module), so a module claiming it on -// two machines holds it on the one assignments names, not on both. A seat no assignments holds is held by derivation, by the -// module claiming it wherever it is placed, as it was before assignments: the seeded assignments hold only the seats the -// controller had on record (twelve on the control-node), and treating their holds as every holder left nine seats with -// no holder in the user list of 2026-10-27 09:21 (issue 1945). A seat of every node is held wherever the module runs. -func heldWherePlaced(holds []buscompose.Seat, placed map[string]meshmoduleassigner.Assignments, node, module string) []buscompose.Seat { - same := func(x meshmoduleassigner.Holding, h buscompose.Seat) bool { - return (x.Scope == "" || x.Scope == catalogue.ScopeMesh) && sameSeat(x.Claim, h.Name) && x.Kind == h.Kind +// holdersAfterPass is the holders on record (each node's assignments, as the seat mesh-module-assigner holds them) as they +// stand after catalogue.SeatsToPassOn (novox/hq ADR 0660): a holder whose every assignment's manifest no longer claims +// its seat has left it, and the seat is the only claimant's. Each assignment is read with the manifest its node runs +// (Inputs.Running), else the trunk's, else the running build of a held module; one with none is unread and keeps what it +// holds. A pass is said as a gap and recorded nowhere (ADR 0660 §4). Pure. +func holdersAfterPass(in Inputs) ([]catalogue.Held, []Gap) { + var held []catalogue.Held + for _, node := range slices.Sorted(maps.Keys(in.Placed)) { + for _, x := range in.Placed[node].Holds { + if x.Scope != "" && x.Scope != catalogue.ScopeMesh { + continue + } + held = append(held, catalogue.Held{Claim: x.Claim, Scope: catalogue.ScopeMesh, Node: node, Module: x.Module, Site: x.Site, Kind: x.Kind}) + } } + var assignments []catalogue.Assignment + for _, node := range slices.Sorted(maps.Keys(in.Assigned)) { + for _, module := range in.Assigned[node] { + m, ok := in.Running[node+"."+module] + if !ok { + m, ok = in.Manifests[module] + } + if !ok { + m, ok = in.Held[module] + } + if !ok { + m = catalogue.Manifest{Module: module} + } + assignments = append(assignments, catalogue.Assignment{Node: node, Manifest: m, Unread: !ok}) + } + } + passes, said := catalogue.SeatsToPassOn(held, assignments) + var gaps []Gap + for _, p := range passes { + held = slices.DeleteFunc(held, func(h catalogue.Held) bool { return h.Kind == p.From.Kind && sameSeat(h.Claim, p.Seat.Name) }) + p.To.Site = p.From.Site + held = append(held, p.To) + gaps = append(gaps, Gap{Input: "seat " + p.Seat.Name, Why: fmt.Sprintf( + "%s on %s is on record as its holder and its manifest no longer claims it: composed as held by %s on %s, not recorded (ADR 0660)", + p.From.Module, p.From.Node, p.To.Module, p.To.Node)}) + } + for _, w := range said { + gaps = append(gaps, Gap{Input: "seat passes", Why: w}) + } + return held, gaps +} + +// heldWherePlaced is a module's holds on a node as the catalogue's assignments says them (ADR 0549 §1, §3). The assignments' +// holds are the holders on record, as catalogue.World's Holdings are, after the passes of holdersAfterPass: a seat held +// once for the mesh that some node's assignments holds is held only where assignments names it (this node's naming this +// module), so a module claiming it on two machines holds it on the one assignments names, not on both. A seat no +// assignments holds is held by derivation, by the module claiming it wherever it is placed, as it was before assignments: +// the seeded assignments hold only the seats the controller had on record (twelve on the control-node), and treating their +// holds as every holder left nine seats with no holder in the user list of 2026-10-27 09:21 (issue 1945). A seat of every +// node is held wherever the module runs. +func heldWherePlaced(holds []buscompose.Seat, holders []catalogue.Held, node, module string) []buscompose.Seat { + same := func(x catalogue.Held, h buscompose.Seat) bool { return sameSeat(x.Claim, h.Name) && x.Kind == h.Kind } var out []buscompose.Seat for _, h := range holds { if h.Scope != "" && h.Scope != catalogue.ScopeMesh { out = append(out, h) continue } - onRecord := false - for _, p := range placed { - if slices.ContainsFunc(p.Holds, func(x meshmoduleassigner.Holding) bool { return same(x, h) }) { - onRecord = true - break - } - } - if !onRecord || slices.ContainsFunc(placed[node].Holds, func(x meshmoduleassigner.Holding) bool { return same(x, h) && x.Module == module }) { + onRecord := slices.ContainsFunc(holders, func(x catalogue.Held) bool { return same(x, h) }) + if !onRecord || slices.ContainsFunc(holders, func(x catalogue.Held) bool { return same(x, h) && x.Node == node && x.Module == module }) { out = append(out, h) } }