diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go index 3aca3716..e00d44d3 100644 --- a/modules/mesh-delivery-indexer/judge/rules.go +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -5529,6 +5529,19 @@ func StateProblems(m catalogue.Manifest) []string { } } for _, r := range m.Reads { + if seat, state, isSeat, err := catalogue.SeatRead(r); isSeat { + // A read of a seat holder's state (design 47 G A-116, A-128): the seat declares the state; the holder is resolved + // where it is read. + switch s, known := catalogue.SeatNamed(seat); { + case err != nil: + problems = append(problems, fmt.Sprintf("%s reads %v", m.Module, err)) + case !known: + problems = append(problems, fmt.Sprintf("%s reads %q, a state of the seat %q, "+catalogue.UndeclaredSeat, m.Module, r, seat)) + case !slices.Contains(s.States, state): + problems = append(problems, fmt.Sprintf("%s reads %q, and the seat %s declares no state %q (design 47 G A-128)", m.Module, r, seat, state)) + } + continue + } module, _, err := catalogue.ReadState(r) if err != nil { problems = append(problems, fmt.Sprintf("%s reads %v", m.Module, err)) diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index 7ef23cb4..f1f9906e 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -6416,3 +6416,22 @@ func TestRulesOther(t *testing.T) { t.Run("TakesSettingsIsRefusedWhereItCannotMeanAnything", caseTakesSettingsIsRefusedWhereItCannotMeanAnything) t.Run("TheInstallationCheckReadsADefault", caseTheInstallationCheckReadsADefault) } + +// A read of a seat holder's state, `seat:.`, is accepted where the seat declares the state and refused, by name, +// where it does not or the seat is unknown (design 47 G A-116, A-128). +func TestASeatNamedReadIsJudgedAgainstTheSeatsStates(t *testing.T) { + was := catalogue.Seats() + t.Cleanup(func() { catalogue.UseSeats(was) }) + catalogue.UseSeats([]catalogue.Seat{{Name: "mesh-provisioner", Scope: catalogue.ScopeMesh, Decision: "test", States: []string{"plans"}}}) + for _, row := range []struct{ read, says string }{ + {"seat:mesh-provisioner.plans", ""}, + {"seat:mesh-provisioner.nothing", `declares no state "nothing"`}, + {"seat:mesh-unknown.plans", "mesh-unknown"}, + {"seat:mesh-provisioner", "does not name a seat and its state"}, + } { + problems := strings.Join(StateProblems(catalogue.Manifest{Module: "reader", Reads: []string{row.read}}), "\n") + if row.says == "" && problems != "" || row.says != "" && !strings.Contains(problems, row.says) { + t.Errorf("%s: problems %q, want %q", row.read, problems, row.says) + } + } +} diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index e48802c2..6b8dad52 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -4964,6 +4964,39 @@ func TestTheGrantsPartKeepsWhatEachNodeRuns(t *testing.T) { } } +// A module reading a seat holder's state, `seat:.`, is granted the read of the module holding the seat on record +// (design 47 G A-116). +func TestASeatNamedReadIsResolvedToItsHolder(t *testing.T) { + for _, row := range []struct { + name, holder string + reads, want []string + }{ + {"resolved to the holder on record", "prov-one", []string{"seat:mesh-provisioner.plans", "mesh-vault.node-logins"}, + []string{"prov-one.plans", "mesh-vault.node-logins"}}, + {"a new holder is read with no change of the manifest", "prov-two", []string{"seat:mesh-provisioner.provisions"}, + []string{"prov-two.provisions"}}, + {"no holder on record stays as written", "", []string{"seat:mesh-provisioner.plans"}, []string{"seat:mesh-provisioner.plans"}}, + } { + t.Run(row.name, func(t *testing.T) { + in := grants.Inputs{Nodes: []string{"a"}, Assigned: map[string][]string{"a": {"mod-t"}}, + Manifests: map[string]catalogue.Manifest{"mod-t": {Module: "mod-t", Version: "1", RunsAs: "mod-t", Reads: row.reads, + OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mod-t/broker"}}}}} + if row.holder != "" { + in.Placed = map[string]meshmoduleassigner.Assignments{"a": {Node: "a", + Holds: []meshmoduleassigner.Holding{{Claim: "mesh-provisioner", Scope: "mesh", Module: row.holder}}}} + } + recs, _ := grants.ListRecords(in) + var got []string + for _, d := range recs.Assigned["a"] { + got = d.Reads + } + if !slices.Equal(got, row.want) { + t.Errorf("reads = %v, want %v", got, row.want) + } + }) + } +} + // A module naming a seat's view, `seat:.views.`, is granted the view of the module holding the seat on record, so a // new holder is granted with no change of its manifest (design 47 G ยง616). func TestASeatNamedViewIsResolvedToItsHolder(t *testing.T) { diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 6ac6d5d4..133dc6b4 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -88,6 +88,7 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { } declare := func(m catalogue.Manifest) buscompose.Declared { m.Consumes = resolveSeatViews(m.Consumes, holders) + m.Reads = resolveSeatReads(m.Reads, holders) d := declaredFor(m, seats, declarers) if len(in.Placed) > 0 { d.Holds = heldWherePlaced(d.Holds, holders, node, m.Module) @@ -213,6 +214,36 @@ func resolveSeatViews(consumes []string, holders []catalogue.Held) []string { return out } +// resolveSeatReads resolves each `seat:.` of a module's reads to `.`, the module holding the seat +// on record, as resolveSeatViews does a view: a new holder is read with no change of the manifest (design 47 G A-116). An entry +// whose seat has no holder on record stays as written, and grants nothing. Pure. +func resolveSeatReads(reads []string, holders []catalogue.Held) []string { + var out []string + for i, r := range reads { + rest, isSeat := strings.CutPrefix(r, seatViewConsume) + seatName, state, named := strings.Cut(rest, ".") + if !isSeat || !named || state == "" || strings.Contains(state, ".") { + if out != nil { + out = append(out, r) + } + continue + } + if out == nil { + out = slices.Clone(reads[:i]) + } + holder := slices.IndexFunc(holders, func(h catalogue.Held) bool { return sameSeat(h.Claim, seatName) }) + if holder < 0 { + out = append(out, r) + continue + } + out = append(out, holders[holder].Module+"."+state) + } + if out == nil { + return reads + } + return out +} + // consumedOf splits what a manifest consumes as the composer grants it: a seat's event on the seat's own subject (its // watches), every other by its emitter's (novox/hq ADR 0118). A placed module and the node-engine's own manifest are split // alike.