diff --git a/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/candidates.go b/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/candidates.go new file mode 100644 index 00000000..5ca67701 --- /dev/null +++ b/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/candidates.go @@ -0,0 +1,36 @@ +package meshcatalogclient + +import ( + "context" + "encoding/json" + "fmt" + + seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// WireCandidatesProcess hands each candidate's `catalog-revision` to hook (design 47 G A-185, A-193, A-199): a candidate's +// revision no Catalog keeps, heard once on the event's one subscription of the process's reader, beside every watched +// branch's Catalog and whether or not the part follows any, so the runner places a run's pipelines on it. A trunk revision +// is passed over (each branch's Catalog keeps it); data that is no such event is an error. The part's manifest consumes the +// seat's event catalog-revision. +func WireCandidatesProcess(r *views.ProcessReader, hook func(ctx context.Context, ev seat.CatalogRevisionEvent) error) error { + if hook == nil { + return fmt.Errorf("meshcatalogclient: a candidates hook is a function") + } + return r.AdoptEvent(seat.Seat, seat.EventCatalogRevision, candidateHear(hook)) +} + +// candidateHear is the handler of catalog-revision that hands a candidate's revision to hook. +func candidateHear(hook func(ctx context.Context, ev seat.CatalogRevisionEvent) error) func(context.Context, []byte) error { + return func(ctx context.Context, data []byte) error { + var ev seat.CatalogRevisionEvent + if err := json.Unmarshal(data, &ev); err != nil || ev.Revision <= 0 { + return fmt.Errorf("meshcatalogclient: not a %s event: %s", seat.EventCatalogRevision, clipBytes(data)) + } + if ev.Kind != KindCandidate { + return nil + } + return hook(ctx, ev) + } +} diff --git a/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/catalog_view_test.go b/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/catalog_view_test.go index 6c74285e..760d8bfc 100644 --- a/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/catalog_view_test.go +++ b/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/catalog_view_test.go @@ -162,3 +162,29 @@ func TestCatalogIsAViewPerWatchedBranch(t *testing.T) { t.Error("a Catalog of no branch was made") } } + +// A-185, A-193, A-199: a candidate's catalog-revision reaches the hook, adopted on the event's one subscription of the +// reader, also with no watched branch's Catalog; a trunk revision does not, and data that is no such event is said. +func TestCandidatesReachTheHook(t *testing.T) { + var hooked []seat.CatalogRevisionEvent + hook := func(_ context.Context, ev seat.CatalogRevisionEvent) error { hooked = append(hooked, ev); return nil } + r := &views.ProcessReader{Module: "runner"} + if err := WireCandidatesProcess(r, hook); err != nil { + t.Fatal(err) + } + if err := WireCandidatesProcess(r, nil); err == nil { + t.Error("a nil hook was taken") + } + hear := candidateHear(hook) + for _, data := range [][]byte{revisionEvent("main", KindTrunk, 3), revisionEvent("main", KindCandidate, 4)} { + if err := hear(context.Background(), data); err != nil { + t.Fatal(err) + } + } + if err := hear(context.Background(), []byte(`not json`)); err == nil { + t.Error("data that is no catalog-revision was taken") + } + if len(hooked) != 1 || hooked[0].Revision != 4 || hooked[0].Kind != KindCandidate { + t.Errorf("the hook was handed %+v, want the candidate's revision 4 alone", hooked) + } +} diff --git a/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go b/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go index 504752fc..5919ecb2 100644 --- a/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go +++ b/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go @@ -1281,6 +1281,8 @@ type RevisionInput struct { Heads string `json:"heads,omitempty"` // a kept revision; absent: the newest of trunk, or of the candidate named Revision int64 `json:"revision,omitempty"` + // a candidate's source revision: the revision of the branch's series taken in from it, with its entries (design 47 G section 441) + Source int64 `json:"source,omitempty"` } // RevisionAnswer is what revision answers. diff --git a/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner/contract.go b/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner/contract.go index 69160e95..f9bdeca2 100644 --- a/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner/contract.go +++ b/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner/contract.go @@ -458,8 +458,10 @@ type Pipeline struct { // yes when the module is over its test budget (design 47 G section 419) OverBudget bool `json:"over-budget,omitempty"` // the pipeline's key, .., a branch escaped as a key's name is - Pipeline string `json:"pipeline,omitempty"` - Repository string `json:"repository,omitempty"` + Pipeline string `json:"pipeline,omitempty"` + // for a pipeline a package's publish started, that publish (A-202) + Published *Published `json:"published,omitempty"` + Repository string `json:"repository,omitempty"` // the catalog graph revision of the branch the pipeline runs for Revision int64 `json:"revision,omitempty"` // the run on request's id, for a pipeline of a run; such a pipeline delivers nothing @@ -573,6 +575,8 @@ type PipelineStarted struct { Module string `json:"module"` // the pipeline's key, .., a branch escaped as a key's name is Pipeline string `json:"pipeline"` + // for a pipeline a package's publish started, that publish (A-202) + Published *Published `json:"published,omitempty"` // the catalog graph revision of the branch the pipeline runs for Revision int64 `json:"revision,omitempty"` // the run on request's id, for a pipeline of a run; such a pipeline delivers nothing @@ -584,6 +588,14 @@ type PipelineStarted struct { V int64 `json:"v"` } +// Published is the publish of a package that started a pipeline: a newer publish of the same artifact on the same branch supersedes it (design 47 G section 31, A-202). +type Published struct { + // the package whose publish started the pipeline + Artifact string `json:"artifact"` + // the version that publish named + Version string `json:"version"` +} + // RunStarted is a run on request's start, with its members at their commits (ADR 0666 section 18). type RunStarted struct { At *time.Time `json:"at,omitempty"` diff --git a/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json b/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json index 0233171e..1c776e5d 100644 --- a/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json +++ b/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json @@ -266,6 +266,10 @@ "type": "string", "description": "a candidate's heads" }, + "source": { + "type": "integer", + "description": "a candidate's source revision: the revision of the branch's series taken in from it, with its entries (design 47 G section 441)" + }, "after": { "type": "string", "description": "the last module of the page before; absent: the first page" diff --git a/modules/mesh-delivery-contracts/seats/mesh-pipeline-runner/seat.json b/modules/mesh-delivery-contracts/seats/mesh-pipeline-runner/seat.json index 333a66f8..60c9f06c 100644 --- a/modules/mesh-delivery-contracts/seats/mesh-pipeline-runner/seat.json +++ b/modules/mesh-delivery-contracts/seats/mesh-pipeline-runner/seat.json @@ -978,6 +978,24 @@ "name" ] }, + "Published": { + "type": "object", + "properties": { + "artifact": { + "type": "string", + "description": "the package whose publish started the pipeline" + }, + "version": { + "type": "string", + "description": "the version that publish named" + } + }, + "description": "the publish of a package that started a pipeline: a newer publish of the same artifact on the same branch supersedes it (design 47 G section 31, A-202)", + "required": [ + "artifact", + "version" + ] + }, "Pipeline": { "type": "object", "properties": { @@ -1083,6 +1101,10 @@ "$ref": "#/defs/LogEntry" }, "description": "the step-log entries of the steps that ended and are not appended to the step log yet, in order; written with the step's end in one write and cleared when appended (design 47 G sections 486, 606)" + }, + "published": { + "$ref": "#/defs/Published", + "description": "for a pipeline a package's publish started, that publish (A-202)" } }, "description": "one pipeline as the runner keeps it in its state pipelines, by compare-and-set (design 47 *The runner's kept data*)", @@ -1272,6 +1294,10 @@ "at": { "type": "string", "format": "date-time" + }, + "published": { + "$ref": "#/defs/Published", + "description": "for a pipeline a package's publish started, that publish (A-202)" } }, "description": "a pipeline's start", diff --git a/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts b/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts index dfe29ba2..9d60bb13 100644 --- a/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts +++ b/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts @@ -731,6 +731,8 @@ export interface RevisionInput { "heads"?: string; /** a kept revision; absent: the newest of trunk, or of the candidate named */ "revision"?: number; + /** a candidate's source revision: the revision of the branch's series taken in from it, with its entries (design 47 G section 441) */ + "source"?: number; } /** is what revision answers. */ diff --git a/modules/mesh-delivery-contracts/ts/seats/meshpipelinerunner/index.d.ts b/modules/mesh-delivery-contracts/ts/seats/meshpipelinerunner/index.d.ts index fa38aa41..b878f1ab 100644 --- a/modules/mesh-delivery-contracts/ts/seats/meshpipelinerunner/index.d.ts +++ b/modules/mesh-delivery-contracts/ts/seats/meshpipelinerunner/index.d.ts @@ -218,6 +218,8 @@ export interface Pipeline { "over-budget"?: boolean; /** the pipeline's key, .., a branch escaped as a key's name is */ "pipeline"?: string; + /** for a pipeline a package's publish started, that publish (A-202) */ + "published"?: Published; "repository"?: string; /** the catalog graph revision of the branch the pipeline runs for */ "revision"?: number; @@ -293,6 +295,8 @@ export interface PipelineStarted { "module": string; /** the pipeline's key, .., a branch escaped as a key's name is */ "pipeline": string; + /** for a pipeline a package's publish started, that publish (A-202) */ + "published"?: Published; /** the catalog graph revision of the branch the pipeline runs for */ "revision"?: number; /** the run on request's id, for a pipeline of a run; such a pipeline delivers nothing */ @@ -304,6 +308,14 @@ export interface PipelineStarted { "v": number; } +/** the publish of a package that started a pipeline: a newer publish of the same artifact on the same branch supersedes it (design 47 G section 31, A-202) */ +export interface Published { + /** the package whose publish started the pipeline */ + "artifact": string; + /** the version that publish named */ + "version": string; +} + /** a run on request's start, with its members at their commits (ADR 0666 section 18) */ export interface RunStarted { "at"?: string; diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_branches_test.go b/modules/mesh-delivery-indexer/internal/index/catalog_branches_test.go index d7e625f9..7de43afc 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog_branches_test.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog_branches_test.go @@ -63,4 +63,36 @@ func TestACatalogRevisionIsKeptPerWatchedBranch(t *testing.T) { if a := answer("beta", 0); !a.NoneYet { t.Errorf("a branch with no revision answered %+v, want none-yet", a) } + // A-205: `source` answers the branch's revision taken in from that source revision; a candidate's group alone answers its + // newest revision, whatever its heads. + bySource := func(branch string, source int64) (meshcatalog.RevisionAnswer, error) { + return Revision(ctx, ix.Revisions, "indexer", "reader", meshcatalog.RevisionInput{Branch: branch, Source: source}) + } + if a, err := bySource("main", 5); err != nil || a.Revision.Revision != 1 || a.Entries[0].Commit != "c1" { + t.Errorf("main's source 5 answered %+v (%v)", a.Revision, err) + } + if a, err := bySource("feature/x=2ey", 7); err != nil || a.Revision.Revision != 1 || a.Entries[0].Commit != "c2" { + t.Errorf("the branch's source 7 answered %+v (%v), want its revision 1 at c2", a.Revision, err) + } + if a, err := bySource("feature/x=2ey", 8); err != nil || a.Revision.Revision != 2 { + t.Errorf("the branch's source 8 answered %+v (%v)", a.Revision, err) + } + if _, err := bySource("feature/x=2ey", 9); err == nil { + t.Error("a source no revision was taken in from was answered") + } + for i, heads := range []string{"h1", "h2"} { + rev, objects, err := CatalogRevisionOf(meshcatalog.CatalogRevision{Revision: int64(3 + i), Kind: "candidate", Group: "run-ab", Heads: heads}, tip("c9")) + if err != nil { + t.Fatal(err) + } + if err := KeepRevision(ctx, ix.Revisions, rev, objects); err != nil { + t.Fatal(err) + } + } + if a, err := Revision(ctx, ix.Revisions, "indexer", "reader", meshcatalog.RevisionInput{Group: "run-ab"}); err != nil || a.Revision.Revision != 4 || a.Revision.Heads != "h2" || a.Entries[0].Commit != "c9" { + t.Errorf("a group alone answered %+v (%v), want its newest revision 4", a.Revision, err) + } + if _, err := Revision(ctx, ix.Revisions, "indexer", "reader", meshcatalog.RevisionInput{Group: "run-none"}); err == nil { + t.Error("a candidate never kept was answered") + } } diff --git a/modules/mesh-delivery-indexer/internal/index/revision_verb.go b/modules/mesh-delivery-indexer/internal/index/revision_verb.go index d279affc..3a0d48e0 100644 --- a/modules/mesh-delivery-indexer/internal/index/revision_verb.go +++ b/modules/mesh-delivery-indexer/internal/index/revision_verb.go @@ -71,11 +71,21 @@ func Revision(ctx context.Context, o Objects, holder, caller string, in meshcata } name := RevisionObject(newest) switch { + case in.Group != "" && in.Heads == "": + // A candidate's group alone answers its newest revision, whatever its heads (A-205). + if name, err = newestCandidate(ctx, o, in.Group); err != nil { + return out, err + } case in.Group != "" || in.Heads != "": if in.Group == "" || in.Heads == "" { return out, fmt.Errorf("a candidate's revision is named by its group and its heads, both") } name = RevisionObject(meshcatalog.CatalogRevision{Kind: "candidate", Group: in.Group, Heads: in.Heads}) + case in.Source != 0: + // The branch's revision taken in from that source revision (A-205). + if name, err = revisionOfSource(ctx, o, branch, in.Source); err != nil { + return out, err + } case in.Revision != 0 && in.Revision != newest.Revision: name = RevisionObject(meshcatalog.CatalogRevision{Branch: branch, Revision: in.Revision}) } @@ -121,3 +131,62 @@ func RevisionTool(o Objects, holder string) stdio.Tool { return Revision(context.Background(), o, holder, stdio.CallerOf(args), in) }} } + +// newestCandidate is the object of the newest revision a candidate group keeps, over whatever heads it was kept at (A-205). +func newestCandidate(ctx context.Context, o Objects, group string) (string, error) { + names, err := o.Names(ctx) + if err != nil { + return "", fmt.Errorf("the candidate revisions cannot be listed: %w", err) + } + best, newest := "", int64(-1) + for _, n := range names { + if !strings.HasPrefix(n, "candidate."+group+".") { + continue + } + rev, _, err := ReadRevision(ctx, o, n) + if err != nil { + return "", err + } + if rev.Group == group && rev.Revision > newest { + best, newest = n, rev.Revision + } + } + if best == "" { + return "", fmt.Errorf("the catalog keeps no candidate of the group %s", group) + } + return best, nil +} + +// revisionOfSource is the object of the newest revision of branch taken in from the source revision source (A-205); a source +// none was taken in from is an error. +func revisionOfSource(ctx context.Context, o Objects, branch string, source int64) (string, error) { + names, err := o.Names(ctx) + if err != nil { + return "", fmt.Errorf("the catalog graph revisions cannot be listed: %w", err) + } + number := TrunkRevisionOf + if !isTrunkBranch(branch) { + number = func(n string) (int64, bool) { return BranchRevisionOf(branch, n) } + } + var found []int64 + for _, n := range names { + if r, ok := number(n); ok { + found = append(found, r) + } + } + slices.SortFunc(found, func(a, b int64) int { return int(b - a) }) + for _, r := range found { + name := RevisionObject(meshcatalog.CatalogRevision{Branch: branch, Revision: r}) + rev, _, err := ReadRevision(ctx, o, name) + if err != nil { + return "", err + } + if rev.Source == source { + return name, nil + } + if rev.Source < source { + break + } + } + return "", fmt.Errorf("no revision of %q was taken in from the source revision %d", branch, source) +} diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go index 58a62d7b..74cf6eac 100644 --- a/modules/mesh-delivery-indexer/judge/rules.go +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -2938,7 +2938,7 @@ func parseManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.M } } problems = append(problems, invokeProblems(m)...) - problems = append(problems, waitProblems(m)...) + problems = append(problems, needsApprovalProblems(m)...) problems = append(problems, replacesProblems(m)...) problems = append(problems, UnitSettingProblems(m)...) problems = append(problems, OwnerSettingProblems(m)...) @@ -3390,43 +3390,103 @@ func replacesProblems(m catalogue.Manifest) []string { return problems } -// waitProblems judges the event families a module says a session may wait on (novox/hq ADR 0367 §2, §6): -// the tool runner's alone, each named whole — emitter and event, with no wildcard — and each once. Refused -// here, in the manifest's words, rather than at the composition of the bus's users. -func waitProblems(m catalogue.Manifest) []string { - if len(m.Waits) == 0 { - return nil - } - if !catalogue.IsToolRunner(m) { - return []string{fmt.Sprintf("%s says what a session waits on; only the tool runner's module (it says "+ - "\"tool-runner\": true) declares waits (novox/hq ADR 0367 §2)", m.Module)} - } +// needsApprovalProblems refuses a mark the mesh cannot honour (design 46 *The approval mark* §4; design 34 every-event +// §32; ADR 0286 §4): a mark on a tool its tools do not list (where it lists them; a bundle's own tools are checked by +// the tool runner at load), on a verb its claims do not serve, or on an event neither its emits nor its streams' events +// carry; one name both one of its tools and one of its events; a tool or verb marked, or a held seat verb its seat +// marks, without operator-channel in uses (§7: a module marking only events needs none); and a tool or verb marked +// while every bundle it builds is run by one of its process resources, so its own program answers and no tool runner +// gates them. Each refusal names the entry and the rule. +func needsApprovalProblems(m catalogue.Manifest) []string { var problems []string - seen := map[string]bool{} - for _, w := range m.Waits { - if strings.ContainsAny(w, "*>") { - problems = append(problems, fmt.Sprintf("%s waits on %q: a family is named one by one, never a "+ - "wildcard (novox/hq ADR 0367 §2)", m.Module, w)) + tools, events, served := map[string]bool{}, map[string]bool{}, map[string]map[string]bool{} + for _, t := range m.Tools { + tools[t] = true + } + for _, e := range m.Emits { + events[e] = true + } + for _, st := range m.Streams { + for _, e := range st.Events { + events[e] = true + } + } + for _, c := range m.Claims { + served[c.Name] = map[string]bool{} + for _, v := range c.ServesFor(m) { + served[c.Name][v] = true + } + } + var acts []string // the tools and verbs it marks, and the held seat verbs its seats mark + for _, e := range m.NeedsApproval { + seat, verb, dotted := strings.Cut(e, ".") + switch { + case tools[e] && events[e]: + problems = append(problems, fmt.Sprintf("%s marks %q in needs-approval, which names both one of its tools "+ + "and one of its events: an entry names one thing (design 46 approval mark §4)", m.Module, e)) + case events[e]: + case tools[e], dotted && served[seat][verb]: + acts = append(acts, e) + case dotted && served[seat] != nil: + problems = append(problems, fmt.Sprintf("%s marks the verb %q in needs-approval, which its claim of %s does "+ + "not serve (ADR 0286 §4)", m.Module, e, seat)) + case dotted: + problems = append(problems, fmt.Sprintf("%s marks %q in needs-approval, which is neither a verb its claims "+ + "serve nor an event its emits or its streams' events carry (ADR 0286 §4; design 34 every-event §32)", m.Module, e)) + case len(m.Tools) > 0: + problems = append(problems, fmt.Sprintf("%s marks the tool %q in needs-approval, which its tools do not list "+ + "(ADR 0286 §4)", m.Module, e)) + default: // a tool its bundle registers: the tool runner checks it at load (§4) + acts = append(acts, e) + } + } + marked := len(acts) > 0 + for _, c := range m.Claims { + seat, ok := catalogue.SeatNamed(c.Name) + if !ok { continue } - tokens := strings.Split(w, ".") - whole := len(tokens) >= 2 && name.MatchString(tokens[0]) - for _, t := range tokens[1:] { - whole = whole && waitToken.MatchString(t) + for _, v := range seat.Serves { + if v.NeedsApproval && served[c.Name][v.Name] { + acts = append(acts, c.Name+"."+v.Name) + } } - if !whole { - problems = append(problems, fmt.Sprintf("%s waits on %q, which does not name an emitter and an "+ - "event: a family is ., as consumes names one", m.Module, w)) - continue - } - if seen[w] { - problems = append(problems, fmt.Sprintf("%s waits on %q twice", m.Module, w)) - } - seen[w] = true + } + if len(acts) > 0 && !slices.Contains(m.Uses, "operator-channel") { + problems = append(problems, fmt.Sprintf("%s marks or holds a marked tool or verb (%s) without operator-channel in "+ + "uses: its tool runner asks the operator through that seat (design 46 approval mark §4, §7)", + m.Module, strings.Join(acts, ", "))) + } + if marked && everyBundleRunByAProcess(m) { + problems = append(problems, fmt.Sprintf("%s marks a tool or verb while every bundle it builds is run by one of "+ + "its process resources: its own program answers them and no tool runner gates them, so the mark would guard "+ + "nothing (design 46 approval mark §4)", m.Module)) } return problems } +// everyBundleRunByAProcess says every artifact of kind bundle the module builds is the artifact of one of its process +// resources (none built is every one run so: no tool runner loads any). +func everyBundleRunByAProcess(m catalogue.Manifest) bool { + run := map[string]bool{} + for _, r := range m.Resources { + if r["type"] == "process" { + if a, ok := r["artifact"].(string); ok { + run[a] = true + } + } + } + if m.Build == nil { + return true + } + for _, a := range m.Build.Artifacts { + if a.Kind == "bundle" && !run[a.Name] { + return false + } + } + return true +} + // whileStoppedProblems judges one container's maintenance window against its own definition // (novox/hq ADR 0189). // @@ -3646,8 +3706,6 @@ var renamed = map[string]string{ var toolName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) -var waitToken = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) - var engineGeneric = map[string]string{ "database": "postgres-database, mssql-database, mariadb-database", "db": "postgres-database, mssql-database, mariadb-database", diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index c72b7417..90d632ba 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -3156,28 +3156,56 @@ func caseAVerbWithAnUnknownKeyIsRefused(t *testing.T) { } } -// A family is named one by one, never by a wildcard, and only by the tool runner's module (ADR 0367 §2, §6): -// each refusal is said in the manifest's words, at parse, rather than at the composition of the bus's users. -func caseAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner(t *testing.T) { - for manifest, want := range map[string]string{ - `{"module":"shop","version":"1.0","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module", - `{"module":"node-tools","version":"1.0","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module", // its name says nothing (issue 774) - `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["mesh-knowledge.*"]}`: "never a wildcard", - `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["**"]}`: "never a wildcard", - `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["mesh-knowledge.>"]}`: "never a wildcard", - `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["*.reviewed"]}`: "never a wildcard", - `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["reviewed"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["mesh-knowledge..reviewed"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["Mesh-Knowledge.reviewed x"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["a.b","a.b"]}`: "twice", +// EE-D4 (design 34 every-event §10; ADR 0670 §2, ADR 0578 §1): the manifest no longer names what a session waits on, so +// `waits` is a field the validator does not know: refused by the checker's Shelf naming it, set aside to wait by the +// indexer's ShelfWaiting; the tool runner's manifest without it is accepted. +func caseAManifestCarryingWaitsIsAFieldNotKnown(t *testing.T) { + raws := map[string][]byte{ + "node-tools": []byte(`{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["mesh-knowledge.reviewed"]}`), + "runner": []byte(`{"module":"runner","version":"1.0","tool-runner":true}`), + } + if strict, _ := Shelf(raws, nil, 0); len(strict) != 1 || !strings.Contains(strings.Join(strict["node-tools"], " "), "waits") { + t.Errorf("Shelf on a manifest carrying waits: %v", strict) + } + if refused, waits, _ := ShelfWaiting(raws, nil, 0, nil, nil); len(refused) != 0 || + !reflect.DeepEqual(waits, map[string][]string{"node-tools": {"waits"}}) { + t.Errorf("ShelfWaiting on a manifest carrying waits: refused %v, waits %v", refused, waits) + } +} + +// AM-D1 (design 46 approval mark §4, §21; design 34 every-event §32): a mark nothing enforces is refused, naming the +// entry and the rule; the login shell's and the forge's manifests of §21 are accepted, and a module marking only events +// needs no operator-channel. +func caseAMarkNothingEnforcesIsRefused(t *testing.T) { + const bundle = `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}` + const oc = `"uses":["operator-channel"]` + shell := `{"module":"zsh","claims":[{"name":"node-login-shell","scope":"node","serves":["execute"]}],"tools":["zsh_config"],` + bundle + for _, row := range []struct{ name, manifest, want string }{ + {"a tool its tools do not list", `{"module":"shop","tools":["sell"],"needs-approval":["refund"],` + oc + `,` + bundle + `}`, "its tools do not list"}, + {"a verb its claims do not serve", `{"module":"shop","claims":[{"name":"shop-till","serves":["price"]}],"tools":["price"],"needs-approval":["shop-till.refund"],` + oc + `,` + bundle + `}`, "does not serve"}, + {"a marked tool without operator-channel", `{"module":"shop","tools":["sell"],"needs-approval":["sell"],` + bundle + `}`, "without operator-channel"}, + {"a held seat verb its seat marks without operator-channel", shell + `}`, "without operator-channel"}, + {"every bundle run by its own process", `{"module":"shop","tools":["sell"],"needs-approval":["sell"],` + oc + `,` + bundle + + `,"resources":[{"id":"serve","type":"process","artifact":"tools","user":"shop"}]}`, "every bundle it builds is run"}, + {"one name a tool and an event", `{"module":"shop","tools":["sold"],"emits":["sold"],"needs-approval":["sold"],` + oc + `,` + bundle + `}`, "both one of its tools"}, + {"an event it does not carry", `{"module":"shop","emits":["sold"],"needs-approval":["sold.again"]}`, "nor an event its emits"}, + {"the login shell", shell + `,` + oc + `}`, ""}, + {"the forge", `{"module":"forge","needs-approval":["gitea_branch_protection_loosen"],` + oc + `,"build":{"artifacts":[` + + `{"name":"code","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"],"loads":["tools/index.js"]},` + + `{"name":"repositories","kind":"bundle","language":"typescript","entrypoints":["repositories/index.js"]}]},` + + `"resources":[{"id":"repositories","type":"process","artifact":"repositories","user":"forge"}]}`, ""}, + {"only events, no operator-channel", `{"module":"shop","emits":["sold"],"streams":[{"name":"ledger","events":["booked"],"max-age-days":1,"max-mib":1}],"needs-approval":["sold","booked"]}`, ""}, } { - _, err := ParseManifest([]byte(manifest)) - if err == nil { - t.Errorf("accepted: %s", manifest) - continue - } - if !strings.Contains(err.Error(), want) { - t.Errorf("%s refused for the wrong reason (want %q): %v", manifest, want, err) + var problems []string + WithSeats([]catalogue.Seat{{Name: "node-login-shell", Serves: []catalogue.Verb{{Name: "execute", NeedsApproval: true}}}}, func() { + _, problems = Manifest(versioned(t, []byte(row.manifest)), nil) + }) + got := strings.Join(problems, "\n") + switch { + case row.want == "" && got != "": + t.Errorf("%s is refused: %s", row.name, got) + case row.want != "" && !strings.Contains(got, row.want): + t.Errorf("%s: problems %q, want one saying %q", row.name, got, row.want) } } } @@ -3983,7 +4011,7 @@ func caseNoModuleMayDeclareTheLoginShell(t *testing.T) { t.Fatalf("declaring login-shell was not refused by name: %q", got) } // And a shell module claiming the mesh's seat, serving execute, is what the seat is for. - m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` + + m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],"uses":["operator-channel"],` + `"claims":[{"name":"node-login-shell","scope":"node"}]}`)) if err != nil { t.Fatal(err) @@ -6355,7 +6383,7 @@ func TestRulesBus(t *testing.T) { t.Run("AServedValueNamingSomethingTheMeshDoesNotHaveIsRefused", caseAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused) t.Run("ASiteScopedProvisionIsRefused", caseASiteScopedProvisionIsRefused) t.Run("AStateNameIsLocalAndOneToken", caseAStateNameIsLocalAndOneToken) - t.Run("AWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner", caseAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner) + t.Run("AManifestCarryingWaitsIsAFieldNotKnown", caseAManifestCarryingWaitsIsAFieldNotKnown) t.Run("AWorkQueueStreamIsReadByExactlyOneProcessOfItsModule", caseAWorkQueueStreamIsReadByExactlyOneProcessOfItsModule) t.Run("AWorkQueueStreamsValuesAreBounded", caseAWorkQueueStreamsValuesAreBounded) t.Run("AZoneMustNameOneOfTheModulesListens", caseAZoneMustNameOneOfTheModulesListens) @@ -6391,6 +6419,7 @@ func TestRulesCatalogue(t *testing.T) { // TestRulesManifests runs the tests of one kind as subtests, each by its former name. func TestRulesManifests(t *testing.T) { t.Run("AMalformedEnvironmentIsRefusedAtParse", caseAMalformedEnvironmentIsRefusedAtParse) + t.Run("AMarkNothingEnforcesIsRefused", caseAMarkNothingEnforcesIsRefused) t.Run("AMalformedFamilyIsRefused", caseAMalformedFamilyIsRefused) t.Run("AMalformedPromptIsRefused", caseAMalformedPromptIsRefused) t.Run("AManifestIsJudgedAgainstTheListOfItsTree", caseAManifestIsJudgedAgainstTheListOfItsTree) diff --git a/modules/mesh-delivery-indexer/testdata/beside/CAPTURED b/modules/mesh-delivery-indexer/testdata/beside/CAPTURED index 6db0cff8..f4430ea3 100644 --- a/modules/mesh-delivery-indexer/testdata/beside/CAPTURED +++ b/modules/mesh-delivery-indexer/testdata/beside/CAPTURED @@ -13,6 +13,8 @@ was mesh-controller's copy, which still held two modules that had left the catal mesh-catalog b5dbfbb784507e211372a7c9640d91f7d6dadd84 modules/*/module.json, modules/*/files/**, modules/*/templates/**, predefined.json mesh-host 233bb23bbcb1a829ce2a6164a31a5c5cc4f3d950 internal/apply/placement_guard.go +mesh-catalog 2d5358b8 (milestone 47/G's AM-K1) modules/zsh/module.json alone: the login shell uses operator-channel for + its seat's marked execute (design 46 approval mark §21), which the validator requires (§4) To move them, from mesh-delivery's modules/mesh-delivery-indexer directory, with the two repositories checked out beside mesh-delivery: diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/module.json.captured index da125af2..028f2213 100644 --- a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/module.json.captured +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/module.json.captured @@ -1,6 +1,6 @@ { "module": "zsh", - "version": "1", + "version": "0.1", "moved": true, "capabilities": [ "package-manager" @@ -14,6 +14,9 @@ ] } ], + "uses": [ + "operator-channel" + ], "tools": [ "zsh_config" ], diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate.go new file mode 100644 index 00000000..7f199d77 --- /dev/null +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate.go @@ -0,0 +1,77 @@ +package main + +import ( + "encoding/json" + "fmt" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/buscalls" + "git.novox.be/novox/mesh-sdk/go/keys" + + "mesh-delivery-pipeline/internal/pipeline" +) + +// readCandidate reads a candidate's revision whole through the catalog seat's verb revision, with the candidate's group and +// heads and the revision its event names, page by page (design 47 G A-180); an event naming no revision reads the candidate's +// newest, the one its answer names (A-205, A-222). +func readCandidate(call func(key string, body any) (json.RawMessage, error), ev catalog.CatalogRevisionEvent) (pipeline.Half, error) { + half := pipeline.Half{Revision: ev.Revision, Source: ev.Source, Entries: map[string]catalog.CatalogEntry{}} + after := "" + for { + raw, err := call(meshcatalogclient.RevisionVerb, catalog.RevisionInput{Branch: ev.Branch, Group: ev.Group, Heads: ev.Heads, Revision: ev.Revision, After: after}) + if err != nil { + return half, err + } + answer, said, ok := buscalls.Unwrap(raw) + if !ok { + return half, fmt.Errorf("%s answered: %s", meshcatalogclient.RevisionVerb, said) + } + var a catalog.RevisionAnswer + if err := json.Unmarshal(answer, &a); err != nil { + return half, fmt.Errorf("%s answered no revision: %w", meshcatalogclient.RevisionVerb, err) + } + if a.Revision != nil && ev.Revision == 0 && after == "" { + half.Revision, half.Source = a.Revision.Revision, a.Revision.Source + ev.Revision = a.Revision.Revision + } + if a.Revision == nil || a.Revision.Revision != ev.Revision || a.Revision.Group != ev.Group { + return half, fmt.Errorf("%s answered %+v for the candidate %s's revision %d", meshcatalogclient.RevisionVerb, a.Revision, ev.Group, ev.Revision) + } + for _, e := range a.Entries { + half.Entries[e.Module] = e + } + if a.Next == "" { + return half, nil + } + after = a.Next + } +} + +// placeLostRuns places, at the runner's start, each run whose pipelines have no steps yet (A-222): the candidate's +// newest revision is read through the seat's revision verb with the run's group and placed as its catalog-revision event +// would place it, so a placing lost while the runner was down is taken up. A run whose candidate cannot be read is said and +// the others go on; its first failure is returned, to be tried at the next start. +func placeLostRuns(unplaced func() ([]pipeline.UnplacedRun, error), call func(key string, body any) (json.RawMessage, error), + place func(ev catalog.CatalogRevisionEvent, half pipeline.Half) error, logf func(string, ...any)) error { + runs, err := unplaced() + if err != nil { + return err + } + var first error + for _, u := range runs { + ev := catalog.CatalogRevisionEvent{Kind: "candidate", Group: pipeline.RunGroupPrefix + u.Run, Branch: keys.EscapeName(u.Branch)} + half, err := readCandidate(call, ev) + if err == nil { + ev.Revision, ev.Source = half.Revision, half.Source + err = place(ev, half) + } + if err != nil { + logf("the run %s of %s is not placed at the start, its candidate's newest revision: %v", u.Run, u.Branch, err) + if first == nil { + first = err + } + } + } + return first +} diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate_test.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate_test.go new file mode 100644 index 00000000..26a92e9c --- /dev/null +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "errors" + "testing" + + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + + "mesh-delivery-pipeline/internal/pipeline" +) + +// A-180: a run's candidate revision is read whole through the seat's revision verb with the candidate's group, heads and +// revision, page by page; an answer of another revision is refused. +func TestReadCandidateReadsTheRevisionWhole(t *testing.T) { + ev := catalog.CatalogRevisionEvent{Kind: "candidate", Group: "run-0a1b2c3d4e5f", Heads: "h", Revision: 12, Branch: "main"} + var asked []catalog.RevisionInput + answerRev := int64(12) + call := func(key string, body any) (json.RawMessage, error) { + in := body.(catalog.RevisionInput) + asked = append(asked, in) + a := catalog.RevisionAnswer{Revision: &catalog.CatalogRevision{Revision: answerRev, Kind: "candidate", Group: in.Group}} + if in.After == "" { + a.Entries, a.Next = []catalog.CatalogEntry{{Module: "app", Fp: "src2:a"}}, "app" + } else { + a.Entries = []catalog.CatalogEntry{{Module: "lib", Fp: "src2:l"}} + } + return json.Marshal(a) + } + half, err := readCandidate(call, ev) + if err != nil || len(half.Entries) != 2 || half.Revision != 12 || len(asked) != 2 || asked[1].After != "app" || + asked[0].Group != ev.Group || asked[0].Heads != "h" || asked[0].Revision != 12 || asked[0].Branch != "main" { + t.Fatalf("read %+v (%v), asked %+v", half, err, asked) + } + answerRev = 13 + if _, err := readCandidate(call, ev); err == nil { + t.Error("an answer of another revision was taken") + } +} + +// A-222: at its start the runner reads the newest revision of each unplaced run's candidate through the seat's revision verb +// (its group, no heads, no revision) and places it; a run whose candidate cannot be read is said and the others go on. +func TestPlaceLostRunsReadsEachCandidateNewest(t *testing.T) { + var asked []catalog.RevisionInput + call := func(key string, body any) (json.RawMessage, error) { + in := body.(catalog.RevisionInput) + asked = append(asked, in) + if in.Group == "run-bad" { + return nil, errors.New("no responders") + } + return json.Marshal(catalog.RevisionAnswer{Revision: &catalog.CatalogRevision{Revision: 7, Source: 3, Kind: "candidate", Group: in.Group}, + Entries: []catalog.CatalogEntry{{Module: "app", Fp: "src2:a"}}}) + } + var placed []catalog.CatalogRevisionEvent + var said []string + err := placeLostRuns(func() ([]pipeline.UnplacedRun, error) { + return []pipeline.UnplacedRun{{Run: "bad", Branch: "fix/1"}, {Run: "good", Branch: "fix/1"}}, nil + }, call, + func(ev catalog.CatalogRevisionEvent, half pipeline.Half) error { + placed = append(placed, ev) + return nil + }, + func(f string, a ...any) { said = append(said, f) }) + if err == nil || len(said) == 0 { + t.Errorf("an unreadable candidate was not said: %v, %v", err, said) + } + if len(placed) != 1 || placed[0].Group != "run-good" || placed[0].Revision != 7 || placed[0].Source != 3 || placed[0].Branch != "fix/1" { + t.Fatalf("placed %+v", placed) + } + if asked[1].Heads != "" || asked[1].Revision != 0 || asked[1].Group != "run-good" { + t.Errorf("asked %+v, want the candidate's group alone", asked[1]) + } +} diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drop.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drop.go new file mode 100644 index 00000000..8a3f1a05 --- /dev/null +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drop.go @@ -0,0 +1,41 @@ +package main + +import ( + "encoding/json" + "fmt" + + nodebuildgo "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/nodebuildgo" + "git.novox.be/novox/mesh-sdk/go/buscalls" + + "mesh-delivery-pipeline/internal/pipeline" +) + +// keptCaller is the runner's kept calls, to any holder of a seat or to one node's (process.Conn). +type keptCaller interface { + CallKept(key string, body any) (json.RawMessage, error) + CallKeptOn(node, key string, body any) (json.RawMessage, error) +} + +// dropAsk asks the build seat's `drop` of a superseded step's ask as a kept call (design 47 G §31, §350; A-182): of the +// holder on the node that took it, or, never taken, of any holder, which records it in the seat's shared drop key. The +// three build seats' drop take one input (nodebuildgo.DropInput; the manifest invokes each). +func dropAsk(calls keptCaller, by string) pipeline.Drop { + return func(buildSeat, node, ask, why string) error { + key := "seat:" + buildSeat + "." + nodebuildgo.VerbDrop + in := nodebuildgo.DropInput{ID: ask, Why: why, By: by} + var raw json.RawMessage + var err error + if node != "" { + raw, err = calls.CallKeptOn(node, key, in) + } else { + raw, err = calls.CallKept(key, in) + } + if err != nil { + return err + } + if _, said, ok := buscalls.Unwrap(raw); !ok { + return fmt.Errorf("%s answered: %s", key, said) + } + return nil + } +} diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drops.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drops.go new file mode 100644 index 00000000..dc1fde33 --- /dev/null +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drops.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/nats-io/nats.go/jetstream" + + mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + + "mesh-delivery-pipeline/internal/pipeline" +) + +// callFindings is the runner's finding writer as its drops use it (design 47 G A-222, §350). +type callFindings interface { + Write(ctx context.Context, f condseat.Finding) error + Clear(ctx context.Context, scope, id, kind, why string) error +} + +// dropFindingID is the id of the call-unanswered finding of buildSeat's drop of ask. +func dropFindingID(buildSeat, ask string) string { return buildSeat + ".drop." + ask } + +// deleteBound deletes a key of the state bounds; a key already gone is no fault. +func deleteBound(ctx context.Context, bounds jetstream.KeyValue) func(key string) error { + return func(key string) error { + if err := bounds.Delete(ctx, key); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) { + return err + } + return nil + } +} + +// armDropBound arms the five-minute bound of a drop, a key of the state bounds with its own time to live (A-77, A-222). +func armDropBound(ctx context.Context, bounds jetstream.KeyValue) func(key string) error { + return func(key string) error { + _, err := bounds.Create(ctx, key, []byte(`{"drop":"`+key+`"}`), jetstream.KeyTTL(pipeline.AskBound)) + if errors.Is(err, jetstream.ErrKeyExists) { + return nil + } + return err + } +} + +// dropAnswered ends the wait of an answered drop: its bound is deleted and a call-unanswered finding it raised before is +// cleared. +func dropAnswered(ctx context.Context, unbound func(key string) error, w callFindings) func(buildSeat, ask string) error { + return func(buildSeat, ask string) error { + if err := unbound(pipeline.DropBoundKey(buildSeat, ask)); err != nil { + return err + } + return w.Clear(ctx, mcc.ScopeCall, dropFindingID(buildSeat, ask), mcc.KindCallUnanswered, "the drop is answered") + } +} + +// dropUnanswered says the drop of ask on buildSeat unanswered, the finding call-unanswered in the runner's findings, on the +// expiry of its five-minute bound (A-222, §350, ADR 0668 §3). +func dropUnanswered(ctx context.Context, w callFindings, module string, now func() time.Time) func(buildSeat, ask string) error { + return func(buildSeat, ask string) error { + if w == nil { + return errors.New("the runner has no finding writer") + } + id := dropFindingID(buildSeat, ask) + call := buildSeat + ".drop" + return w.Write(ctx, condseat.Finding{Scope: mcc.ScopeCall, ID: id, Kind: mcc.KindCallUnanswered, Severity: condseat.FindingSeverityWarning, + Summary: fmt.Sprintf("the call %s of ask %s is unanswered", call, ask), + Headline: "A call whose answer is kept is not answered", + Explanation: fmt.Sprintf("The drop of the superseded step's ask %s was asked of the seat %s as a kept call and no holder answered it within %s, so the build it names may still run.", ask, buildSeat, pipeline.AskBound), + Source: module + "." + mcc.KindCallUnanswered, Observer: module, At: now().UTC(), Subject: condseat.Subject{Scope: mcc.ScopeCall, ID: id}}) + } +} + +// boundExpired takes the expiry of a key of the state bounds: a drop's says the drop unanswered, an ask's ends the step no +// holder took (§32, §535, A-77); another key is none of the runner's. +func boundExpired(h *pipeline.Hearer, unanswered func(buildSeat, ask string) error, logf func(string, ...any)) func(key string) { + return func(key string) { + if s, ask, ok := pipeline.ParseDropBound(key); ok { + if err := unanswered(s, ask); err != nil { + logf("the drop of ask %s on %s, unanswered, is not said: %v", ask, s, err) + } + return + } + if !pipeline.IsAskKey(key) { + return + } + if err := h.NotTaken(key, pipeline.AskBound); err != nil { + logf("the step of ask %s, which no holder took, is not ended: %v", key, err) + } + } +} diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drops_test.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drops_test.go new file mode 100644 index 00000000..8b8321b0 --- /dev/null +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drops_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "testing" + "time" + + mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + + "mesh-delivery-pipeline/internal/pipeline" +) + +type fakeFindings struct { + written []condseat.Finding + cleared []string +} + +func (f *fakeFindings) Write(_ context.Context, x condseat.Finding) error { + f.written = append(f.written, x) + return nil +} + +func (f *fakeFindings) Clear(_ context.Context, scope, id, kind, _ string) error { + f.cleared = append(f.cleared, scope+"."+id+"."+kind) + return nil +} + +// A-222, A-77: the expiry of a drop's five-minute bound says the drop call-unanswered in the runner's findings, naming the +// seat and the ask; an answered drop clears it; a key that is no drop's is not said. +func TestADropBoundsExpirySaysCallUnanswered(t *testing.T) { + w := &fakeFindings{} + var logged int + expired := boundExpired(nil, dropUnanswered(context.Background(), w, "the-runner", func() time.Time { return time.Unix(100, 0) }), func(string, ...any) { logged++ }) + expired("not-a-bound") + if len(w.written) != 0 { + t.Fatalf("a key of no drop was said: %+v", w.written) + } + expired(pipeline.DropBoundKey("node-build-go", "step-ab")) + if len(w.written) != 1 { + t.Fatalf("written %+v, want one finding", w.written) + } + f := w.written[0] + if f.Scope != mcc.ScopeCall || f.Kind != mcc.KindCallUnanswered || f.ID != "node-build-go.drop.step-ab" || f.Observer != "the-runner" || f.Subject.ID != f.ID { + t.Errorf("the finding = %+v", f) + } + var unbound []string + if err := dropAnswered(context.Background(), func(k string) error { unbound = append(unbound, k); return nil }, w)("node-build-go", "step-ab"); err != nil || + len(w.cleared) != 1 || w.cleared[0] != "call.node-build-go.drop.step-ab.call-unanswered" || len(unbound) != 1 || unbound[0] != "drop.node-build-go.step-ab" { + t.Errorf("an answered drop: %v, cleared %v, unbound %v", err, w.cleared, unbound) + } +} diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go index b3bddb1c..8e593380 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go @@ -11,12 +11,14 @@ import ( "log" "os" "os/signal" + "strings" "sync/atomic" "syscall" "time" "github.com/nats-io/nats.go" + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner" "git.novox.be/novox/mesh-sdk/go/process" @@ -92,10 +94,16 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // A branch's view holding its first half asks again the steps placed in its running pipelines that were never asked // (§88); the starter is made after the views, so it is reached through a pointer set once it is. var resumer atomic.Pointer[func(string)] + var placer atomic.Pointer[func(catalog.CatalogRevisionEvent) error] rv, err := startViews(ctx, js, conn, cred, hub, calls.State("findings"), calls.Call, func(branch string) { if f := resumer.Load(); f != nil { (*f)(branch) } + }, func(_ context.Context, ev catalog.CatalogRevisionEvent) error { + if f := placer.Load(); f != nil { + return (*f)(ev) + } + return fmt.Errorf("the candidate %s's revision %d is heard before the runner can place it", ev.Group, ev.Revision) }, logger.Printf) if err != nil { return err @@ -138,6 +146,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro } }, Fills: fills, Recovered: kvRecoveries{ctx, recovered}.Has} + placeHalf := func(ev catalog.CatalogRevisionEvent, half pipeline.Half) error { + n, err := starter.PlaceRun(ev, half, rv.Catalog.View) + logger.Printf("the candidate %s's revision %d placed %d pipelines of its run", ev.Group, ev.Revision, n) + return err + } resume := func(branch string) { half, ok := rv.Catalog.View(branch) if !ok { @@ -155,17 +168,57 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro logger.Printf("the steps placed on %s and not yet asked: %d asked again", branch, n) } resumer.Store(&resume) + starter.Store = pipelineStore{ctx: ctx, kv: pipelines} + // A run's placing lost while the runner was down is taken up from its candidate's newest revision, once the registrations + // that place its steps are held (A-222). + go func() { + select { + case <-ctx.Done(): + return + case <-rv.Registrations.Ready(): + } + if err := placeLostRuns(starter.UnplacedRuns, calls.CallKept, placeHalf, logger.Printf); err != nil { + logger.Printf("the runs not placed at the start: %v", err) + } + }() + // A run's candidate revision places its pipelines' steps, its entries read through the seat's revision verb (A-180). + place := func(ev catalog.CatalogRevisionEvent) error { + if !strings.HasPrefix(ev.Group, pipeline.RunGroupPrefix) { + return nil + } + half, err := readCandidate(calls.CallKept, ev) + if err != nil { + return fmt.Errorf("the candidate %s's revision %d is not read, so its run is not placed: %w", ev.Group, ev.Revision, err) + } + return placeHalf(ev, half) + } + placer.Store(&place) + // A newer commit or publish supersedes the older running pipelines it replaces, their asks dropped as kept calls (§31, + // §350; A-182, A-202). + starter.Supersede = func(newer seat.Pipeline) { + n, err := hearer.Supersede(newer, pipeline.BoundDrop(dropAsk(calls, cred.Module), armDropBound(ctx, bounds), dropAnswered(ctx, deleteBound(ctx, bounds), rv.Findings)), dropBound(ctx, bounds)) + if err != nil { + logger.Printf("the pipelines %s supersedes: %d ended, then %v", newer.Pipeline, n, err) + } else if n > 0 { + logger.Printf("the pipeline %s superseded %d older pipelines", newer.Pipeline, n) + } + } if err := hearStepEnded(heard, hearer, logger.Printf); err != nil { return err } if err := hearToolchain(ctx, heard, toolchains, logger.Printf); err != nil { return err } - if err := hearAskTaken(ctx, heard, bounds, logger.Printf); err != nil { + if err := hearAskTaken(ctx, heard, bounds, hearer.Taken, logger.Printf); err != nil { return err } store := pipelineStore{ctx: ctx, kv: pipelines} - if err := watchBounds(ctx, bounds, hearer, func(key string) { + expired := boundExpired(hearer, dropUnanswered(ctx, rv.Findings, cred.Module, time.Now), logger.Printf) + if err := watchBounds(ctx, bounds, func(key string) { + if !pipeline.IsHeldKey(key) { + expired(key) + return + } if n, err := starter.ReleaseExpired(store, key, rv.Catalog.View); err != nil { logger.Printf("the pipelines held for the runner, whose %s passed: %d released, then %v", key, n, err) } else { diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/steps.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/steps.go index 998394bd..6b263676 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/steps.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/steps.go @@ -135,9 +135,9 @@ func hearStepEnded(hub handlers, h *pipeline.Hearer, logf func(string, ...any)) return nil } -// hearAskTaken deletes the bound of each ask a build seat's holder took (§602, §647): the ask is answered, so its five minutes -// end with no finding. -func hearAskTaken(ctx context.Context, hub handlers, bounds jetstream.KeyValue, logf func(string, ...any)) error { +// hearAskTaken deletes the bound of each ask a build seat's holder took (§602, §647) and keeps on its step the node that took +// it (A-182): the ask is answered, so its five minutes end with no finding. +func hearAskTaken(ctx context.Context, hub handlers, bounds jetstream.KeyValue, taken func(ask, node string) error, logf func(string, ...any)) error { for _, ts := range pipeline.TakenSeats { ts := ts err := hub.Handle(ts.Subject, func(m jetstream.Msg) { @@ -152,6 +152,12 @@ func hearAskTaken(ctx context.Context, hub handlers, bounds jetstream.KeyValue, _ = m.Nak() return } + // The step keeps the node that took its ask, so a drop of it is asked of that node's holder (A-182). + if err := taken(ev.Ask, ev.Node); err != nil { + logf("the node %s that took ask %s is not kept on its step, heard again: %v", ev.Node, ev.Ask, err) + _ = m.Nak() + return + } _ = m.Ack() }) if err != nil { @@ -218,8 +224,8 @@ func hearRunning(ctx context.Context, hub handlers, self string, st *pipeline.St } // watchBounds hears the end of each ask's bound, the bus's marker of the key's expiry, and ends the step no holder took (§32, -// §535, A-77): no timer in the process. -func watchBounds(ctx context.Context, bounds jetstream.KeyValue, h *pipeline.Hearer, release func(key string), logf func(string, ...any)) error { +// §535, A-77), and says a drop no holder answered unanswered (A-222): no timer in the process. +func watchBounds(ctx context.Context, bounds jetstream.KeyValue, expired func(key string), logf func(string, ...any)) error { w, err := bounds.WatchAll(ctx, jetstream.UpdatesOnly()) if err != nil { return err @@ -234,16 +240,10 @@ func watchBounds(ctx context.Context, bounds jetstream.KeyValue, h *pipeline.Hea if !ok { return } - if e != nil && e.Operation() == jetstream.KeyValuePurge && pipeline.IsHeldKey(e.Key()) { - release(e.Key()) + if e == nil || e.Operation() != jetstream.KeyValuePurge { continue } - if e == nil || e.Operation() != jetstream.KeyValuePurge || !pipeline.IsAskKey(e.Key()) { - continue - } - if err := h.NotTaken(e.Key(), pipeline.AskBound); err != nil { - logf("the step of ask %s, which no holder took, is not ended: %v", e.Key(), err) - } + expired(e.Key()) } } }() diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs.go index 1517865c..a5816073 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs.go @@ -9,6 +9,7 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner" "git.novox.be/novox/mesh-sdk/go/process" @@ -38,10 +39,11 @@ func answerVerbs(conn *process.Conn, v pipeline.Verbs) (func(), error) { return conn.AnswerSeat(seat.Seat, writeHandlers(v)) } -// writeHandlers are the seat's write verbs the process answers: review, rerun, accept, cancel, start, ask-again and -// release (§306; RN14; A-206). +// writeHandlers are the seat's write verbs the process answers: run, review, rerun, accept, cancel, start, ask-again and +// release (§64, §306; RN12, RN14; A-206). func writeHandlers(v pipeline.Verbs) map[string]process.SeatHandler { return map[string]process.SeatHandler{ + seat.VerbRun: verbOf(func(c string, in seat.RunInput) (any, error) { return v.Run(c, in) }), seat.VerbReview: verbOf(func(c string, in seat.ReviewInput) (any, error) { return v.Review(c, in) }), seat.VerbRerun: verbOf(func(c string, in seat.RerunInput) (any, error) { return v.Rerun(c, in) }), seat.VerbAccept: verbOf(func(c string, in seat.AcceptInput) (any, error) { return v.Accept(c, in) }), @@ -65,5 +67,8 @@ func dropBound(ctx context.Context, bounds jetstream.KeyValue) func(string) erro // writeVerbs are the verbs over the hearer and the starter, their hand acts recorded through the seat mesh-hand-acts. func writeVerbs(ctx context.Context, h *pipeline.Hearer, s *pipeline.Starter, view func(string) (pipeline.Half, bool), calls *process.Conn, bounds jetstream.KeyValue) pipeline.Verbs { - return pipeline.Verbs{Hearer: h, Starter: s, View: view, Acts: meshhandactsclient.Recorder{Ask: calls.Call}, Drop: dropBound(ctx, bounds)} + candidate := func(group, branch string, revision int64) (pipeline.Half, error) { + return readCandidate(calls.CallKept, catalog.CatalogRevisionEvent{Kind: "candidate", Group: group, Branch: branch, Revision: revision}) + } + return pipeline.Verbs{Hearer: h, Starter: s, View: view, Candidate: candidate, Acts: meshhandactsclient.Recorder{Ask: calls.Call}, Drop: dropBound(ctx, bounds)} } diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs_test.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs_test.go index 61801856..9cb87105 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs_test.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs_test.go @@ -18,7 +18,7 @@ func TestEveryWriteVerbIsServed(t *testing.T) { got = append(got, v) } slices.Sort(got) - want := []string{seat.VerbAccept, seat.VerbAskAgain, seat.VerbCancel, seat.VerbRerun, seat.VerbRelease, seat.VerbReview, seat.VerbStart} + want := []string{seat.VerbAccept, seat.VerbAskAgain, seat.VerbCancel, seat.VerbRerun, seat.VerbRelease, seat.VerbReview, seat.VerbStart, seat.VerbRun} slices.Sort(want) if !slices.Equal(got, want) { t.Errorf("the process answers %v, want %v", got, want) diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go index aebeab85..d53851ce 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go @@ -28,7 +28,8 @@ type runnerViews struct { // startViews wires the runner's views on hub and asks each first view; register every other handler on hub before or // after, then start hub once. func startViews(ctx context.Context, js jetstream.JetStream, conn *nats.Conn, cred process.Account, hub *process.EventHub, findingsState process.State, - call meshcatalogclient.Call, onReady func(branch string), logf func(string, ...any)) (*runnerViews, error) { + call meshcatalogclient.Call, onReady func(branch string), candidate func(ctx context.Context, ev catalog.CatalogRevisionEvent) error, + logf func(string, ...any)) (*runnerViews, error) { findings, err := findingWriter(ctx, cred, conn, js, findingsState, logf) if err != nil { return nil, err @@ -55,6 +56,11 @@ func startViews(ctx context.Context, js jetstream.JetStream, conn *nats.Conn, cr if err != nil { return nil, err } + // A candidate's revision, heard once on the event's one subscription whatever branches are followed, places a run's + // pipelines (A-185, A-193, A-199). + if err := meshcatalogclient.WireCandidatesProcess(reader, candidate); err != nil { + return nil, err + } reader.Start(ctx, js, cred, status) return &runnerViews{Registrations: regs, Catalog: cv, Findings: findings}, nil } diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/bounds.go b/modules/mesh-delivery-pipeline/internal/pipeline/bounds.go index 918adf93..bfb3063e 100644 --- a/modules/mesh-delivery-pipeline/internal/pipeline/bounds.go +++ b/modules/mesh-delivery-pipeline/internal/pipeline/bounds.go @@ -97,3 +97,36 @@ func (h *Hearer) endNotTaken(key, ask string, bound time.Duration) error { // IsAskKey says whether a key of the state `bounds` is an ask's bound. func IsAskKey(key string) bool { return strings.HasPrefix(key, "step-") && !strings.Contains(key, ".") } + +// DropBoundPrefix begins the key of a superseded step's drop in the state `bounds`, `drop..` (design 47 G +// A-222, A-77): its five-minute expiry says the drop unanswered. +const DropBoundPrefix = "drop." + +// DropBoundKey is the key of the bound of buildSeat's drop of ask. +func DropBoundKey(buildSeat, ask string) string { return DropBoundPrefix + buildSeat + "." + ask } + +// ParseDropBound reads a key of the state `bounds` as a drop's bound: the build seat and the ask it names. +func ParseDropBound(key string) (buildSeat, ask string, ok bool) { + rest, found := strings.CutPrefix(key, DropBoundPrefix) + if !found { + return "", "", false + } + buildSeat, ask, found = strings.Cut(rest, ".") + return buildSeat, ask, found && buildSeat != "" && ask != "" && !strings.Contains(ask, ".") +} + +// BoundDrop is drop with a bound of five minutes (A-222, A-77): the bound key is armed, with its own time to live, before +// the drop is asked, and answered is told when the drop is answered, to delete the key, so an answer ends the wait with no +// finding; a drop never answered leaves its key to expire, and the expiry says it unanswered. A bound that cannot be armed +// is returned and the drop not asked. +func BoundDrop(drop Drop, arm func(key string) error, answered func(buildSeat, ask string) error) Drop { + return func(buildSeat, node, ask, why string) error { + if err := arm(DropBoundKey(buildSeat, ask)); err != nil { + return fmt.Errorf("the bound of the drop of ask %s is not armed: %w", ask, err) + } + if err := drop(buildSeat, node, ask, why); err != nil { + return err + } + return answered(buildSeat, ask) + } +} diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/run.go b/modules/mesh-delivery-pipeline/internal/pipeline/run.go new file mode 100644 index 00000000..16e85b51 --- /dev/null +++ b/modules/mesh-delivery-pipeline/internal/pipeline/run.go @@ -0,0 +1,264 @@ +package pipeline + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "strings" + "time" + + runnerclient "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshpipelinerunnerclient" + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// RunGroupPrefix is a run on request's candidate group before its id, `run-` (design 47 G A-160). +const RunGroupPrefix = "run-" + +// RunKey is the key of a run on request's pipeline, `..`: the run's id under the member's named branch +// (design 47 G §420, A-193). +func RunKey(branch, run, module string) string { + return seat.PipelinesByBranchRevisionModuleKey(keys.EscapeName(branch), run, module) +} + +// NewRunID is a run's id, a random 12-hex id, so two runs of one commit are two pipelines (A-180). +func NewRunID() (string, error) { + b := make([]byte, 6) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// Run starts a run on request (design 47 G §64, §420; A-180, A-193): it writes each member's pipeline at once, running and +// with no steps, its `run` the run's new id, and says run-started, from which the indexer builds the run's candidate; the +// candidate's revision places the steps (PlaceRun). A member naming no module, commit or branch is refused, the run then +// writing nothing. caller is the one the bus proved. newID makes the run's id (NewRunID). +func (s *Starter) Run(caller string, in seat.RunInput, newID func() (string, error)) (seat.RunAnswer, error) { + if len(in.Members) == 0 { + return seat.RunAnswer{}, errors.New("a run names one or more members, each a module at a commit on a branch") + } + for _, m := range in.Members { + if m.Module == "" || m.Commit == "" || m.Branch == "" { + return seat.RunAnswer{}, fmt.Errorf("a run's member names its module, commit and branch: %+v", m) + } + } + id, err := newID() + if err != nil { + return seat.RunAnswer{}, fmt.Errorf("the run's id is not made: %w", err) + } + now := s.now() + var written []string + for _, m := range in.Members { + key := RunKey(m.Branch, id, m.Module) + p := seat.Pipeline{V: 1, Pipeline: key, Branch: m.Branch, Module: m.Module, Commit: m.Commit, Run: id, + Outcome: seat.PipelineOutcomeRunning, Started: &now, Steps: []seat.StepResult{}} + raw, err := json.Marshal(p) + if err == nil { + err = s.Pipelines.Create(key, raw) + } + if err != nil { + s.failRun(written, "the run's other pipelines were not written") + return seat.RunAnswer{}, fmt.Errorf("the pipeline %s of the run is not written: %w", key, err) + } + written = append(written, key) + } + first := in.Members[0] + last := first.Commit + if len(in.Members) > 1 { + last = RunGroupPrefix + id + } + ev := seat.RunStarted{V: 1, At: &now, By: caller, Members: in.Members, Run: id} + if err := s.Say(runnerclient.Subject(seat.EventRunStarted, first.Module, first.Branch, last), ev); err != nil { + s.failRun(written, "run-started was not said, so no candidate is built for it") + return seat.RunAnswer{}, fmt.Errorf("the run %s is not started: run-started is not said: %w", id, err) + } + return seat.RunAnswer{Run: id, Pipelines: written}, nil +} + +// failRun ends the run's pipelines written before its start failed, failed and saying why, so none stands running. +func (s *Starter) failRun(written []string, why string) { + if s.Store == nil { + return + } + for _, key := range written { + for { + p, rev, err := s.Store.Get(key) + if err != nil || p.Outcome != seat.PipelineOutcomeRunning { + break + } + now := s.now() + p.Outcome, p.Why, p.Ended = seat.PipelineOutcomeFailed, why, &now + p.EndCount++ + if _, err := s.Store.Update(key, p, rev); errors.Is(err, ErrChanged) { + continue + } else if err != nil { + s.logf("the pipeline %s of a run not started stays running: %v", key, err) + } + break + } + } +} + +// PlaceRun places a run's pipelines from its candidate's revision (design 47 G §64, §373, A-180, A-193): ev is the +// catalog-revision of the candidate `run-`, half its entries read whole through the seat's revision verb. Each member's +// pipeline of the run not yet placed takes its fingerprint, the candidate's revision and its steps, by compare-and-set, and +// says pipeline-started; each further entry whose fingerprint the members change (another than in base, the branch's catalog +// half, or absent there) gets a pipeline of the run of its own, placed the same way. Another candidate's revision places +// nothing; a revision heard again places nothing twice. It says how many pipelines it placed. +func (s *Starter) PlaceRun(ev catalog.CatalogRevisionEvent, half Half, base func(branch string) (Half, bool)) (int, error) { + id, ok := strings.CutPrefix(ev.Group, RunGroupPrefix) + if !ok || id == "" { + return 0, nil + } + if s.Store == nil { + return 0, errors.New("the runner holds no pipelines to place a run's in") + } + pkeys, err := s.Store.Keys("*." + id + ".*") + if err != nil { + return 0, fmt.Errorf("the pipelines of the run %s are not listed: %w", id, err) + } + slices.Sort(pkeys) + members := map[string]bool{} + branch := "" + for _, key := range pkeys { + if p, _, err := s.Store.Get(key); err == nil && p.Run == id { + members[p.Module] = true + if branch == "" { + branch = p.Branch + } + } + } + if branch == "" { + s.logf("the candidate %s's revision %d names a run with no pipeline here: nothing is placed", ev.Group, ev.Revision) + return 0, nil + } + placed := 0 + var first error + keep := func(ok bool, err error) { + if err != nil && first == nil { + first = err + } + if ok { + placed++ + } + } + for _, key := range pkeys { + keep(s.placeRunPipeline(key, id, ev.Revision, half)) + } + // The dependents (§373): the candidate's entries the members change, beside the members, on the first member's branch. + trunk, ok := base(branch) + if b, err := keys.UnescapeName(ev.Branch); err == nil && b != "" && b != branch { + if h, held := base(b); held { + trunk, ok = h, true + } + } + if !ok { + s.logf("the run %s's dependents are not known: the catalog half of %s is not held, so its members run alone", id, branch) + return placed, first + } + for _, module := range slices.Sorted(maps.Keys(half.Entries)) { + e := half.Entries[module] + if members[module] { + continue + } + if was, held := trunk.Entries[module]; held && was.Fp == e.Fp { + continue + } + key := RunKey(branch, id, module) + now := s.now() + p := seat.Pipeline{V: 1, Pipeline: key, Branch: branch, Module: module, Commit: e.Commit, Run: id, + Outcome: seat.PipelineOutcomeRunning, Started: &now, Steps: []seat.StepResult{}} + raw, err := json.Marshal(p) + if err == nil { + err = s.Pipelines.Create(key, raw) + } + if err != nil && !errors.Is(err, ErrExists) { + keep(false, fmt.Errorf("the dependent %s of the run %s is not written: %w", module, id, err)) + continue + } + keep(s.placeRunPipeline(key, id, ev.Revision, half)) + } + return placed, first +} + +// UnplacedRun is a run with a pipeline running and no steps placed yet, on the branch the run names. +type UnplacedRun struct{ Run, Branch string } + +// UnplacedRuns are the runs with a pipeline running and no steps placed yet, by branch and id (design 47 G A-222): a placing +// lost while the runner was down is taken up from these at its start. +func (s *Starter) UnplacedRuns() ([]UnplacedRun, error) { + if s.Store == nil { + return nil, errors.New("the runner holds no pipelines to read a run's from") + } + pkeys, err := s.Store.Keys("*.*.*") + if err != nil { + return nil, fmt.Errorf("the pipelines are not listed: %w", err) + } + var out []UnplacedRun + for _, key := range pkeys { + if p, _, err := s.Store.Get(key); err == nil && p.Run != "" && p.Outcome == seat.PipelineOutcomeRunning && p.Revision == 0 { + if u := (UnplacedRun{Run: p.Run, Branch: p.Branch}); !slices.Contains(out, u) { + out = append(out, u) + } + } + } + slices.SortFunc(out, func(a, b UnplacedRun) int { return strings.Compare(a.Branch+"\n"+a.Run, b.Branch+"\n"+b.Run) }) + return out, nil +} + +// placeRunPipeline places one pipeline of the run, written and not yet placed (no revision), from the candidate's half. +func (s *Starter) placeRunPipeline(key, id string, revision int64, half Half) (bool, error) { + for { + p, rev, err := s.Store.Get(key) + if err != nil { + return false, fmt.Errorf("the pipeline %s is not read: %w", key, err) + } + if p.Run != id || p.Outcome != seat.PipelineOutcomeRunning || p.Revision != 0 { + return false, nil + } + now := s.now() + e, ok := half.Entries[p.Module] + if !ok { + s.logf("the candidate of the run %s holds no entry of %s: its pipeline is placed with no fingerprint", id, p.Module) + } + p.Fingerprint, p.Repository, p.Revision = e.Fp, e.Repository, revision + var asks []Asked + if s.Registration != nil { + if reg, ok := s.Registration(p.Module); ok { + p.Steps, asks = Place(p, reg, half.Entries, now, s.Name) + } else { + s.logf("%s has no registration yet: its pipeline is placed with no step", key) + } + } + if _, err := s.Store.Update(key, p, rev); errors.Is(err, ErrChanged) { + continue + } else if err != nil { + return false, fmt.Errorf("the pipeline %s cannot be written: %w", key, err) + } + ev := seat.PipelineStarted{V: 1, At: &now, Pipeline: key, Branch: p.Branch, Module: p.Module, Commit: p.Commit, + Fingerprint: p.Fingerprint, Revision: p.Revision, Run: id} + if err := s.Say(runnerclient.Subject(seat.EventPipelineStarted, p.Module, p.Branch, p.Commit), ev); err != nil { + s.logf("pipeline-started of %s is not said: %v", key, err) + } + if s.Steps != nil && len(asks) > 0 { + s.carry(key, asks) + if err := askAll(s.Steps, s.Arm, key, asks, s.recovered(key, p.Commit)); err != nil { + return true, err + } + } + return true, nil + } +} + +func (s *Starter) now() time.Time { + if s.Now != nil { + return s.Now().UTC() + } + return time.Now().UTC() +} diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/run_test.go b/modules/mesh-delivery-pipeline/internal/pipeline/run_test.go new file mode 100644 index 00000000..6dbc0dc1 --- /dev/null +++ b/modules/mesh-delivery-pipeline/internal/pipeline/run_test.go @@ -0,0 +1,112 @@ +package pipeline + +import ( + "errors" + "strings" + "testing" + + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner" +) + +// RN12 (design 47 G §64, §373, §420; A-180, A-193): `run` writes each member's pipeline at once under the run's id and its +// named branch and says run-started; the candidate run-'s revision places each member's steps and a pipeline of each +// entry the members change, says pipeline-started once each, and places nothing twice; a member naming no branch is refused. +func TestRunOnRequest(t *testing.T) { + reg, entries := placeFixture() + store := memCreated{memStore{}} + var said []string + asks := &stepAsks{} + s := &Starter{Pipelines: store, Store: store, Steps: asks, Name: "the-runner", + Registration: func(m string) (catalog.Registration, bool) { + r := reg + r.Module = m + return r, m == "app" || m == "lib" + }, + Say: func(subject string, body any) error { + said = append(said, subject[strings.Index(subject, ".event.")+7:]) + return nil + }} + id := func() (string, error) { return "0a1b2c3d4e5f", nil } + if _, err := s.Run("sessions", seat.RunInput{Members: []seat.Member{{Module: "app", Commit: "c1"}}}, id); err == nil { + t.Error("a member naming no branch was taken") + } + if len(store.memStore) != 0 || len(said) != 0 { + t.Fatalf("a refused run wrote %v and said %v", store.memStore, said) + } + a, err := s.Run("sessions", seat.RunInput{Members: []seat.Member{{Module: "app", Commit: "c1", Branch: "fix/1"}}}, id) + if err != nil || a.Run != "0a1b2c3d4e5f" || len(a.Pipelines) != 1 { + t.Fatalf("run = %+v, %v", a, err) + } + key := a.Pipelines[0] + if key != RunKey("fix/1", "0a1b2c3d4e5f", "app") { + t.Errorf("the member's pipeline is %s, want the run's id under its named branch", key) + } + if p, _, _ := store.Get(key); p.Run != a.Run || p.Outcome != seat.PipelineOutcomeRunning || len(p.Steps) != 0 || p.Branch != "fix/1" || p.Source != 0 { + t.Errorf("the member's pipeline %s = %+v", key, p) + } + if len(said) != 1 || !strings.HasPrefix(said[0], seat.EventRunStarted+".app.") || !strings.HasSuffix(said[0], ".c1") { + t.Errorf("said %v, want run-started on the member's module, branch and commit", said) + } + // A-222: a restart finds the runs whose steps were never placed, by their branch. + if ids, err := s.UnplacedRuns(); err != nil || len(ids) != 1 || ids[0] != (UnplacedRun{Run: a.Run, Branch: "fix/1"}) { + t.Errorf("the unplaced runs = %v (%v), want %s on fix/1", ids, err, a.Run) + } + cand := entries + cand["app"] = catalog.CatalogEntry{Module: "app", Commit: "c1", Fp: "src2:new", Repository: "o/app", Artifacts: entries["app"].Artifacts} + cand["lib"] = catalog.CatalogEntry{Module: "lib", Commit: "c0", Fp: "src2:lib2", Artifacts: entries["app"].Artifacts} + cand["same"] = catalog.CatalogEntry{Module: "same", Fp: "src2:same"} + base := func(string) (Half, bool) { + return Half{Entries: map[string]catalog.CatalogEntry{"lib": {Fp: "src2:lib1"}, "same": {Fp: "src2:same"}, "mesh-build-go": entries["mesh-build-go"]}}, true + } + ev := catalog.CatalogRevisionEvent{Kind: "candidate", Group: "run-" + a.Run, Revision: 12} + if n, err := s.PlaceRun(catalog.CatalogRevisionEvent{Kind: "candidate", Group: "try-3", Revision: 11}, Half{Entries: cand}, base); n != 0 || err != nil { + t.Errorf("a try's candidate placed %d (%v)", n, err) + } + said = nil + n, err := s.PlaceRun(ev, Half{Revision: 12, Entries: cand}, base) + if err != nil || n != 2 { + t.Fatalf("the candidate placed %d (%v), want the member and the dependent lib", n, err) + } + p, _, _ := store.Get(key) + if p.Fingerprint != "src2:new" || p.Revision != 12 || len(p.Steps) == 0 { + t.Errorf("the member's pipeline placed = %+v", p) + } + dep, _, err := store.Get(RunKey("fix/1", a.Run, "lib")) + if err != nil || dep.Run != a.Run || dep.Fingerprint != "src2:lib2" || len(dep.Steps) == 0 { + t.Errorf("the dependent's pipeline = %+v (%v)", dep, err) + } + if _, _, err := store.Get(RunKey("fix/1", a.Run, "same")); err == nil { + t.Error("an entry the members do not change got a pipeline") + } + if len(said) != 2 || !strings.HasPrefix(said[0], seat.EventPipelineStarted+".") || len(asks.ids) == 0 { + t.Errorf("placing said %v and asked %d steps", said, len(asks.ids)) + } + if ids, _ := s.UnplacedRuns(); len(ids) != 0 { + t.Errorf("a placed run is still unplaced: %v", ids) + } + if n, _ := s.PlaceRun(ev, Half{Revision: 12, Entries: cand}, base); n != 0 { + t.Errorf("the revision heard again placed %d", n) + } + // A restart asks a run's steps again from no branch's half: they are its candidate's. + if n, err := s.Resume(store, "fix/1", Half{Entries: entries}); n != 0 || err != nil { + t.Errorf("a restart asked %d steps of the run again (%v)", n, err) + } + + // Several members say run-started on the first member's module and branch and run-; a run-started not said fails the + // pipelines written, so none stands running. + said = nil + two, err := s.Run("operator", seat.RunInput{Members: []seat.Member{{Module: "app", Commit: "c2", Branch: "main"}, {Module: "lib", Commit: "c3", Branch: "main"}}}, + func() (string, error) { return "ffffffffffff", nil }) + if err != nil || len(two.Pipelines) != 2 || len(said) != 1 || said[0] != seat.EventRunStarted+".app.main.run-ffffffffffff" { + t.Errorf("a run of two: %+v (%v), said %v", two, err, said) + } + s.Say = func(string, any) error { return errors.New("bus away") } + three, err := s.Run("operator", seat.RunInput{Members: []seat.Member{{Module: "app", Commit: "c4", Branch: "main"}}}, func() (string, error) { return "eeeeeeeeeeee", nil }) + if err == nil || three.Run != "" { + t.Errorf("a run whose run-started was not said answered %+v", three) + } + if p, _, _ := store.Get(RunKey("main", "eeeeeeeeeeee", "app")); p.Outcome != seat.PipelineOutcomeFailed { + t.Errorf("its pipeline is %s, want failed", p.Outcome) + } +} diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/start.go b/modules/mesh-delivery-pipeline/internal/pipeline/start.go index ddba572e..88444cd7 100644 --- a/modules/mesh-delivery-pipeline/internal/pipeline/start.go +++ b/modules/mesh-delivery-pipeline/internal/pipeline/start.go @@ -39,6 +39,8 @@ type Begin struct { Await string // Entries are the catalog half's entries of the pipeline's branch: the steps' artifacts and build seats are read from them. Entries map[string]catalog.CatalogEntry + // Published is the package's publish that started the pipeline, nil for any other start (A-202). + Published *seat.Published } // Key is a pipeline's key in the state pipelines, `..`, the branch escaped as a key's @@ -86,12 +88,16 @@ type Starter struct { Fills *Fills // Recovered says whether a commit is named a recovery (§81, §86): its steps are asked on the build seats' stream delivery. Recovered func(commit string) (bool, error) + // Store is the state pipelines read and written by compare-and-set, where a run's pipelines are placed (PlaceRun). + Store Updater + // Supersede ends the older running pipelines the pipeline just written supersedes (§31; Hearer.Supersede); nil ends none. + Supersede func(newer seat.Pipeline) } func newPipeline(b Begin, key string, now time.Time) seat.Pipeline { return seat.Pipeline{V: 1, Pipeline: key, Branch: b.Branch, Source: b.Source, Module: b.Module, Commit: b.Commit, Fingerprint: b.Fp, Group: b.Group, Repository: b.Repository, Revision: b.Revision, Outcome: seat.PipelineOutcomeRunning, Started: &now, - Steps: []seat.StepResult{}} + Steps: []seat.StepResult{}, Published: b.Published} } func (s *Starter) create(key string, p seat.Pipeline) error { @@ -140,7 +146,7 @@ func (s *Starter) Start(b Begin) (bool, error) { return false, fmt.Errorf("the pipeline %s is not written: %w", key, err) } ev := seat.PipelineStarted{V: 1, At: &now, Pipeline: key, Branch: b.Branch, Source: b.Source, Module: b.Module, - Commit: b.Commit, Fingerprint: b.Fp, Group: b.Group, Revision: b.Revision} + Commit: b.Commit, Fingerprint: b.Fp, Group: b.Group, Revision: b.Revision, Published: b.Published} commitOrGroup := b.Commit if b.Group != "" { commitOrGroup = b.Group @@ -148,6 +154,9 @@ func (s *Starter) Start(b Begin) (bool, error) { if err := s.Say(runnerclient.Subject(seat.EventPipelineStarted, b.Module, b.Branch, commitOrGroup), ev); err != nil { s.logf("pipeline-started of %s is not said: %v", key, err) } + if s.Supersede != nil { + s.Supersede(p) + } if s.Steps != nil && len(asks) > 0 { s.carry(key, asks) if err := askAll(s.Steps, s.Arm, key, asks, s.recovered(key, b.Commit)); err != nil { @@ -184,7 +193,8 @@ func (s *Starter) Resume(store Updater, branch string, half Half) (int, error) { } continue } - if p.Branch != branch || p.Outcome != seat.PipelineOutcomeRunning { + // A run on request's steps are placed from its candidate, not from the branch's half (PlaceRun). + if p.Branch != branch || p.Outcome != seat.PipelineOutcomeRunning || p.Run != "" { continue } reg, ok := s.Registration(p.Module) @@ -300,7 +310,8 @@ func (s *Starter) Published(ev builds.PackagePublished, outdated func(catalog.Ou if o.Kind != outdatedKind || o.Module == "" { continue } - b := Begin{Branch: ev.Branch, Source: ev.Source, Module: o.Module, Revision: half.Revision, Entries: half.Entries} + b := Begin{Branch: ev.Branch, Source: ev.Source, Module: o.Module, Revision: half.Revision, Entries: half.Entries, + Published: &seat.Published{Artifact: ev.Artifact, Version: ev.Version}} if e, ok := half.Entries[o.Module]; ok { b.Commit, b.Fp, b.Repository = e.Commit, e.Fp, e.Repository } else { diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/start_test.go b/modules/mesh-delivery-pipeline/internal/pipeline/start_test.go index 7fcfec71..63fef192 100644 --- a/modules/mesh-delivery-pipeline/internal/pipeline/start_test.go +++ b/modules/mesh-delivery-pipeline/internal/pipeline/start_test.go @@ -27,11 +27,15 @@ func (c created) Create(key string, value []byte) error { func TestStarts(t *testing.T) { store := created{} var said []string + var superseding []string s := &Starter{Pipelines: store, Say: func(subject string, body any) error { ev := body.(seat.PipelineStarted) said = append(said, subject+" "+ev.Pipeline) + if ev.Published == nil || ev.Published.Artifact != "mesh-sdk:go" || ev.Published.Version != "0.1.16" { + t.Errorf("pipeline-started of %s says the publish %+v (A-202)", ev.Pipeline, ev.Published) + } return nil - }} + }, Supersede: func(newer seat.Pipeline) { superseding = append(superseding, newer.Pipeline) }} view := func(branch string) (Half, bool) { return Half{Revision: 9, Entries: map[string]catalog.CatalogEntry{ "app": {Module: "app", Commit: "c1", Fp: "src2:aa", Repository: "o/app"}}}, true @@ -51,9 +55,13 @@ func TestStarts(t *testing.T) { } var p seat.Pipeline if err := json.Unmarshal(store["main.41.app"], &p); err != nil || p.Commit != "c1" || p.Fingerprint != "src2:aa" || p.Revision != 9 || - p.Outcome != seat.PipelineOutcomeRunning || p.Source != 41 { + p.Outcome != seat.PipelineOutcomeRunning || p.Source != 41 || p.Published == nil || p.Published.Version != "0.1.16" { t.Errorf("the pipeline main.41.app = %+v (%v)", p, err) } + // Each pipeline written supersedes the older ones it replaces (§31). + if len(superseding) != 2 { + t.Errorf("the publish's pipelines superseding: %v, want both", superseding) + } if _, ok := store["main.41.ordered"]; ok { t.Error("an ordering edge starts no pipeline") } @@ -66,6 +74,7 @@ func TestStarts(t *testing.T) { if !strings.HasPrefix(said[0], "mesh.seat.mesh-pipeline-runner.event.pipeline-started.app.main.c1 main.41.app") { t.Errorf("pipeline-started said on %q", said[0]) } + s.Say = func(string, any) error { return nil } // A branch holding a dot is escaped in the key, so the key reads back; a start naming no source revision is refused. if _, err := s.Start(Begin{Branch: "release.1", Source: 3, Module: "app"}); err != nil { t.Fatal(err) diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/supersede.go b/modules/mesh-delivery-pipeline/internal/pipeline/supersede.go new file mode 100644 index 00000000..a729b552 --- /dev/null +++ b/modules/mesh-delivery-pipeline/internal/pipeline/supersede.go @@ -0,0 +1,158 @@ +package pipeline + +import ( + "errors" + "fmt" + "time" + + seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner" +) + +// Drop asks a build seat's `drop` of a superseded step's ask as a kept call (design 47 G §31, §350; A-182): of node's holder +// when a holder took it (node the one its ask-taken named), else of any holder, which records it in the seat's shared drop +// key so no holder takes it. why names the newer commit. +type Drop func(buildSeat, node, ask, why string) error + +// Taken notes on the running step asked under ask the node whose holder said ask-taken for it (A-182), so a drop of it is +// asked of that node's holder. A step no pipeline keeps running under ask takes nothing. +func (h *Hearer) Taken(ask, node string) error { + if ask == "" || node == "" { + return nil + } + pkeys, err := h.Store.Keys("*.*.*") + if err != nil { + return fmt.Errorf("the pipelines are not listed: %w", err) + } + for _, key := range pkeys { + for { + p, rev, err := h.Store.Get(key) + if err != nil { + break + } + i := stepOfAsk(p, ask) + if i < 0 || p.Steps[i].Outcome != seat.StepResultOutcomeRunning || p.Steps[i].Node == node { + break + } + p.Steps[i].Node = node + if _, err := h.Store.Update(key, p, rev); errors.Is(err, ErrChanged) { + continue + } else if err != nil { + return fmt.Errorf("pipeline %s cannot be written: %w", key, err) + } + return nil + } + } + return nil +} + +// Supersede ends, for the pipeline newer just written, each older running pipeline of the same watched branch and module +// whose fingerprint newer's commit changed, and for a publish's pipeline each an older publish of the package started +// (design 47 G §31, §119; ADR 0666 §9; A-202): the pipeline and each step not ended +// `superseded`, naming the newer commit, and it says pipeline-ended. A module the newer commit left as it was finishes its +// pipeline. The ask of each superseded step on a build seat that serves `drop` is dropped (drop; its bound deleted through +// unbound); a step on mesh-builds or mesh-wants, whose seats serve no drop, is only ended superseded (A-182). A run on +// request's pipeline neither supersedes nor is superseded. It says how many pipelines it ended. +func (h *Hearer) Supersede(newer seat.Pipeline, drop Drop, unbound func(ask string) error) (int, error) { + if newer.Run != "" || newer.Source <= 0 { + return 0, nil + } + pkeys, err := h.Store.Keys("*.*.*") + if err != nil { + return 0, fmt.Errorf("the pipelines are not listed: %w", err) + } + why := supersededWhy(newer) + ended := 0 + var first error + for _, key := range pkeys { + if key == newer.Pipeline { + continue + } + done, err := h.supersedeOne(key, newer, why, drop, unbound) + if err != nil && first == nil { + first = err + } + if done { + ended++ + } + } + return ended, first +} + +// superseded says whether old is a running pipeline newer supersedes (§31, A-202): of the same watched branch and an older +// source revision, either of the same module at another fingerprint, or, for newer a package's publish started, one an older +// publish of that package started (its version another, made from an older source revision of the branch). +func superseded(old, newer seat.Pipeline) bool { + if old.Outcome != seat.PipelineOutcomeRunning || old.Run != "" || old.Branch != newer.Branch || old.Source >= newer.Source { + return false + } + if newer.Published != nil && old.Published != nil && old.Published.Artifact == newer.Published.Artifact { + return old.Published.Version != newer.Published.Version + } + return old.Module == newer.Module && old.Fingerprint != newer.Fingerprint +} + +// supersededWhy names the newer commit, or the newer publish, that ended a pipeline. +func supersededWhy(newer seat.Pipeline) string { + if newer.Published != nil { + return fmt.Sprintf("the newer publish %s %s of %s supersedes it", newer.Published.Artifact, newer.Published.Version, newer.Branch) + } + return fmt.Sprintf("the newer commit %s of %s changed %s's fingerprint", newer.Commit, newer.Branch, newer.Module) +} + +func (h *Hearer) supersedeOne(key string, newer seat.Pipeline, why string, drop Drop, unbound func(string) error) (bool, error) { + for { + p, rev, err := h.Store.Get(key) + if err != nil || !superseded(p, newer) { + return false, nil + } + now := h.now() + var drops []seat.StepResult + var changed []int + for i := range p.Steps { + s := &p.Steps[i] + if s.Outcome != seat.StepResultOutcomeRunning && s.Outcome != seat.StepResultOutcomeWaiting { + continue + } + if s.Ask != "" && BoundSeat(s.Seat) { + drops = append(drops, *s) + } + s.Outcome, s.Why, s.Ended = seat.StepResultOutcomeSuperseded, why, ptrTime(now) + p.Unlogged = append(p.Unlogged, endEntry(key, *s, now)) + changed = append(changed, i) + } + p.Outcome, p.Why, p.Ended = seat.PipelineOutcomeSuperseded, why, ptrTime(now) + p.EndCount++ + if _, err := h.Store.Update(key, p, rev); errors.Is(err, ErrChanged) { + continue + } else if err != nil { + return false, fmt.Errorf("pipeline %s cannot be written: %w", key, err) + } + var first error + for _, i := range changed { + if err := h.say(seat.EventStepEnded, p, &p.Steps[i]); err != nil && first == nil { + first = fmt.Errorf("pipeline %s's %s is written but not said: %w", key, seat.EventStepEnded, err) + } + } + if len(changed) > 0 { + h.appendUnlogged(key) + } + for _, s := range drops { + if drop != nil { + if err := drop(s.Seat, s.Node, s.Ask, why); err != nil { + h.logf("the drop of ask %s of the superseded %s is not answered: %v", s.Ask, key, err) + } + } + if unbound != nil { + if err := unbound(s.Ask); err != nil { + h.logf("the bound of ask %s of the superseded %s is not dropped: %v", s.Ask, key, err) + } + } + } + if err := h.said(p, false, true); err != nil && first == nil { + first = err + } + return true, first + } +} + +func ptrTime(t time.Time) *time.Time { return &t } diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/supersede_test.go b/modules/mesh-delivery-pipeline/internal/pipeline/supersede_test.go new file mode 100644 index 00000000..8490be86 --- /dev/null +++ b/modules/mesh-delivery-pipeline/internal/pipeline/supersede_test.go @@ -0,0 +1,125 @@ +package pipeline + +import ( + "errors" + "slices" + "strings" + "testing" + + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshbuilds" + seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner" + nodebuildgo "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/nodebuildgo" +) + +// RN8 (design 47 G §31, §119, §350; A-182, A-202): a step keeps the node that took its ask; a newer commit changing the +// module's fingerprint, or a newer publish of the package that started a pipeline, ends the older running pipeline +// superseded, its asks dropped of the node that took each (any holder for one never taken), a mesh-builds step only ended; +// an unchanged fingerprint, a run on request and an equal publish supersede nothing. +func TestSupersede(t *testing.T) { + h, store, said, _, _, _ := fixture() + old := store[pkey] + old.p.Steps = append(old.p.Steps, seat.StepResult{Artifact: "program", Step: seat.StepResultStepComposition, Status: seat.StepResultStatusRequired, + Outcome: seat.StepResultOutcomeRunning, Ask: "b1", Seat: builds.Seat}) + store[pkey] = old + if err := h.Taken("a1", "n7"); err != nil { + t.Fatal(err) + } + if p, _, _ := store.Get(pkey); p.Steps[0].Node != "n7" || p.Steps[1].Node != "" { + t.Fatalf("after ask-taken of a1 on n7 the steps name %q and %q", p.Steps[0].Node, p.Steps[1].Node) + } + type dropped struct{ seat, node, ask string } + var drops []dropped + var unbound []string + drop := func(s, node, ask, why string) error { + if !strings.Contains(why, "c2") { + t.Errorf("the drop of %s names no newer commit: %q", ask, why) + } + drops = append(drops, dropped{s, node, ask}) + return nil + } + unbind := func(ask string) error { unbound = append(unbound, ask); return nil } + newer := func(key string, source int64, fp string, run string, pub *seat.Published) seat.Pipeline { + return seat.Pipeline{Pipeline: key, Branch: "main", Source: source, Module: "app", Commit: "c2", Fingerprint: fp, Run: run, Published: pub, + Outcome: seat.PipelineOutcomeRunning} + } + for _, c := range []struct { + name string + newer seat.Pipeline + }{ + {"an unchanged fingerprint", newer("main.6.app", 6, "src2:aa", "", nil)}, + {"a run on request", newer("main.r1.app", 6, "src2:bb", "r1", nil)}, + {"an older source revision", newer("main.4.app", 4, "src2:bb", "", nil)}, + } { + if n, err := h.Supersede(c.newer, drop, unbind); n != 0 || err != nil { + t.Errorf("%s superseded %d (%v)", c.name, n, err) + } + } + *said = nil + if n, err := h.Supersede(newer("main.6.app", 6, "src2:bb", "", nil), drop, unbind); n != 1 || err != nil { + t.Fatalf("a changed fingerprint superseded %d (%v)", n, err) + } + p, _, _ := store.Get(pkey) + if p.Outcome != seat.PipelineOutcomeSuperseded || p.EndCount != 1 || !strings.Contains(p.Why, "c2") { + t.Errorf("the older pipeline is %s (%d ends): %q", p.Outcome, p.EndCount, p.Why) + } + for _, s := range p.Steps { + if s.Outcome != seat.StepResultOutcomeSuperseded { + t.Errorf("the %s step is %s, want superseded", s.Step, s.Outcome) + } + } + want := []dropped{{nodebuildgo.Seat, "n7", "a1"}, {nodebuildgo.Seat, "", "a2"}} + if !slices.Equal(drops, want) || !slices.Equal(unbound, []string{"a1", "a2"}) { + t.Errorf("dropped %+v and unbound %v, want %+v (the mesh-builds step only ended)", drops, unbound, want) + } + if n := len(*said); n != 5 || (*said)[4] != seat.EventPipelineEnded+".app.main.c1" { + t.Errorf("said %v, want four step-ended then pipeline-ended", *said) + } + if n, _ := h.Supersede(newer("main.7.app", 7, "src2:cc", "", nil), drop, unbind); n != 0 { + t.Errorf("a superseded pipeline was superseded again: %d", n) + } + + // A newer publish of a package supersedes the pipelines an older publish of it started, whatever their module. + pub := memStore{"main.5.lib": {p: seat.Pipeline{Pipeline: "main.5.lib", Branch: "main", Source: 5, Module: "lib", Fingerprint: "src2:aa", + Outcome: seat.PipelineOutcomeRunning, Published: &seat.Published{Artifact: "core:pkg", Version: "v1"}}, rev: 1}} + h.Store = pub + if n, _ := h.Supersede(newer("main.6.app", 6, "src2:aa", "", &seat.Published{Artifact: "core:pkg", Version: "v1"}), drop, unbind); n != 0 { + t.Errorf("a publish of the same version superseded %d", n) + } + if n, err := h.Supersede(newer("main.6.app", 6, "src2:aa", "", &seat.Published{Artifact: "core:pkg", Version: "v2"}), drop, unbind); n != 1 || err != nil { + t.Fatalf("a newer publish superseded %d (%v)", n, err) + } + if p, _, _ := pub.Get("main.5.lib"); p.Outcome != seat.PipelineOutcomeSuperseded || !strings.Contains(p.Why, "v2") { + t.Errorf("the older publish's pipeline is %s: %q", p.Outcome, p.Why) + } +} + +// A-222, A-77: a drop is bounded by a key of its own armed before it is asked and deleted when it is answered; one never +// answered leaves its key to expire. +func TestBoundDrop(t *testing.T) { + var armed, answered []string + arm := func(key string) error { armed = append(armed, key); return nil } + done := func(s, ask string) error { answered = append(answered, s+"/"+ask); return nil } + if s, ask, ok := ParseDropBound(DropBoundKey(nodebuildgo.Seat, "step-ab")); !ok || s != nodebuildgo.Seat || ask != "step-ab" { + t.Errorf("the drop's key reads %q, %q, %v", s, ask, ok) + } + if _, _, ok := ParseDropBound("step-ab"); ok { + t.Error("an ask's bound was read as a drop's") + } + if IsAskKey(DropBoundKey("s", "step-ab")) { + t.Error("a drop's bound was taken for an ask's") + } + err := BoundDrop(func(string, string, string, string) error { return nil }, arm, done)(nodebuildgo.Seat, "n1", "step-ab", "why") + if err != nil || len(armed) != 1 || armed[0] != "drop."+nodebuildgo.Seat+".step-ab" || len(answered) != 1 { + t.Errorf("an answered drop: %v, armed %v, answered %v", err, armed, answered) + } + answered = nil + err = BoundDrop(func(string, string, string, string) error { return errors.New("no responders") }, arm, done)(nodebuildgo.Seat, "", "step-cd", "why") + if err == nil || len(armed) != 2 || len(answered) != 0 { + t.Errorf("an unanswered drop: %v, armed %v, answered %v (its key is left to expire)", err, armed, answered) + } + asked := false + err = BoundDrop(func(string, string, string, string) error { asked = true; return nil }, func(string) error { return errors.New("full") }, done)(nodebuildgo.Seat, "", "step-ef", "why") + if err == nil || asked { + t.Errorf("a drop whose bound is not armed: %v, asked %v", err, asked) + } +} diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/verbs.go b/modules/mesh-delivery-pipeline/internal/pipeline/verbs.go index 1c5bc55e..6ff1d86c 100644 --- a/modules/mesh-delivery-pipeline/internal/pipeline/verbs.go +++ b/modules/mesh-delivery-pipeline/internal/pipeline/verbs.go @@ -10,6 +10,7 @@ import ( seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner" wants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" nodebuildgo "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/nodebuildgo" + "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -21,6 +22,9 @@ type Verbs struct { // Starter asks a step again (rerun), as it asks the steps of a start; View reads a branch's catalog half for the ask. Starter *Starter View func(branch string) (Half, bool) + // Candidate reads a run's candidate's catalog half, its group `run-`, on branch at the revision its pipeline was placed + // from, for a rerun of a run's step (A-222): a run's steps are the candidate's, never the branch's. + Candidate func(group, branch string, revision int64) (Half, error) // Acts records a hand act before the verb does it (rerun, accept, cancel); a nil Acts refuses the verb, nothing recorded. Acts views.HandActs // Drop drops the bound of an ask a cancelled pipeline no longer waits on. @@ -299,15 +303,25 @@ func (v Verbs) Rerun(caller string, in seat.RerunInput) (seat.RerunAnswer, error // askOf is the ask to put on the serving seat for step s again: the one Place makes for it, under a new id (an ask put twice // under one id is kept once), saying it is a rerun and by whom. func (v Verbs) askOf(p seat.Pipeline, s seat.StepResult, caller string, now time.Time) (*Asked, error) { - if v.Starter == nil || v.Starter.Registration == nil || v.Starter.Steps == nil || v.View == nil { + if v.Starter == nil || v.Starter.Registration == nil || v.Starter.Steps == nil || (v.View == nil && p.Run == "") { return nil, errors.New("the runner cannot ask a step: it holds no registration or no ask") } reg, ok := v.Starter.Registration(p.Module) if !ok { return nil, fmt.Errorf("%s has no registration", p.Module) } - half, ok := v.View(p.Branch) - if !ok { + var half Half + if p.Run != "" { + if v.Candidate == nil { + return nil, errors.New("the runner cannot read a run's candidate") + } + var err error + if half, err = v.Candidate(RunGroupPrefix+p.Run, keys.EscapeName(p.Branch), p.Revision); err != nil { + return nil, fmt.Errorf("the candidate %s%s's revision %d is not read, so its step is not asked: %w", RunGroupPrefix, p.Run, p.Revision, err) + } + } else if h, ok := v.View(p.Branch); ok { + half = h + } else { return nil, fmt.Errorf("the catalog half of %s is not held yet: the step is asked when it is", p.Branch) } _, placed := Place(p, reg, half.Entries, now, v.Starter.Name) @@ -338,6 +352,15 @@ func (v Verbs) askOf(p seat.Pipeline, s seat.StepResult, caller string, now time return nil, fmt.Errorf("the %s step of %s is placed no ask now", s.Step, p.Pipeline) } +// Run starts a run on request for the caller the bus proved (design 47 G §64; Starter.Run): its pipelines written, its +// run-started said, its steps placed when its candidate's revision is heard. +func (v Verbs) Run(caller string, in seat.RunInput) (seat.RunAnswer, error) { + if v.Starter == nil { + return seat.RunAnswer{}, errors.New("the runner cannot start a run: it holds no starter") + } + return v.Starter.Run(caller, in, NewRunID) +} + // Start starts by hand a delivering pipeline for the head of a watched branch the runner's catalog half holds, recorded in // the hand-act log first (§306, §394): the pipeline of that branch's source revision and the module, as a take-in would // start it. A head whose pipeline is written already starts nothing and is refused, naming the pipeline. diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/verbs_test.go b/modules/mesh-delivery-pipeline/internal/pipeline/verbs_test.go index 633f120b..6b6ed3df 100644 --- a/modules/mesh-delivery-pipeline/internal/pipeline/verbs_test.go +++ b/modules/mesh-delivery-pipeline/internal/pipeline/verbs_test.go @@ -155,6 +155,59 @@ func TestAcceptRerunAndCancelWriteByCompareAndSetAndRecordTheirHandAct(t *testin } } +// A-222: a rerun of a run's step asks with the candidate's catalog half (its entries, here the directory), never the branch's, +// and is refused, nothing asked, when the candidate cannot be read. +func TestRerunOfARunAsksWithItsCandidatesHalf(t *testing.T) { + v, store, _, asks, _, _ := verbsFixture() + p, rev, _ := store.Get(pkey) + p.Run, p.Branch, p.Revision = "0a1b2c3d4e5f", "fix/1", 12 + for i := range p.Steps { + if p.Steps[i].Step == "lint" && p.Steps[i].Artifact == "app:program" { + p.Steps[i].Outcome = seat.StepResultOutcomeFailed + } + } + store.Update(pkey, p, rev) + _, cand := placeFixture() + e := cand["app"] + e.Path = "candidate/app" + cand["app"] = e + var read struct { + group, branch string + revision int64 + } + v.Candidate = func(group, branch string, revision int64) (Half, error) { + read.group, read.branch, read.revision = group, branch, revision + return Half{Revision: revision, Entries: cand}, nil + } + in := seat.RerunInput{Pipeline: pkey, Step: "lint", Artifact: "app:program", Why: "retry"} + if _, err := v.Rerun("operator", in); err != nil { + t.Fatal(err) + } + if b, ok := asks.asks[0].(nodebuildgo.StepAsk); !ok || b.Step == nil || b.Step.Fetch.Dir != "candidate/app" { + t.Errorf("the ask = %+v, want the candidate's directory", asks.asks[0]) + } + if read.group != "run-0a1b2c3d4e5f" || read.branch != "fix/1" || read.revision != 12 { + t.Errorf("the candidate read = %+v", read) + } + p, rev, _ = store.Get(pkey) + p.Steps[stepIndex(p, "lint", "app:program")].Outcome = seat.StepResultOutcomeFailed + store.Update(pkey, p, rev) + v.Candidate = func(string, string, int64) (Half, error) { return Half{}, errors.New("no answer") } + n := len(asks.asks) + if _, err := v.Rerun("operator", in); err == nil || len(asks.asks) != n { + t.Errorf("a rerun with no candidate read: %v, asked %d more", err, len(asks.asks)-n) + } +} + +func stepIndex(p seat.Pipeline, step, artifact string) int { + for i, s := range p.Steps { + if s.Step == step && s.Artifact == artifact { + return i + } + } + return -1 +} + // A hand act the log does not take is a verb that does nothing (ADR 0565 clause 11). func TestAVerbWhoseHandActIsNotRecordedDoesNothing(t *testing.T) { v, store, _, _, _, _ := verbsFixture() diff --git a/modules/mesh-delivery-pipeline/manifest_test.go b/modules/mesh-delivery-pipeline/manifest_test.go index 44c9368d..81f65253 100644 --- a/modules/mesh-delivery-pipeline/manifest_test.go +++ b/modules/mesh-delivery-pipeline/manifest_test.go @@ -88,6 +88,8 @@ func TestManifest(t *testing.T) { "seat:mesh-grants.grants-for", // RN11 (§33, A-191): the author of a failed head, read as the forge records it. "seat:git.pull", + // RN8 (§31, §350; A-182): a superseded step's ask dropped of the build seat that holds it. + "seat:node-build-go.drop", "seat:node-build-node.drop", "seat:node-build-image.drop", // A-74, A-89: who holds the seat operator-channel, whose state asks holds the router's record of the approval ask. "seat:mesh-module-assigner.assignments", // A-102 (§22, §24): the accepted kinds the runner puts its steps on. diff --git a/modules/mesh-delivery-pipeline/module.json b/modules/mesh-delivery-pipeline/module.json index e428eb86..37a6d125 100644 --- a/modules/mesh-delivery-pipeline/module.json +++ b/modules/mesh-delivery-pipeline/module.json @@ -67,7 +67,10 @@ "seat:node-build-image.check.step", "seat:node-build-go.delivery.step", "seat:node-build-node.delivery.step", - "seat:node-build-image.delivery.step" + "seat:node-build-image.delivery.step", + "seat:node-build-go.drop", + "seat:node-build-node.drop", + "seat:node-build-image.drop" ], "uses": [ "operator-channel" diff --git a/modules/mesh-grants/cmd/mesh-grants/composed_integration_test.go b/modules/mesh-grants/cmd/mesh-grants/composed_integration_test.go index 188a48e3..fb8fe2e7 100644 --- a/modules/mesh-grants/cmd/mesh-grants/composed_integration_test.go +++ b/modules/mesh-grants/cmd/mesh-grants/composed_integration_test.go @@ -11,10 +11,12 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "time" "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" + sdkbc "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/seal" @@ -403,3 +405,221 @@ func manifestIn(t *testing.T, module string) catalogue.Manifest { } return m } + +// composedBus is a server loading the accounts composed from records, its conf file rewritten and reloaded by recompose. +type composedBus struct { + t *testing.T + s *server.Server + conf string +} + +func startComposedBus(t *testing.T, records buscompose.Records) *composedBus { + dir := t.TempDir() + b := &composedBus{t: t, conf: filepath.Join(dir, "server.conf")} + b.write(records, dir) + opts, err := server.ProcessConfigFile(b.conf) + if err != nil { + t.Fatalf("the composed list is not the server's: %v", err) + } + opts.NoLog, opts.NoSigs = true, true + if b.s, err = server.NewServer(opts); err != nil { + t.Fatal(err) + } + go b.s.Start() + if !b.s.ReadyForConnections(30 * time.Second) { + t.Fatal("the bus did not come up") + } + t.Cleanup(b.s.Shutdown) + return b +} + +func (b *composedBus) write(records buscompose.Records, dir string) { + users, err := buscompose.Users(records) + if err != nil { + b.t.Fatal(err) + } + for i := range users { + users[i].PasswordHash = "pw-" + users[i].Username() + } + accounts, err := buscompose.ComposeAccounts(users) + if err != nil { + b.t.Fatal(err) + } + if err := os.WriteFile(b.conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: \""+dir+"\" }\n"+accounts), 0o600); err != nil { + b.t.Fatal(err) + } +} + +// recompose writes the accounts composed from records again and has the server load them. +func (b *composedBus) recompose(records buscompose.Records) { + b.write(records, filepath.Dir(b.conf)) + if err := b.s.Reload(); err != nil { + b.t.Fatalf("the server does not load the composed list again: %v", err) + } +} + +// dial connects as a composed user; the server's refusals of its subscriptions and publishes arrive on refusals. +func (b *composedBus) dial(user string) (*nats.Conn, chan string) { + refusals := make(chan string, 16) + nc, err := nats.Connect(b.s.ClientURL(), nats.UserInfo(user, "pw-"+user), nats.CustomInboxPrefix("_INBOX."+user), + nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refusals <- err.Error() })) + if err != nil { + b.t.Fatalf("%s: %v", user, err) + } + b.t.Cleanup(nc.Close) + return nc, refusals +} + +// refusedWithin waits, within a bound, for the server's refusal naming subject. +func refusedWithin(refusals chan string, subject string) bool { + bound := time.NewTimer(5 * time.Second) + defer bound.Stop() + for { + select { + case e := <-refusals: + if strings.Contains(e, "Permissions Violation") && strings.Contains(e, subject) { + return true + } + case <-bound.C: + return false + } + } +} + +// theToolRunnerHearsEveryEventOnTheBus, run by TestTheGrantsPartWhole, defends design 34 every-event §9 and §27 on a +// real server (ADR 0670 §1, §3, ADR 0671 §1, §2): the tool runner's account, as composed, hears a module's event, a +// module stream's and a restricted stream's whose grant value names no tool runner, still after that value changes and +// the accounts are composed again; it publishes none of them, hears no accept or proof, and gets no durable consumer +// and no acknowledgement on the restricted stream; another module hears no event it does not consume, and a module +// consuming the restricted stream with no entry in its grant value gets no consumer and no read. +func theToolRunnerHearsEveryEventOnTheBus(t *testing.T) { + const ledger = "mesh.stream.shop.event.booked" + records := func(auditorEntry []string) buscompose.Records { + return buscompose.Records{Nodes: []string{"a"}, Assigned: map[string][]buscompose.Declared{"a": { + {Module: buscompose.GrantsModule}, + {Module: "node-tools", ToolRunner: true}, + {Module: "mesh-delivery-view", Emits: []string{"delivered"}}, + {Module: "plex"}, + {Module: "shop", Streams: []buscompose.ModuleStream{ + {Module: "shop", Name: "SHOP", Events: []string{"opened"}, MaxAgeDays: 1, MaxMiB: 1}, + {Module: "shop", Name: "LEDGER", Events: []string{"booked"}, MaxAgeDays: 1, MaxMiB: 1, Restricted: true}}}, + {Module: "auditor", RunsAs: "auditor", Consumes: []string{"shop.booked"}, + Hears: []buscompose.Heard{{Stream: "LEDGER", Emitter: "shop", Filters: auditorEntry, Restricted: true}}}, + }}} + } + bus := startComposedBus(t, records(nil)) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + refusedIn := func() context.Context { + c, stop := context.WithTimeout(ctx, 2*time.Second) + t.Cleanup(stop) + return c + } + grantsConn, _ := bus.dial("a." + buscompose.GrantsModule) + grants, err := jetstream.New(grantsConn) + if err != nil { + t.Fatal(err) + } + for _, st := range []jetstream.StreamConfig{{Name: "SHOP", Subjects: []string{"mesh.stream.shop.event.opened"}}, + {Name: "LEDGER", Subjects: []string{ledger}}} { + if _, err := grants.CreateStream(ctx, st); err != nil { + t.Fatalf("the grants part creates the declared stream %s: %v", st.Name, err) + } + } + runner, runnerRefused := bus.dial("a.node-tools") + heard := make(chan *nats.Msg, 16) + for _, s := range sdkbc.EveryEvent() { + if _, err := runner.ChanSubscribe(s, heard); err != nil { + t.Fatal(err) + } + } + if err := runner.Flush(); err != nil { + t.Fatal(err) + } + hears := func(subject, from string) { + t.Helper() + select { + case m := <-heard: + if m.Subject != subject { + t.Errorf("the tool runner heard %s, want %s published by %s", m.Subject, subject, from) + } + case <-time.After(5 * time.Second): + t.Errorf("the tool runner heard nothing, want %s published by %s", subject, from) + } + } + view, _ := bus.dial("a.mesh-delivery-view") + shop, _ := bus.dial("a.shop") + for _, e := range []struct { + conn *nats.Conn + subject, from string + }{ + {view, "mesh.mod.mesh-delivery-view.event.delivered", "the view"}, + {shop, "mesh.stream.shop.event.opened", "a module's stream"}, + {shop, ledger, "a restricted stream whose grant names no tool runner"}} { + if err := e.conn.Publish(e.subject, []byte(`{}`)); err != nil { + t.Fatal(err) + } + if err := e.conn.Flush(); err != nil { + t.Fatal(err) + } + hears(e.subject, e.from) + } + // It publishes no event, and hears no accept nor proof (they are never events). + if err := runner.Publish("mesh.mod.mesh-delivery-view.event.delivered", []byte(`{}`)); err != nil { + t.Fatal(err) + } + if !refusedWithin(runnerRefused, "mesh.mod.mesh-delivery-view.event.delivered") { + t.Error("the tool runner published the view's event") + } + for _, s := range []string{"mesh.seat.channel.accept.show.k", "mesh.seat.intake.proof.code.k"} { + if _, err := runner.SubscribeSync(s); err != nil { + t.Fatal(err) + } + if !refusedWithin(runnerRefused, s) { + t.Errorf("the tool runner subscribed %s", s) + } + } + // No durable consumer, no acknowledgement on the restricted stream. + runnerJS, err := jetstream.New(runner) + if err != nil { + t.Fatal(err) + } + if _, err := runnerJS.CreateConsumer(refusedIn(), "LEDGER", jetstream.ConsumerConfig{Durable: "node-tools"}); err == nil { + t.Error("the tool runner made a durable consumer on the restricted stream") + } + if err := runner.Publish("$JS.ACK.LEDGER.node-tools.1.1.1.1.0", nil); err != nil { + t.Fatal(err) + } + if !refusedWithin(runnerRefused, "$JS.ACK.LEDGER") { + t.Error("the tool runner acknowledged on the restricted stream") + } + // Another module hears no event it does not consume. + plex, plexRefused := bus.dial("a.plex") + if _, err := plex.SubscribeSync("mesh.mod.mesh-delivery-view.event.delivered"); err != nil { + t.Fatal(err) + } + if !refusedWithin(plexRefused, "mesh.mod.mesh-delivery-view.event.delivered") { + t.Error("plex subscribed an event it does not consume") + } + // A consumer of the restricted stream with no entry in its grant value gets no consumer and no read. + auditor, _ := bus.dial("a.auditor") + auditorJS, err := jetstream.New(auditor) + if err != nil { + t.Fatal(err) + } + if _, err := auditorJS.CreateConsumer(refusedIn(), "LEDGER", jetstream.ConsumerConfig{Durable: "auditor"}); err == nil { + t.Error("a consumer with no entry made a durable consumer on the restricted stream") + } + if _, err := auditorJS.Stream(refusedIn(), "LEDGER"); err == nil { + t.Error("a consumer with no entry reads the restricted stream") + } + // The grant value changes and the accounts are composed again: the tool runner still hears the restricted stream. + bus.recompose(records([]string{ledger})) + if err := shop.Publish(ledger, []byte(`{}`)); err != nil { + t.Fatal(err) + } + if err := shop.Flush(); err != nil { + t.Fatal(err) + } + hears(ledger, "the restricted stream after its grant value changed") +} diff --git a/modules/mesh-grants/cmd/mesh-grants/integration_test.go b/modules/mesh-grants/cmd/mesh-grants/integration_test.go index 2d4e9f82..4b5fc605 100644 --- a/modules/mesh-grants/cmd/mesh-grants/integration_test.go +++ b/modules/mesh-grants/cmd/mesh-grants/integration_test.go @@ -627,6 +627,7 @@ func TestTheGrantsPartWhole(t *testing.T) { // And what the part composes holds on a server that loads it (one integration test per module, the test budget). t.Run("the composed grants hold on a server that loads them", theBusNodeFollowsTheSealedListUnderTheComposedGrants) + t.Run("the tool runner hears every event on a server that loads its grants", theToolRunnerHearsEveryEventOnTheBus) } // lockedLog is the part's log, read by the test while the part writes it. diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index 54971dea..0ad31793 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshgrants" "git.novox.be/novox/mesh-sdk/go/seatlist" "git.novox.be/novox/mesh-sdk/go/views" @@ -257,6 +258,23 @@ func toolsOf(p *grants.Part) []stdio.Tool { "composed yet by this version of the part, with its step, the acting part, its version, since when, its bound (10 minutes) and " + "clock-from (the start of this version's first pass), and which have passed their bound. Read-only.", Run: func(map[string]any) (any, error) { return p.WaitsOf() }}, + {Name: grants.ToolCallers, Description: "The callers each verb and each accepted kind of the seat definitions admits (design 47 G §83): " + + "the list the definition names, or the default of the verb's class. Name a seat, or a seat and a verb, or neither for every one. Read-only.", + Input: schema(map[string]string{"seat": "a seat's name (default: every seat)", "verb": "a verb or accepted kind (default: every one)"}), + Run: func(a map[string]any) (any, error) { + return p.Callers(grants.Answer(a, "seat"), grants.Answer(a, "verb")) + }}, + {Name: grants.ToolWhyRefused, Description: "Why an account's call of a seat's verb is refused (design 47 G §83): the call subject the account " + + "lacks a grant for (the bus's own answer, nats_user_can) and the definition's callers that leave it out. Read-only.", + Input: schema(map[string]string{"account": "the bus user, e.g. g14.node-tools", "verb": "the verb as .", "node": "the node, for a node seat"}, "account", "verb"), + Run: func(a map[string]any) (any, error) { + return p.WhyRefused(grants.Answer(a, "account"), grants.Answer(a, "verb"), grants.Answer(a, "node")) + }}, + {Name: meshgrants.Seat + "." + meshgrants.VerbGrantsFor, + Description: "The grants a candidate would compose, per module (design 47 G §441): the grants it would add and remove against the branch's newest.", + Run: func(a map[string]any) (any, error) { + return p.GrantsFor(meshgrants.GrantsForInput{Branch: grants.Answer(a, "branch"), Run: grants.Answer(a, "run"), Source: grants.Answer(a, "source")}) + }}, {Name: grants.ToolRelease, Description: "Release one held granting wait by a person's act with why, recorded as a hand act " + "(the seat mesh-hand-acts) before it is released (design 47 G §276). grants_waits lists them.", Input: schema(map[string]string{"module": "the held entry's module", "why": "why, kept in the hand act"}, "module", "why"), diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 8f722f46..20ac0160 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -29,6 +29,7 @@ import ( "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshgrants" "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller" "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" @@ -91,8 +92,11 @@ type fakeBus struct { maxValue int // entries are the catalog half's entries by fingerprint, the manifest each holds as the indexer's manifests state has it // (entry); entriesAsked says every one is asked, not answered: the half cannot be read yet. - entries map[string]string - entriesAsked bool + entries map[string]string + entriesAsked bool + // revision is the catalog half of mesh-catalog's `revision`: the branch's `newest` and a `candidate` (a source or a group), each + // module's manifest. + revision map[string]map[string]string catalogWaited []string } @@ -363,6 +367,21 @@ func (b *fakeBus) Call(key string, body any) (json.RawMessage, error) { } } return json.Marshal(map[string]any{"users": out}) + case grants.CallRevision: + var in meshcatalog.RevisionInput + raw, _ := json.Marshal(body) + _ = json.Unmarshal(raw, &in) + which := "newest" + if in.Group != "" || in.Source != 0 { + which = "candidate" + } + var out meshcatalog.RevisionAnswer + for module, manifest := range b.revision[which] { + var m map[string]any + _ = json.Unmarshal([]byte(manifest), &m) + out.Entries = append(out.Entries, meshcatalog.CatalogEntry{Module: module, Manifest: m}) + } + return json.Marshal(out) case grants.CallStreams: if stream, _ := args["stream"].(string); stream != "" { return json.Marshal(map[string]any{"detail": []map[string]any{{"name": stream, "consumer_detail": b.consumers[stream]}}}) @@ -602,10 +621,9 @@ func TestGrants(t *testing.T) { if err != nil { t.Fatalf("mesh-sdk cannot decode the manifest: %v", err) } - // The part claims no seat: the seat mesh-grants served the lease write alone, which went with the controller - // (novox/hq ADR 0590 §3, issue 1126). - if m.Module != "mesh-grants" || len(m.Claims) != 0 { - t.Errorf("module %q claims %v: the part claims no seat", m.Module, m.Claims) + // The part claims the seat mesh-grants, which serves `grants-for` (design 47 G §401, §441). + if m.Module != "mesh-grants" || len(m.Claims) != 1 || m.Claims[0].Name != "mesh-grants" || !slices.Equal(m.Claims[0].Serves, []string{"grants-for"}) { + t.Errorf("module %q claims %v: the part claims the seat mesh-grants alone, serving grants-for", m.Module, m.Claims) } for _, row := range []struct { what string @@ -5095,3 +5113,37 @@ func TestAnOldStateSectionDeclaresItsCap(t *testing.T) { } } } + +// grants-for answers what the composed accounts gain and lose with a candidate's entries in place of the branch's newest (design +// 47 G §441, A-205): a tool the candidate adds is an added grant naming its verb, and an input that names no source nor run is +// refused. +func TestGrantsFor(t *testing.T) { + b := fixture() + candidateX := strings.Replace(strings.Replace(xManifest, `,"logs":[{"name":"history","max-age-days":30}]`, "", 1), `"state"`, `"emits":["hello"],"state"`, 1) + b.revision = map[string]map[string]string{"newest": {"mod-x": xManifest}, "candidate": {"mod-x": candidateX}} + p := partOn(b) + got, err := p.GrantsFor(meshgrants.GrantsForInput{Branch: "main", Run: "r1"}) + if err != nil || got.Error != "" || len(got.Modules) != 1 || got.Modules[0].Module != "mod-x" { + t.Fatalf("grants-for: %+v, %v", got, err) + } + var verbs []string + for _, g := range got.Modules[0].Added { + verbs = append(verbs, g.Verb+" "+g.Subject) + } + if !slices.ContainsFunc(got.Modules[0].Added, func(g meshgrants.Grant) bool { + return g.Verb == "hello" && strings.HasPrefix(g.Subject, "mesh.mod.mod-x.") + }) { + t.Errorf("the event the candidate adds is not an added grant: %v / %+v", verbs, got.Modules[0]) + } + if len(got.Modules[0].Removed) == 0 { + t.Error("the log the candidate drops is no removed grant") + } + b.revision["candidate"] = b.revision["newest"] + same, _ := p.GrantsFor(meshgrants.GrantsForInput{Branch: "main", Source: "7"}) + if len(same.Modules) != 0 { + t.Errorf("a candidate equal to the newest changes grants: %+v", same.Modules) + } + if _, err := p.GrantsFor(meshgrants.GrantsForInput{Branch: "main"}); err == nil { + t.Error("grants-for with neither a source nor a run answered") + } +} diff --git a/modules/mesh-grants/internal/buscompose/buscompose_test.go b/modules/mesh-grants/internal/buscompose/buscompose_test.go index 9a623ad2..53a1bc51 100644 --- a/modules/mesh-grants/internal/buscompose/buscompose_test.go +++ b/modules/mesh-grants/internal/buscompose/buscompose_test.go @@ -20,6 +20,7 @@ import ( "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/announce" + sdkbc "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/seatdef" "github.com/nats-io/nats.go/jetstream" @@ -60,6 +61,8 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Run("a module's ledger is written by its ledger account alone", aLedgerIsWrittenByItsLedgerAccountAlone) t.Run("the bus module's process alone keeps every module's bus state and serves the broker's verbs", theBusModulesProcessKeepsBusState) t.Run("a process serving a verb is granted its calls and chunk subjects, and the reverse goes to an account naming it", chunkSubjectsFollowTheAnswerer) + t.Run("the tool runner's runtime hears every event subject and nothing wider", theToolRunnerHearsEveryEvent) + t.Run("a per-machine state reaches its machine's keys below it and no other machine's", aPerMachineStateReachesItsMachinesKeys) // ADR 0567 §3, §9: a node reads every key of the seat's assignments and its candidates (it composes with the whole // proposal laid over), and none of its open proposals; it writes neither. reads := strings.Join(NodeComposeReads("laptop"), " ") @@ -1447,3 +1450,111 @@ func chunkSubjectsFollowTheAnswerer(t *testing.T) { t.Errorf("the keeper's restore grant reaches a stream that is not restricted") } } + +// EE-D2 (design 34 every-event §6, §8, §25, §27; ADR 0670 §1, §3, ADR 0671 §1): the runtime carrying the tool runner +// subscribes the three event subjects of mesh-sdk's EveryEvent, beside a module with a restricted stream, and nothing +// wider; it publishes no other module's or seat's event and derives no durable consumer or acknowledgement on that +// stream; a runtime carrying no tool runner gets none of the three. +func theToolRunnerHearsEveryEvent(t *testing.T) { + shop := func() Declared { + return Declared{Module: "shop", Serves: []string{"sell"}, Emits: []string{"sold"}, + Streams: []ModuleStream{{Module: "shop", Name: "LEDGER", Events: []string{"booked"}, Restricted: true}}} + } + r := Records{Nodes: []string{"a", "b"}, Assigned: map[string][]Declared{ + "a": {{Module: "node-tools", ToolRunner: true}, shop()}, + "b": {shop()}}} + users, err := Users(r) + if err != nil { + t.Fatal(err) + } + every := sdkbc.EveryEvent() + if len(every) != 3 { + t.Fatalf("EveryEvent answers %v, want three subjects", every) + } + wider := []string{">", "mesh.>", "mesh.*.>", "mesh.seat.>", "mesh.mod.>", "mesh.stream.>"} + runners := 0 + for _, u := range users { + p, err := PermissionsFor(u) + if err != nil { + t.Fatalf("%s: %v", u.Username(), err) + } + runs := false + for _, d := range u.Carries { + runs = runs || d.ToolRunner + } + for _, e := range every { + if slices.Contains(p.Subscribe, e) != runs { + t.Errorf("%s (tool runner %v) subscribes %s: %v", u.Username(), runs, e, !runs) + } + } + for _, s := range p.Subscribe { + if slices.Contains(wider, s) { + t.Errorf("%s subscribes %s, wider than every event", u.Username(), s) + } + } + if !runs { + continue + } + runners++ + for _, s := range p.Subscribe { + if strings.HasPrefix(s, "$JS.") && strings.Contains(s, "LEDGER") { + t.Errorf("the tool runner's runtime hears %s on the restricted stream", s) + } + } + for _, s := range []string{"mesh.mod.other.event.x", "mesh.seat.mesh-catalog.event.manifest-accepted", + "mesh.stream.other.event.x"} { + if MayPublish(p, s) { + t.Errorf("the tool runner's runtime may publish another's event %s", s) + } + } + for _, s := range p.Publish { + if strings.Contains(s, "LEDGER") || strings.HasPrefix(s, "$JS.ACK.") { + t.Errorf("the tool runner's runtime may %s on the restricted stream", s) + } + } + } + if runners != 1 { + t.Errorf("%d runtimes carry the tool runner, want 1", runners) + } +} + +// AM-D5 (design 46 approval mark §13): a per-machine state reaches its machine's key and the keys below it, `.>`, +// and no other machine's, on the account and in the membership alike. +func aPerMachineStateReachesItsMachinesKeys(t *testing.T) { + runner := func() Declared { + return Declared{Module: "node-tools", ToolRunner: true, + State: []Bucket{{Module: "node-tools", Name: "approvals", PerMachine: true, KeyTTL: true, TTLSeconds: 172800}}} + } + r := Records{Nodes: []string{"a", "b"}, Assigned: map[string][]Declared{"a": {runner()}, "b": {runner()}}} + users, err := Users(r) + if err != nil { + t.Fatal(err) + } + const bucket = "node-tools_approvals" + found := false + for _, u := range users { + if u.Username() != "a.node-tools" { + continue + } + found = true + p, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + for key, reach := range map[string]bool{"a": true, "a.ask.1": true, "a.act.d": true, "b": false, "b.ask.1": false, "ab.ask.1": false} { + if MayPublish(p, "$KV."+bucket+"."+key) != reach { + t.Errorf("a's runner writes %s's key %s: %v", bucket, key, !reach) + } + if MayPublish(p, "$JS.API.DIRECT.GET.KV_"+bucket+".$KV."+bucket+"."+key) != reach { + t.Errorf("a's runner reads %s's key %s: %v", bucket, key, !reach) + } + } + } + if !found { + t.Fatal("no account a.node-tools composed") + } + // The membership names the machine's key, which reaches the keys below it in the same form (mesh-sdk membership). + if issued := stateIssuedFor(runner(), "a"); len(issued) != 1 || issued[0].Key != "a" || issued[0].Bucket != bucket { + t.Errorf("a's membership issues %+v", issued) + } +} diff --git a/modules/mesh-grants/internal/buscompose/busstate.go b/modules/mesh-grants/internal/buscompose/busstate.go index f0716943..7b868fea 100644 --- a/modules/mesh-grants/internal/buscompose/busstate.go +++ b/modules/mesh-grants/internal/buscompose/busstate.go @@ -249,6 +249,11 @@ func boundOf(module string, bindings []membership.BusBinding) boundGrants { case catalogue.StorageKeyValue: if b.Bucket == base { g.States = append(g.States, b.Local) + // Its object store beside it, `-objects`, is its owner's alone (design 47 G §58, §91): the one bucket the + // binding names for this module's own state. + if b.Objects != "" && b.Objects == module+"_"+catalogue.StateObjectsName(b.Local) { + g.Objects = append(g.Objects, b.Objects) + } } case catalogue.StorageObjectStore: if b.ObjectStore == base { diff --git a/modules/mesh-grants/internal/buscompose/callers.go b/modules/mesh-grants/internal/buscompose/callers.go index 5af25dd0..2cd8a779 100644 --- a/modules/mesh-grants/internal/buscompose/callers.go +++ b/modules/mesh-grants/internal/buscompose/callers.go @@ -115,3 +115,6 @@ func callerNamed(user string, pub, sub []string) ([]string, []string) { } return unique(pub), unique(sub) } + +// SeatToolSubject is where a seat's verb is served: `mesh.seat..tool.`, with the node last on a node seat. +func SeatToolSubject(s Seat, verb, node string) string { return seatToolSubject(s, verb, node) } diff --git a/modules/mesh-grants/internal/buscompose/membership.go b/modules/mesh-grants/internal/buscompose/membership.go index b5c2a8a9..cc6a3ef0 100644 --- a/modules/mesh-grants/internal/buscompose/membership.go +++ b/modules/mesh-grants/internal/buscompose/membership.go @@ -64,7 +64,6 @@ func MembershipFor(node string, d Declared, where Assignments) Membership { m.Logs = append(logsIssuedFor(d), readLogsIssuedFor(withReadLogs(d, where.Logs))...) m.Streams = streamsIssuedFor(d) m.Hears = HeardIssuedFor(node, d) - m.Waits = waitsIssuedFor(d) t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches) for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) { if !s.Kinded { diff --git a/modules/mesh-grants/internal/buscompose/modstreams.go b/modules/mesh-grants/internal/buscompose/modstreams.go index 0c724157..94805b01 100644 --- a/modules/mesh-grants/internal/buscompose/modstreams.go +++ b/modules/mesh-grants/internal/buscompose/modstreams.go @@ -29,23 +29,12 @@ func ModuleStreamSubject(module, event string) string { // OnAModuleStream says a subject is one a module's own stream takes. func OnAModuleStream(subject string) bool { return strings.HasPrefix(subject, moduleStreamPrefix) } -// WirePattern is a local event pattern as the bus spells it: `**` is `>`. -func WirePattern(local string) string { - parts := strings.Split(local, ".") - for i, p := range parts { - if p == "**" { - parts[i] = ">" - } - } - return strings.Join(parts, ".") -} - // A ModuleStream is one module's declared stream as the bus holds it. type ModuleStream struct { Module string // Name is its name on the bus, `MAIL`. Name string - // Events are the events it carries, as the bus spells them (WirePattern). + // Events are the events it carries, as the bus spells them (mesh-sdk buscompose.WirePattern). Events []string MaxAgeDays int MaxMiB int diff --git a/modules/mesh-grants/internal/buscompose/named_asks_test.go b/modules/mesh-grants/internal/buscompose/named_asks_test.go new file mode 100644 index 00000000..066edd62 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/named_asks_test.go @@ -0,0 +1,69 @@ +package buscompose + +import "testing" + +// Design 47 G §543, §544, §600: a build seat's ask names its asker, each queue has a named worker beside main's, an asker +// is granted the queues of its kinds under its own seat's name alone, and main's subject is the checker's and the builder's. +func TestNamedAsks(t *testing.T) { + build := Seat{Name: "node-build-go", Scope: "node", Accepts: []string{"delivery", "check", "recheck"}, PulledByKind: true, + AcceptCallers: map[string][]string{ + "delivery": {"mesh-builds"}, + "check": {"mesh-pipeline-runner", "mesh-catalog", "mesh-delivery-checker"}, + "recheck": {"mesh-delivery-checker"}, + }} + asker := func(module string, held ...string) Principal { + p := Principal{Kind: KindModule, Node: "one", Module: module, Uses: []Seat{build}} + for _, h := range held { + p.Holds = append(p.Holds, Seat{Name: h}) + } + return p + } + const base = "mesh.seat.node-build-go.accept." + for _, c := range []struct { + who Principal + want map[string]bool + }{ + {asker("runner-module", "mesh-pipeline-runner"), map[string]bool{ + base + "check.mesh-pipeline-runner": true, base + "delivery.mesh-pipeline-runner": false, + base + "check.mesh-catalog": false, base + "check": false, base + "delivery": false}}, + {asker("an-indexer", "mesh-catalog"), map[string]bool{ + base + "check.mesh-catalog": true, base + "check.mesh-pipeline-runner": false, base + "recheck.mesh-catalog": false}}, + {asker("the-builder", "mesh-builds"), map[string]bool{ + base + "delivery.mesh-builds": true, base + "delivery": true, base + "check": true, base + "check.mesh-builds": false}}, + {asker("the-checker", "mesh-delivery-checker"), map[string]bool{ + base + "check.mesh-delivery-checker": true, base + "recheck.mesh-delivery-checker": true, base + "recheck": true, + base + "delivery.mesh-delivery-checker": false}}, + {asker("a-stranger"), map[string]bool{base + "check": false, base + "delivery": false, base + "check.a-stranger": false}}, + } { + perms, err := PermissionsFor(c.who) + if err != nil { + t.Fatal(err) + } + for subject, want := range c.want { + if got := MayPublish(perms, subject); got != want { + t.Errorf("%s may publish %s: %v, want %v", c.who.Module, subject, got, want) + } + } + } + // Each queue's workers: main's and the named one, filtered to subjects that overlap nothing. + held := Seat{Name: "node-build-go", Scope: "node", Accepts: []string{"delivery", "check"}, PulledByKind: true} + tr := SeatTrafficOf("go-module", []Seat{held}, nil, nil) + filters := map[string]string{} + for _, w := range tr.Workers { + filters[w.Consumer] = w.Filter + } + for consumer, filter := range map[string]string{ + "SEAT_NODE_BUILD_GO_CHECK_worker": base + "check", + "SEAT_NODE_BUILD_GO_CHECK_named_worker": base + "check.*", + "SEAT_NODE_BUILD_GO_DELIVERY_worker": base + "delivery", + "SEAT_NODE_BUILD_GO_DELIVERY_named_worker": base + "delivery.*", + } { + if filters[consumer] != filter { + t.Errorf("worker %s filters %q, want %q", consumer, filters[consumer], filter) + } + } + _, consumers := SeatTrafficObjects([]Principal{{Kind: KindModule, Node: "one", Module: "go-module", Holds: []Seat{held}}}) + if len(consumers) != 4 { + t.Errorf("the work queue's consumers: %d, want 4 (a main and a named worker for each of two queues)", len(consumers)) + } +} diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index febf1457..9d9942b7 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -103,6 +103,10 @@ type Seat struct { // PulledByKind says each accepted kind is a worker of its own on the seat's queue, filtered to that kind, which // every holder pulls in the order Accepts lists them: the build seats' (novox/hq ADR 0525 §2, ADR 0536 §4). PulledByKind bool + // AcceptCallers are, for a seat pulled by kind, the seats that may ask each of its queues, by queue: the `seat:` + // entries of the callers of the accepted kinds the definition puts on that queue (design 47 G §543, §600). The asker + // in a named ask's subject is the asking seat's name (A-163). Empty where the seat list is unread. + AcceptCallers map[string][]string // HeldByTheEngine says every node's engine holds the seat on its own account (catalogue.Seat.HeldByTheEngine, novox/hq // ADR 0563 §1): each of its events names the engine's machine as its last token (machineEvent). HeldByTheEngine bool @@ -1192,14 +1196,13 @@ func permissionsFor(p Principal) (Permissions, error) { pub = append(pub, logGrants(d.Module, logNames(d.Logs))...) pub = append(pub, readLogGrants(d.ReadLogs)...) } - // **And the event families a session may wait on** (novox/hq ADR 0367 §2): the tool runner's module's own, - // named one by one in its manifest, as a plain subscription — no consumer is made for them, and no - // call or setting adds one. - waits, err := waitGrants(p.Carries) - if err != nil { - return Permissions{}, err + // **And every event subject** (design 34 every-event §6, §25; novox/hq ADR 0670 §1, ADR 0671 §1): the runtime + // carrying the tool runner hears every seat's, module's and module stream's events, a restricted stream + // included, as a plain subscription: no publish, no durable consumer, no acknowledgement and no deny is added + // for it, and a runtime carrying no tool runner gets none of them. + if slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.ToolRunner }) { + sub = append(sub, sdkbc.EveryEvent()...) } - sub = append(sub, waits...) // **Never the traffic of a trusted holder** (novox/hq ADR 0259 §8): the machine's runtime runs as the // operator's account, which every agent runs as, so a module saying warrants or speaking for a kind // that proves its sender is never composed into it — refused here, naming it, whatever registration diff --git a/modules/mesh-grants/internal/buscompose/sdk_membership.go b/modules/mesh-grants/internal/buscompose/sdk_membership.go index c27b383b..bb07f183 100644 --- a/modules/mesh-grants/internal/buscompose/sdk_membership.go +++ b/modules/mesh-grants/internal/buscompose/sdk_membership.go @@ -20,7 +20,6 @@ type ( KindHeld = membership.KindHeld StreamIssued = membership.StreamIssued HeardIssued = membership.HeardIssued - WaitIssued = membership.WaitIssued ) // AssignmentsStream holds every assignment's membership, the newest per subject. diff --git a/modules/mesh-grants/internal/buscompose/seattraffic.go b/modules/mesh-grants/internal/buscompose/seattraffic.go index 5e369c6b..0cb21b5f 100644 --- a/modules/mesh-grants/internal/buscompose/seattraffic.go +++ b/modules/mesh-grants/internal/buscompose/seattraffic.go @@ -35,6 +35,16 @@ const BuildAskMaxDeliver = 5 // KindedBenches are the seats that may be kinded (ADR 0234 §2): making another is a decision, recorded. var KindedBenches = map[string]bool{"channel": true, "intake": true} +// NamedWorkerName is the worker of a pulled-by-kind seat's named asks, `accept..`, beside the queue's own (WorkerName): +// two filters that overlap nothing, so the work queue takes both (design 47 G §544). +func NamedWorkerName(seat, queue string) string { + return "SEAT_" + upperSnake(seat) + "_" + upperSnake(queue) + "_named_worker" +} + +// MainAskers are the seats that may publish a build seat's ask on main's subject `accept.`, which names no asker: the +// checker and the builder, until main's subject goes (design 47 G §543, §600, A-184). +var MainAskers = []string{"mesh-delivery-checker", "mesh-builds"} + // WorkerName is the worker a seat's holders pull from: one for the seat, or one per kind on a kinded bench. func WorkerName(seat, kind string) string { if kind == "" { @@ -86,6 +96,9 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic { for _, a := range s.Accepts { t.Workers = append(t.Workers, Worker{Stream: seatStreamName(s.Name), Consumer: WorkerName(s.Name, a), Filter: "mesh.seat." + s.Name + ".accept." + a}) + // Its named asks have a worker of their own (§544); a holder of G pulls it, one before G knows only main's. + t.Workers = append(t.Workers, Worker{Stream: seatStreamName(s.Name), Consumer: NamedWorkerName(s.Name, a), + Filter: "mesh.seat." + s.Name + ".accept." + a + ".*"}) } } else if len(s.Accepts) > 0 { stream := seatStreamName(s.Name) @@ -121,7 +134,25 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic { if !s.isNewTraffic() { continue } + if s.PulledByKind { + // **An ask names its asker** (§543, §600): each asker is granted `accept..` for the queues whose + // kinds it is a caller of, its name the seat it holds, and main's `accept.` is the checker's and the + // builder's alone. + for _, q := range s.Accepts { + for _, h := range holds { + if namesVerb(s.AcceptCallers[q], h.Name) { + t.Publish = append(t.Publish, seatSubject(s, "accept", q+"."+h.Name)) + } + if namesVerb(MainAskers, h.Name) { + t.Publish = append(t.Publish, seatSubject(s, "accept", q)) + } + } + } + } for _, a := range s.Accepts { + if s.PulledByKind { + break + } switch { case namesVerb(s.ByCaller, a): t.Publish = append(t.Publish, seatSubject(s, "accept", a+"."+module)) @@ -205,7 +236,7 @@ func (s Seat) isNewTraffic() bool { // plainVerbs is a seat's accepts or emits with those the rules above compose taken out: a verb named by // its caller and every verb of a kinded bench are composed by SeatTrafficOf and nowhere else. func plainVerbs(s Seat, verbs []string) []string { - if s.Kinded { + if s.Kinded || s.PulledByKind { return nil } var out []string diff --git a/modules/mesh-grants/internal/buscompose/state.go b/modules/mesh-grants/internal/buscompose/state.go index 86e9029a..b489d2a8 100644 --- a/modules/mesh-grants/internal/buscompose/state.go +++ b/modules/mesh-grants/internal/buscompose/state.go @@ -2,6 +2,7 @@ package buscompose import ( "fmt" + sdkbc "git.novox.be/novox/mesh-sdk/go/buscompose" "slices" "strings" "time" @@ -36,8 +37,8 @@ type Bucket struct { History int // TTLSeconds is how long a value lives; zero is until replaced or deleted. TTLSeconds int - // PerMachine says each machine's instance writes only the key named for its machine (novox/hq ADR - // 0260), and is granted that key and no other. + // PerMachine says each machine's instance writes only the key named for its machine and the keys below it (novox/hq + // ADR 0260; design 46 approval mark §13), and is granted those and no other machine's. PerMachine bool // KeyTTL says the bucket allows a time to live per key (novox/hq ADR 0433 §1): its owner writes each key with its // own, and the server writes a delete marker, kept KeyTTLMarkerTTL, when it ends. @@ -166,8 +167,11 @@ func stateGrants(a stateAccess) []string { if machineKey == "" { continue // a machine with no usable name reaches none of it } - read(bucket, machineKey) - out = append(out, "$KV."+bucket+"."+machineKey) + // Its machine's key and the keys below it, no other machine's (design 46 approval mark §13). + for _, k := range sdkbc.PerMachineKeys(machineKey) { + read(bucket, k) + out = append(out, "$KV."+bucket+"."+k) + } continue } read(bucket, "") diff --git a/modules/mesh-grants/internal/buscompose/state_objects_test.go b/modules/mesh-grants/internal/buscompose/state_objects_test.go new file mode 100644 index 00000000..8a3975a4 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/state_objects_test.go @@ -0,0 +1,38 @@ +package buscompose + +import ( + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/membership" +) + +// Design 47 G §58, §59, §91, §124: a state's `-objects` store is its owner's alone, and a sent store is read, never +// written, by the modules it is for. +func TestStateObjectsAndSentStores(t *testing.T) { + owner, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop", Bound: []membership.BusBinding{ + {Local: "orders", Storage: catalogue.StorageKeyValue, Template: catalogue.TemplateState, Bucket: "shop_orders", Objects: "shop_orders-objects"}}}) + if err != nil { + t.Fatal(err) + } + other, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "bank"}) + if err != nil { + t.Fatal(err) + } + for _, subject := range []string{"$O.shop_orders-objects.C.x", "$O.shop_orders-objects.M.x", "$JS.API.DIRECT.GET.OBJ_shop_orders-objects.x"} { + if !MayPublish(owner, subject) { + t.Errorf("the owner may not publish %s", subject) + } + if MayPublish(other, subject) { + t.Errorf("another module may publish %s", subject) + } + } + reader, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "bank", ReadsObjects: []string{"shop_sent-objects"}}) + if err != nil { + t.Fatal(err) + } + if !MayPublish(reader, "$JS.API.DIRECT.GET.OBJ_shop_sent-objects.x") || MayPublish(reader, "$O.shop_sent-objects.C.x") || + MayPublish(reader, "$JS.API.DIRECT.GET.OBJ_other_sent-objects.x") { + t.Error("a reader of a sent store reads that one and writes none") + } +} diff --git a/modules/mesh-grants/internal/buscompose/testdata/user-lists.json b/modules/mesh-grants/internal/buscompose/testdata/user-lists.json index fd128363..2879ecd2 100644 --- a/modules/mesh-grants/internal/buscompose/testdata/user-lists.json +++ b/modules/mesh-grants/internal/buscompose/testdata/user-lists.json @@ -152,7 +152,7 @@ }, { "Name": "state, logs, streams, queues and processes", - "List": "# The mesh's users, composed by the grants part. Do not edit: the next\n# composition overwrites it. Permissions are derived from what each module\n# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\naccounts {\n MESH {\n jetstream: enabled\n imports = [\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.CONNECT\" }, to: \"mesh.bus.connect\" }\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.DISCONNECT\" }, to: \"mesh.bus.disconnect\" }\n { service: { account: \"$SYS\", subject: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\" }, to: \"mesh.bus.connz\" }\n ]\n users = [\n { user: \"ace.plex\", password: \"$2a$11$ace.plexace.plexace.plfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.ace_plex.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.ace.plex\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.ace_plex\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.ace_plex\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.ace.plex\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.plex\", \"$SRV.INFO.plex.\u003e\", \"$SRV.PING\", \"$SRV.PING.plex\", \"$SRV.PING.plex.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.plex\", \"$SRV.STATS.plex.\u003e\", \"_INBOX.ace.plex.\u003e\", \"mesh.mod.plex.call.\u003e\", \"mesh.mod.plex.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.ace\", password: \"$2a$11$node.acenode.acenode.afixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_bounds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_findings.*.$KV.mesh-host_findings.machine.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.ace\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.ace\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-ace.*.$O.declaration-files-ace.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-ace.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_bounds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_findings.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-ace.*\", \"$JS.API.CONSUMER.INFO.NODES.ace\", \"$JS.API.DIRECT.GET.KV_composed-ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_bounds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_findings.$KV.mesh-host_findings.machine.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.ace\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.ace\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.ace.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-ace.$O.declaration-files-ace.M.*\", \"$JS.API.STREAM.INFO.KV_composed-ace\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_assignments\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_verdicts\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_bounds\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_findings\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-ace.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_bounds.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_findings.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-ace.\u003e\", \"$KV.composed-ace.\u003e\", \"$KV.mesh-host_acts.ace.\u003e\", \"$KV.mesh-host_bounds.\u003e\", \"$KV.mesh-host_data.ace.\u003e\", \"$KV.mesh-host_findings.machine.ace.\u003e\", \"$KV.mesh-host_health.ace\", \"$KV.mesh-host_packages.ace\", \"$KV.node-engine_have.ace.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.*.ace\", \"$SRV.PING.\u003e\", \"mesh.control.ace.\u003e\", \"mesh.mod.plex.call.plex_status.ace.node~ace\", \"mesh.mod.plex.tool.plex_status.ace\", \"mesh.seat.node-backup.call.backed-up.ace.node~ace\", \"mesh.seat.node-backup.call.now.ace.node~ace\", \"mesh.seat.node-backup.tool.backed-up.ace\", \"mesh.seat.node-backup.tool.now.ace\", \"mesh.seat.node-engine.event.*.ace\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_assignments\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.ace\", \"_DELIVER.ace.\u003e\", \"_INBOX.node.ace.\u003e\", \"mesh.node.ace.ask.access-act\", \"mesh.node.ace.ask.hand-over\", \"mesh.node.ace.ask.judge-directories\", \"mesh.node.ace.ask.move-secret\", \"mesh.node.ace.ask.report\", \"mesh.node.ace.ask.setuid-search\", \"mesh.node.ace.declare\", \"mesh.seat.node-engine.call.*.ace.*\", \"mesh.seat.node-engine.tool.*.ace\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.novox\", password: \"$2a$11$node.novoxnode.novoxnofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_bounds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_findings.*.$KV.mesh-host_findings.machine.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-novox.*.$O.declaration-files-novox.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-novox.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_bounds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_findings.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-novox.*\", \"$JS.API.CONSUMER.INFO.NODES.novox\", \"$JS.API.DIRECT.GET.KV_composed-novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_bounds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_findings.$KV.mesh-host_findings.machine.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.novox\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.novox\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.novox.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-novox.$O.declaration-files-novox.M.*\", \"$JS.API.STREAM.INFO.KV_composed-novox\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_assignments\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_verdicts\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_bounds\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_findings\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-novox.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_bounds.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_findings.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-novox.\u003e\", \"$KV.composed-novox.\u003e\", \"$KV.mesh-host_acts.novox.\u003e\", \"$KV.mesh-host_bounds.\u003e\", \"$KV.mesh-host_data.novox.\u003e\", \"$KV.mesh-host_findings.machine.novox.\u003e\", \"$KV.mesh-host_health.novox\", \"$KV.mesh-host_packages.novox\", \"$KV.node-engine_have.novox.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.*.novox\", \"$SRV.PING.\u003e\", \"mesh.control.novox.\u003e\", \"mesh.seat.node-backup.call.backed-up.novox.node~novox\", \"mesh.seat.node-backup.call.now.novox.node~novox\", \"mesh.seat.node-backup.tool.backed-up.novox\", \"mesh.seat.node-backup.tool.now.novox\", \"mesh.seat.node-engine.event.*.novox\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_assignments\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.novox\", \"_DELIVER.novox.\u003e\", \"_INBOX.node.novox.\u003e\", \"mesh.node.novox.ask.access-act\", \"mesh.node.novox.ask.hand-over\", \"mesh.node.novox.ask.judge-directories\", \"mesh.node.novox.ask.move-secret\", \"mesh.node.novox.ask.report\", \"mesh.node.novox.ask.setuid-search\", \"mesh.node.novox.declare\", \"mesh.seat.node-engine.call.*.novox.*\", \"mesh.seat.node-engine.tool.*.novox\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-delivery-builder\", password: \"$2a$11$novox.mesh-delivery-bufixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-delivery-builder.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_presence.*.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-delivery-builder\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-delivery-builder\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-delivery-builder\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_presence.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-delivery-builder\", \"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_builds\", \"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_builds.\u003e\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_NODE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_NODE\", \"$JS.FC.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"$KV.mesh-delivery-builder_presence.novox\", \"$KV.mesh-delivery-builder_steps.\u003e\", \"mesh.log.mesh-delivery-builder.builds.\u003e\", \"mesh.mod.gitea.call.gitea_commit.*.novox~mesh-delivery-builder\", \"mesh.mod.gitea.call.gitea_commit.novox~mesh-delivery-builder\", \"mesh.mod.gitea.tool.gitea_commit\", \"mesh.mod.gitea.tool.gitea_commit.*\", \"mesh.mod.mesh-delivery-builder.event.built\", \"mesh.mod.mesh-delivery-builder.event.states\", \"mesh.stream.mesh-delivery-builder.event.ask\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_presence\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"$SRV.INFO\", \"$SRV.INFO.mesh-delivery-builder\", \"$SRV.INFO.mesh-delivery-builder.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-delivery-builder\", \"$SRV.PING.mesh-delivery-builder.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-delivery-builder\", \"$SRV.STATS.mesh-delivery-builder.\u003e\", \"_INBOX.novox.mesh-delivery-builder.\u003e\", \"mesh.mod.mesh-delivery-builder.call.\u003e\", \"mesh.mod.mesh-delivery-builder.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-delivery-builder.worker\", password: \"$2a$11$novox.mesh-delivery-bufixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.asks.novox_mesh-delivery-builder_worker.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.INFO.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.MSG.NEXT.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$KV.mesh-delivery-builder_steps.\u003e\", \"mesh.mod.gitea.call.gitea_commit.*.novox~mesh-delivery-builder~worker\", \"mesh.mod.gitea.call.gitea_commit.novox~mesh-delivery-builder~worker\", \"mesh.mod.gitea.tool.gitea_commit\", \"mesh.mod.gitea.tool.gitea_commit.*\", \"mesh.mod.mesh-delivery-builder.event.built\", \"mesh.mod.mesh-delivery-builder.event.states\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"_INBOX.novox.mesh-delivery-builder.worker.\u003e\"] }\n } }\n { user: \"novox.mesh-delivery-view\", password: \"$2a$11$novox.mesh-delivery-vifixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-delivery-view.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_presence.*.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-delivery-view\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-delivery-view\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_RECHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_STEP_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_RECHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_STEP_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_RECHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_STEP_worker\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-delivery-view\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_presence.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-delivery-view\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_NODE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_NODE\", \"$JS.FC.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_presence\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"$SRV.INFO\", \"$SRV.INFO.mesh-delivery-view\", \"$SRV.INFO.mesh-delivery-view.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-delivery-view\", \"$SRV.PING.mesh-delivery-view.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-delivery-view\", \"$SRV.STATS.mesh-delivery-view.\u003e\", \"_INBOX.novox.mesh-delivery-view.\u003e\", \"mesh.mod.mesh-delivery-view.call.\u003e\", \"mesh.mod.mesh-delivery-view.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-grants\", password: \"$2a$11$novox.mesh-grantsnovoxfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-grants.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_assertions.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-grants\", \"$JS.API.CONSUMER.CREATE.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_assertions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-grants\", \"$JS.API.CONSUMER.INFO.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-grants\", \"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.*.*\", \"$JS.API.DIRECT.GET.KV_mesh-grants_assertions.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-grants\", \"$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.\u003e\", \"$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.CREATE.KV_mesh-grants_assertions\", \"$JS.API.STREAM.CREATE.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.CREATE.asks\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_assertions\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.INFO.asks\", \"$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.UPDATE.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.UPDATE.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.UPDATE.KV_mesh-grants_assertions\", \"$JS.API.STREAM.UPDATE.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.UPDATE.asks\", \"$JS.FC.KV_mesh-grants_assertions.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"$KV.mesh-grants_assertions.\u003e\", \"$O.mesh-grants-user-list.\u003e\", \"mesh.assignment.*.*\", \"mesh.mod.mesh-grants.event.membership-changed\", \"mesh.mod.mesh-grants.event.states\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-grants_assertions\", \"$SRV.INFO\", \"$SRV.INFO.mesh-grants\", \"$SRV.INFO.mesh-grants.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-grants\", \"$SRV.PING.mesh-grants.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-grants\", \"$SRV.STATS.mesh-grants.\u003e\", \"_INBOX.novox.mesh-grants.\u003e\", \"mesh.mod.mesh-grants.call.\u003e\", \"mesh.mod.mesh-grants.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n \"$SYS\" {\n exports = [\n { stream: \"$SYS.ACCOUNT.MESH.CONNECT\", accounts: [\"MESH\"] }\n { stream: \"$SYS.ACCOUNT.MESH.DISCONNECT\", accounts: [\"MESH\"] }\n { service: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\", accounts: [\"MESH\"] }\n ]\n }\n}\n", + "List": "# The mesh's users, composed by the grants part. Do not edit: the next\n# composition overwrites it. Permissions are derived from what each module\n# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\naccounts {\n MESH {\n jetstream: enabled\n imports = [\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.CONNECT\" }, to: \"mesh.bus.connect\" }\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.DISCONNECT\" }, to: \"mesh.bus.disconnect\" }\n { service: { account: \"$SYS\", subject: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\" }, to: \"mesh.bus.connz\" }\n ]\n users = [\n { user: \"ace.plex\", password: \"$2a$11$ace.plexace.plexace.plfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.ace_plex.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.ace.plex\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.ace_plex\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.ace_plex\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.ace.plex\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.plex\", \"$SRV.INFO.plex.\u003e\", \"$SRV.PING\", \"$SRV.PING.plex\", \"$SRV.PING.plex.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.plex\", \"$SRV.STATS.plex.\u003e\", \"_INBOX.ace.plex.\u003e\", \"mesh.mod.plex.call.\u003e\", \"mesh.mod.plex.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.ace\", password: \"$2a$11$node.acenode.acenode.afixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_bounds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_findings.*.$KV.mesh-host_findings.machine.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.ace\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.ace\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-ace.*.$O.declaration-files-ace.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-ace.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_bounds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_findings.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-ace.*\", \"$JS.API.CONSUMER.INFO.NODES.ace\", \"$JS.API.DIRECT.GET.KV_composed-ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_bounds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_findings.$KV.mesh-host_findings.machine.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.ace\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.ace\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.ace.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-ace.$O.declaration-files-ace.M.*\", \"$JS.API.STREAM.INFO.KV_composed-ace\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_assignments\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_verdicts\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_bounds\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_findings\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-ace.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_bounds.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_findings.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-ace.\u003e\", \"$KV.composed-ace.\u003e\", \"$KV.mesh-host_acts.ace.\u003e\", \"$KV.mesh-host_bounds.\u003e\", \"$KV.mesh-host_data.ace.\u003e\", \"$KV.mesh-host_findings.machine.ace.\u003e\", \"$KV.mesh-host_health.ace\", \"$KV.mesh-host_packages.ace\", \"$KV.node-engine_have.ace.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.*.ace\", \"$SRV.PING.\u003e\", \"mesh.control.ace.\u003e\", \"mesh.mod.plex.call.plex_status.ace.node~ace\", \"mesh.mod.plex.tool.plex_status.ace\", \"mesh.seat.node-backup.call.backed-up.ace.node~ace\", \"mesh.seat.node-backup.call.now.ace.node~ace\", \"mesh.seat.node-backup.tool.backed-up.ace\", \"mesh.seat.node-backup.tool.now.ace\", \"mesh.seat.node-engine.event.*.ace\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_assignments\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.ace\", \"_DELIVER.ace.\u003e\", \"_INBOX.node.ace.\u003e\", \"mesh.node.ace.ask.access-act\", \"mesh.node.ace.ask.hand-over\", \"mesh.node.ace.ask.judge-directories\", \"mesh.node.ace.ask.move-secret\", \"mesh.node.ace.ask.report\", \"mesh.node.ace.ask.setuid-search\", \"mesh.node.ace.declare\", \"mesh.seat.node-engine.call.*.ace.*\", \"mesh.seat.node-engine.tool.*.ace\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.novox\", password: \"$2a$11$node.novoxnode.novoxnofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_bounds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_findings.*.$KV.mesh-host_findings.machine.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-novox.*.$O.declaration-files-novox.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-novox.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_bounds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_findings.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-novox.*\", \"$JS.API.CONSUMER.INFO.NODES.novox\", \"$JS.API.DIRECT.GET.KV_composed-novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_bounds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_findings.$KV.mesh-host_findings.machine.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.novox\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.novox\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.novox.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-novox.$O.declaration-files-novox.M.*\", \"$JS.API.STREAM.INFO.KV_composed-novox\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_assignments\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_verdicts\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_bounds\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_findings\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-novox.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_bounds.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_findings.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-novox.\u003e\", \"$KV.composed-novox.\u003e\", \"$KV.mesh-host_acts.novox.\u003e\", \"$KV.mesh-host_bounds.\u003e\", \"$KV.mesh-host_data.novox.\u003e\", \"$KV.mesh-host_findings.machine.novox.\u003e\", \"$KV.mesh-host_health.novox\", \"$KV.mesh-host_packages.novox\", \"$KV.node-engine_have.novox.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.*.novox\", \"$SRV.PING.\u003e\", \"mesh.control.novox.\u003e\", \"mesh.seat.node-backup.call.backed-up.novox.node~novox\", \"mesh.seat.node-backup.call.now.novox.node~novox\", \"mesh.seat.node-backup.tool.backed-up.novox\", \"mesh.seat.node-backup.tool.now.novox\", \"mesh.seat.node-engine.event.*.novox\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_assignments\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.novox\", \"_DELIVER.novox.\u003e\", \"_INBOX.node.novox.\u003e\", \"mesh.node.novox.ask.access-act\", \"mesh.node.novox.ask.hand-over\", \"mesh.node.novox.ask.judge-directories\", \"mesh.node.novox.ask.move-secret\", \"mesh.node.novox.ask.report\", \"mesh.node.novox.ask.setuid-search\", \"mesh.node.novox.declare\", \"mesh.seat.node-engine.call.*.novox.*\", \"mesh.seat.node-engine.tool.*.novox\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-delivery-builder\", password: \"$2a$11$novox.mesh-delivery-bufixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-delivery-builder.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_presence.*.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_presence.*.$KV.mesh-delivery-builder_presence.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-delivery-builder\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-delivery-builder\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-delivery-builder\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_presence.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_presence.$KV.mesh-delivery-builder_presence.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-delivery-builder\", \"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_builds\", \"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_builds.\u003e\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_NODE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_NODE\", \"$JS.FC.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"$KV.mesh-delivery-builder_presence.novox\", \"$KV.mesh-delivery-builder_presence.novox.\u003e\", \"$KV.mesh-delivery-builder_steps.\u003e\", \"mesh.log.mesh-delivery-builder.builds.\u003e\", \"mesh.mod.gitea.call.gitea_commit.*.novox~mesh-delivery-builder\", \"mesh.mod.gitea.call.gitea_commit.novox~mesh-delivery-builder\", \"mesh.mod.gitea.tool.gitea_commit\", \"mesh.mod.gitea.tool.gitea_commit.*\", \"mesh.mod.mesh-delivery-builder.event.built\", \"mesh.mod.mesh-delivery-builder.event.states\", \"mesh.stream.mesh-delivery-builder.event.ask\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_presence\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"$SRV.INFO\", \"$SRV.INFO.mesh-delivery-builder\", \"$SRV.INFO.mesh-delivery-builder.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-delivery-builder\", \"$SRV.PING.mesh-delivery-builder.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-delivery-builder\", \"$SRV.STATS.mesh-delivery-builder.\u003e\", \"_INBOX.novox.mesh-delivery-builder.\u003e\", \"mesh.mod.mesh-delivery-builder.call.\u003e\", \"mesh.mod.mesh-delivery-builder.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-delivery-builder.worker\", password: \"$2a$11$novox.mesh-delivery-bufixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.asks.novox_mesh-delivery-builder_worker.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.INFO.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.MSG.NEXT.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$KV.mesh-delivery-builder_steps.\u003e\", \"mesh.mod.gitea.call.gitea_commit.*.novox~mesh-delivery-builder~worker\", \"mesh.mod.gitea.call.gitea_commit.novox~mesh-delivery-builder~worker\", \"mesh.mod.gitea.tool.gitea_commit\", \"mesh.mod.gitea.tool.gitea_commit.*\", \"mesh.mod.mesh-delivery-builder.event.built\", \"mesh.mod.mesh-delivery-builder.event.states\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"_INBOX.novox.mesh-delivery-builder.worker.\u003e\"] }\n } }\n { user: \"novox.mesh-delivery-view\", password: \"$2a$11$novox.mesh-delivery-vifixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-delivery-view.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_presence.*.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-delivery-view\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-delivery-view\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_RECHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_STEP_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_RECHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_STEP_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_RECHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_STEP_worker\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-delivery-view\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_presence.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-delivery-view\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_NODE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_NODE\", \"$JS.FC.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_presence\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"$SRV.INFO\", \"$SRV.INFO.mesh-delivery-view\", \"$SRV.INFO.mesh-delivery-view.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-delivery-view\", \"$SRV.PING.mesh-delivery-view.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-delivery-view\", \"$SRV.STATS.mesh-delivery-view.\u003e\", \"_INBOX.novox.mesh-delivery-view.\u003e\", \"mesh.mod.mesh-delivery-view.call.\u003e\", \"mesh.mod.mesh-delivery-view.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-grants\", password: \"$2a$11$novox.mesh-grantsnovoxfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-grants.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_assertions.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-grants\", \"$JS.API.CONSUMER.CREATE.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_assertions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-grants\", \"$JS.API.CONSUMER.INFO.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-grants\", \"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.*.*\", \"$JS.API.DIRECT.GET.KV_mesh-grants_assertions.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-grants\", \"$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.\u003e\", \"$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.CREATE.KV_mesh-grants_assertions\", \"$JS.API.STREAM.CREATE.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.CREATE.asks\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_assertions\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.INFO.asks\", \"$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.UPDATE.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.UPDATE.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.UPDATE.KV_mesh-grants_assertions\", \"$JS.API.STREAM.UPDATE.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.UPDATE.asks\", \"$JS.FC.KV_mesh-grants_assertions.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"$KV.mesh-grants_assertions.\u003e\", \"$O.mesh-grants-user-list.\u003e\", \"mesh.assignment.*.*\", \"mesh.mod.mesh-grants.event.membership-changed\", \"mesh.mod.mesh-grants.event.states\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-grants_assertions\", \"$SRV.INFO\", \"$SRV.INFO.mesh-grants\", \"$SRV.INFO.mesh-grants.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-grants\", \"$SRV.PING.mesh-grants.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-grants\", \"$SRV.STATS.mesh-grants.\u003e\", \"_INBOX.novox.mesh-grants.\u003e\", \"mesh.mod.mesh-grants.call.\u003e\", \"mesh.mod.mesh-grants.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n \"$SYS\" {\n exports = [\n { stream: \"$SYS.ACCOUNT.MESH.CONNECT\", accounts: [\"MESH\"] }\n { stream: \"$SYS.ACCOUNT.MESH.DISCONNECT\", accounts: [\"MESH\"] }\n { service: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\", accounts: [\"MESH\"] }\n ]\n }\n}\n", "Missing": null, "Err": "", "Memberships": { diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index 5bbff318..c861f9d2 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -56,9 +56,6 @@ type Declared struct { // CatchUps are the module's own tools that bring a kept data forward after a restore of it, as its manifest's kept data // name them (design 47 G §406, §631, A-108): the node-engine on a node that places the module is granted to call each. CatchUps []string - // Waits are the event families a session may wait on through the tool runner, the tool runner's module's - // alone (novox/hq ADR 0367 §2): its account subscribes them for a call, through no consumer. - Waits []Wait // State is the state it keeps, each a bucket its instances write (novox/hq ADR 0201). State []Bucket // Logs are the logs it keeps, each a stream its instances append to (novox/hq ADR 0297). @@ -592,14 +589,14 @@ func Composed(d Declared) bool { return d.Module != EngineModule } func CarriedByRunner(d Declared) bool { return Composed(d) && d.RunsAs == "" && runnerServes(d) } // runnerServes says the machine's tool runner has something of this module to serve (novox/hq ADR 0436 §1, -// ADR 0437 §4, issue 1329): tools or loads it answers, a seat it holds, event families a session waits on, +// ADR 0437 §4, issue 1329): tools or loads it answers, a seat it holds, // a bundle the runtime loads (Loads), or a check its health asks. A module with none of these is a process of its own on its own credential, and // carrying it would hand every session on the machine the runner's account with that module's state, seat // traffic and streams. A module with no account of its own (NoAccount) is spoken for by the runner, so it // stays carried, and so does the runtime module itself. Whether the module has its own broker secret is not // the test: a module with tools and its own credential is still carried (ADR 0175). func runnerServes(d Declared) bool { - return d.ToolRunner || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0 + return d.ToolRunner || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Checks) > 0 } // declaredStreamsOf is the stream of every bucket, log and stream a module placed anywhere declares, once each, in name diff --git a/modules/mesh-grants/internal/buscompose/waits.go b/modules/mesh-grants/internal/buscompose/waits.go deleted file mode 100644 index 1fb8616b..00000000 --- a/modules/mesh-grants/internal/buscompose/waits.go +++ /dev/null @@ -1,94 +0,0 @@ -package buscompose - -import ( - "fmt" - "strings" -) - -// What a session may wait on through the mesh MCP server (novox/hq ADR 0367). -// -// **A grant apart from `consumes`, and the tool runner's alone.** The tool runner's module names in its -// manifest the event families a session may wait on, one by one; the runtime's account is granted a plain -// subscription on those subjects and nothing wider — no durable consumer, no queue of its own — and its -// membership names the same list, from which `mesh_wait` refuses any other. Both are read from this one -// composition, so the grant and what the tool runner offers cannot disagree, and nothing but the manifest -// adds a family (§6). - -// Wait is one event family the tool runner's module declares: its name as the manifest gives it, and the -// emitter — a seat or a module — and the event, as the inventory resolved them. -type Wait struct { - // Name is the family as the manifest names it, `.`: what a session asks for. - Name string - Emitter string - // Seat says the emitter is a seat, whose events live under the seat's namespace (novox/hq ADR 0121). - Seat bool - // Event is the event, with any further token the subject carries: `decided.claude-code`, the asker of a - // seat that names it last (ADR 0259 §3). - Event string - // Last names what the seat's event subject ends in — "kind" for a kinded bench, "asker" for a seat naming - // its caller (ADR 0259 §3) — and is empty for an event that ends at its name. A family needing it is waited - // on with it given, and refused without. - Last string -} - -// WaitSubject is where a family's events land. Each token is checked before it becomes a subject: a -// wildcard, a dot inside a name or an empty token would widen the grant. -func WaitSubject(w Wait) (string, error) { - if !safeSubject.MatchString(w.Emitter) { - return "", fmt.Errorf("%q cannot name an emitter of a waited event: it would widen the subject", w.Emitter) - } - for _, t := range strings.Split(w.Event, ".") { - if !safeSubject.MatchString(t) { - return "", fmt.Errorf("%q cannot name a waited event: each of its names is plain, never a wildcard "+ - "(novox/hq ADR 0367 §2)", w.Event) - } - } - if w.Last != "" && !strings.Contains(w.Event, ".") { - return "", fmt.Errorf("%s names no %s: %s's events are said for each %s, so a family is %s.%s.<%s>; "+ - "without it no event would ever be heard (novox/hq ADR 0367 §5)", - w.Name, w.Last, w.Emitter, w.Last, w.Emitter, w.Event, w.Last) - } - if w.Seat { - return "mesh.seat." + w.Emitter + ".event." + w.Event, nil - } - return "mesh.mod." + w.Emitter + ".event." + w.Event, nil -} - -// waitGrants is the subscription the tool runner's account is granted for its module's families, and an -// error for a carried module other than the tool runner's that declares any (ADR 0367 §2). -func waitGrants(carries []Declared) ([]string, error) { - var sub []string - for _, d := range carries { - if len(d.Waits) == 0 { - continue - } - if !d.ToolRunner { - return nil, fmt.Errorf("%s declares what a session waits on; only the tool runner's module (its manifest says "+ - "tool-runner) does (novox/hq ADR 0367 §2)", d.Module) - } - for _, w := range d.Waits { - s, err := WaitSubject(w) - if err != nil { - return nil, err - } - sub = append(sub, s) - } - } - return sub, nil -} - -// waitsIssuedFor is the families a membership names: the tool runner's module's, and nobody else's. -func waitsIssuedFor(d Declared) []WaitIssued { - if !d.ToolRunner { - return nil - } - var out []WaitIssued - for _, w := range d.Waits { - s, err := WaitSubject(w) - if err != nil { - continue // refused where the account is composed, which names it - } - out = append(out, WaitIssued{Event: w.Name, Subject: s}) - } - return out -} diff --git a/modules/mesh-grants/internal/grants/callers.go b/modules/mesh-grants/internal/grants/callers.go new file mode 100644 index 00000000..677f053b --- /dev/null +++ b/modules/mesh-grants/internal/grants/callers.go @@ -0,0 +1,139 @@ +package grants + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/seatdef" + + "mesh-grants/internal/buscompose" +) + +// The tools that say who may call and why a call is refused (design 47 G §83, GR2). +const ( + ToolCallers = "grants_callers" + ToolWhyRefused = "grants_why_refused" +) + +// CallerLine is one verb's, or one accepted kind's, callers as a seat definition gives them. +type CallerLine struct { + Seat string `json:"seat"` + // Verb is the verb's name, or the accepted kind's. + Verb string `json:"verb"` + // Kind is `verb` or `accepts`. + Kind string `json:"kind"` + Class string `json:"class,omitempty"` + // Callers are the entries the verb admits: the list the definition names, or its class's default. + Callers []string `json:"callers"` + // Named says the definition names the list; false is the class's default (§391). + Named bool `json:"named"` +} + +// definitions are the seat definitions the part composes from, or why it has none. +func (p *Part) definitions() (map[string]seatdef.Definition, error) { + d, ok := p.SeatList.(SeatDefinitions) + if !ok { + return nil, errors.New("the seat list is not read here: the definitions' callers are unknown") + } + defs := d.Definitions() + if len(defs) == 0 { + return nil, errors.New("the seat list holds no definitions yet") + } + return defs, nil +} + +// CallerLines are the callers each verb and each accepted kind admits, from the definitions, of the seat named (every seat +// when empty) and the verb named (every verb when empty). +func CallerLines(defs map[string]seatdef.Definition, seat, verb string) []CallerLine { + var out []CallerLine + for _, d := range defs { + if seat != "" && d.Seat != seat { + continue + } + for _, v := range d.Verbs { + if verb == "" || v.Name == verb { + out = append(out, CallerLine{Seat: d.Seat, Verb: v.Name, Kind: "verb", Class: v.Class, + Callers: v.EffectiveCallers(), Named: v.Callers != nil && !v.Callers.IsText()}) + } + } + for _, a := range d.Accepts { + if verb == "" || a.Name == verb { + l := CallerLine{Seat: d.Seat, Verb: a.Name, Kind: "accepts"} + if a.Callers != nil && !a.Callers.IsText() { + l.Callers, l.Named = a.Callers.List, true + } + out = append(out, l) + } + } + } + sort.Slice(out, func(i, j int) bool { + a, b := out[i], out[j] + return a.Seat+"\x00"+a.Kind+"\x00"+a.Verb < b.Seat+"\x00"+b.Kind+"\x00"+b.Verb + }) + return out +} + +// Callers answers the tool grants_callers. +func (p *Part) Callers(seat, verb string) (any, error) { + defs, err := p.definitions() + if err != nil { + return nil, err + } + lines := CallerLines(defs, seat, verb) + if len(lines) == 0 { + return nil, fmt.Errorf("no seat definition names a verb or an accepted kind matching seat %q verb %q", seat, verb) + } + return map[string]any{"callers": lines, "count": len(lines)}, nil +} + +// WhyRefused answers the tool grants_why_refused: the grant an account lacks to call address (`.`), and the +// definition's line that leaves it out. The bus answers whether the account may publish the verb's call subject +// (nats.nats_user_can, the account's grants as the server holds them); the definition's callers say who was meant to. +func (p *Part) WhyRefused(account, address, node string) (any, error) { + seat, verb, ok := strings.Cut(address, ".") + if account == "" || !ok || seat == "" || verb == "" { + return nil, errors.New("why_refused names an account and a verb as .") + } + defs, err := p.definitions() + if err != nil { + return nil, err + } + lines := CallerLines(defs, seat, verb) + if len(lines) == 0 { + return nil, fmt.Errorf("no seat definition names the verb %s", address) + } + def := defs[seat] + s := buscompose.Seat{Name: seat, Scope: def.Scope} + subject := buscompose.CalledSubject(buscompose.SeatToolSubject(s, verb, node), account) + if subject == "" { + return nil, fmt.Errorf("%s is not the name of a bus user", account) + } + out := map[string]any{"account": account, "verb": address, "subject": subject, "definition": lines} + raw, err := p.Call.Call(CallUserCan, map[string]any{"user": account, "action": "publish", "subject": subject}) + if err != nil { + out["bus"] = "not read: " + err.Error() + return out, nil + } + var can struct { + Allowed *bool `json:"allowed"` + } + _ = json.Unmarshal(raw, &can) + out["bus"] = json.RawMessage(raw) + switch { + case can.Allowed == nil: + out["says"] = "the bus's answer names no verdict; read it beside the definition's line" + case *can.Allowed: + out["says"] = "not refused at the bus: the account holds the grant to publish " + subject + default: + callers := lines[0].Callers + out["says"] = fmt.Sprintf("the account lacks the grant to publish %s; the definition admits %s (%s), and the grants part grants a call only to a caller one of those entries names", + subject, strings.Join(callers, ", "), map[bool]string{true: "named by the definition", false: "the class's default"}[lines[0].Named]) + out["lacks"] = subject + out["admitted_by"] = slices.Clone(callers) + } + return out, nil +} diff --git a/modules/mesh-grants/internal/grants/callers_test.go b/modules/mesh-grants/internal/grants/callers_test.go new file mode 100644 index 00000000..2c73338b --- /dev/null +++ b/modules/mesh-grants/internal/grants/callers_test.go @@ -0,0 +1,57 @@ +package grants + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +type defsGate struct{ defs map[string]seatdef.Definition } + +func (defsGate) MayAct(...string) bool { return true } +func (g defsGate) Definitions() map[string]seatdef.Definition { return g.defs } + +type canCall struct { + allowed bool + asked map[string]any +} + +func (c *canCall) Call(key string, body any) (json.RawMessage, error) { + c.asked = body.(map[string]any) + return json.Marshal(map[string]any{"allowed": c.allowed}) +} + +// grants_callers and grants_why_refused read the definitions' callers (design 47 G §83): a verb that names none takes its +// class's default, and a refused account is told the call subject it lacks and the callers the definition admits. +func TestCallersAndWhyRefused(t *testing.T) { + defs := map[string]seatdef.Definition{"mesh-wants": {Seat: "mesh-wants", Scope: "mesh", Verbs: []seatdef.Verb{ + {Name: "upgrade", Class: seatdef.ClassAct}, + {Name: "entry", Callers: &seatdef.Callers{List: []string{"seat:mesh-pipeline-runner", "operator"}}}, + }}} + lines := CallerLines(defs, "mesh-wants", "") + if len(lines) != 2 || lines[0].Verb != "entry" || !lines[0].Named || lines[1].Named || + strings.Join(lines[1].Callers, ",") != "operator,sessions" { + t.Fatalf("lines: %+v", lines) + } + can := &canCall{} + p := &Part{SeatList: defsGate{defs}, Call: can} + got, err := p.WhyRefused("one.shop", "mesh-wants.upgrade", "") + if err != nil { + t.Fatal(err) + } + out := got.(map[string]any) + if out["lacks"] != "mesh.seat.mesh-wants.call.upgrade.one~shop" || can.asked["subject"] != out["lacks"] || + !strings.Contains(out["says"].(string), "operator, sessions") { + t.Errorf("a refused caller: %+v", out) + } + can.allowed = true + got, _ = p.WhyRefused("one.shop", "mesh-wants.upgrade", "") + if _, lacks := got.(map[string]any)["lacks"]; lacks { + t.Errorf("an allowed caller lacks a grant: %+v", got) + } + if _, err := p.WhyRefused("one.shop", "mesh-wants.nothing", ""); err == nil { + t.Error("a verb no definition names was explained") + } +} diff --git a/modules/mesh-grants/internal/grants/grantsfor.go b/modules/mesh-grants/internal/grants/grantsfor.go new file mode 100644 index 00000000..81985a6c --- /dev/null +++ b/modules/mesh-grants/internal/grants/grantsfor.go @@ -0,0 +1,188 @@ +package grants + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "sort" + "strconv" + "strings" + + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshgrants" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// **The seat mesh-grants' read `grants-for`** (design 47 G §441, §401, A-205): the grants a candidate would compose, per +// module, added and removed against the branch's newest. The candidate's catalog half is taken by mesh-catalog's `revision` (a +// run by its group `run-`, a source revision by the branch and `source`), the branch's newest by `revision` with the branch +// alone; the answer is what the composed memberships' permissions gain and lose with the candidate's entries swapped in for the +// branch's newest. + +// RunGroup is the group of a run's candidate in mesh-catalog's `revision` (A-205). +const RunGroup = "run-" + +// CallRevision is the seat mesh-catalog's `revision`, as the part calls it. +const CallRevision = "seat:" + meshcatalog.Seat + "." + meshcatalog.VerbRevision + +// revisionEntries reads a catalog graph revision's entries, page after page, as manifests by module. none says the branch has +// no revision yet. +func (p *Part) revisionEntries(in meshcatalog.RevisionInput) (manifests map[string]catalogue.Manifest, none bool, err error) { + manifests = map[string]catalogue.Manifest{} + for { + raw, err := p.Call.Call(CallRevision, in) + if err != nil { + return nil, false, fmt.Errorf("%s does not answer: %w", CallRevision, err) + } + var a meshcatalog.RevisionAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return nil, false, fmt.Errorf("%s answered what is not a revision: %w", CallRevision, err) + } + if a.NoneYet { + return nil, true, nil + } + for _, e := range a.Entries { + body, err := json.Marshal(e.Manifest) + if err != nil || len(e.Manifest) == 0 { + continue + } + m, err := catalogue.Decode(body) + if err != nil { + return nil, false, fmt.Errorf("the manifest of %s in the revision does not decode: %w", e.Module, err) + } + manifests[e.Module] = m + } + if a.Next == "" || a.Next == in.After { + return manifests, false, nil + } + in.After = a.Next + } +} + +// grantsOfEntries is, by module, the subjects the composed accounts of that module may publish or subscribe, composed from in with +// the entries' manifests in place of the trunk's and of what runs. +func grantsOfEntries(in Inputs, entries map[string]catalogue.Manifest) (map[string]map[string]bool, error) { + in.Manifests = maps.Clone(in.Manifests) + in.Running = maps.Clone(in.Running) + for module, m := range entries { + in.Manifests[module] = m + for key := range in.Running { + if strings.HasSuffix(key, "."+module) { + delete(in.Running, key) + } + } + } + recs, _ := ListRecords(in) + users, refused, err := ComposeEach(recs) + if err != nil { + return nil, err + } + if len(refused) > 0 { + user := sortedKeys(refused)[0] + return nil, fmt.Errorf("the grants of %s do not compose: %s", user, refused[user]) + } + out := map[string]map[string]bool{} + for _, u := range users { + _, module, ok := strings.Cut(u.User, ".") + if _, named := entries[module]; !ok || !named { + continue + } + if out[module] == nil { + out[module] = map[string]bool{} + } + for _, s := range append(slices.Clone(u.Publish), u.Subscribe...) { + out[module][s] = true + } + } + return out, nil +} + +// verbOf names the verb, event or tool a subject is for: `mesh.seat...…` and `mesh.mod...…`. +func verbOf(subject string) string { + parts := strings.Split(subject, ".") + if len(parts) >= 5 && parts[0] == "mesh" && (parts[1] == "seat" || parts[1] == "mod") { + return parts[4] + } + return "" +} + +func grantList(subjects []string) []meshgrants.Grant { + out := []meshgrants.Grant{} + for _, s := range subjects { + out = append(out, meshgrants.Grant{Subject: s, Verb: verbOf(s)}) + } + return out +} + +// GrantsFor answers the verb grants-for. +func (p *Part) GrantsFor(in meshgrants.GrantsForInput) (meshgrants.GrantsFor, error) { + if in.Branch == "" { + return meshgrants.GrantsFor{}, errors.New("grants-for names the candidate's watched branch") + } + candidate := meshcatalog.RevisionInput{Branch: in.Branch} + switch { + case in.Run != "": + candidate.Group = RunGroup + in.Run + case in.Source != "": + n, err := strconv.ParseInt(in.Source, 10, 64) + if err != nil { + return meshgrants.GrantsFor{}, fmt.Errorf("the source revision %q is not a number", in.Source) + } + candidate.Source = n + default: + return meshgrants.GrantsFor{}, errors.New("grants-for names a source revision or a run") + } + cand, none, err := p.revisionEntries(candidate) + if err != nil || none { + return meshgrants.GrantsFor{Error: orWhy(err, "the candidate's catalog half is not held: the branch has no revision of it")}, nil + } + newest, none, err := p.revisionEntries(meshcatalog.RevisionInput{Branch: in.Branch}) + if err != nil || none { + return meshgrants.GrantsFor{Error: orWhy(err, "the branch's newest catalog half is not held")}, nil + } + base := map[string]catalogue.Manifest{} + for module := range cand { + if m, ok := newest[module]; ok { + base[module] = m + } + } + input := p.inputs() + was, err := grantsOfEntries(input, base) + if err != nil { + return meshgrants.GrantsFor{Error: "the branch's newest does not compose: " + err.Error()}, nil + } + will, err := grantsOfEntries(input, cand) + if err != nil { + return meshgrants.GrantsFor{Error: "the candidate does not compose: " + err.Error()}, nil + } + var out meshgrants.GrantsFor + for _, module := range sortedKeys(will) { + var added, removed []string + for s := range will[module] { + if !was[module][s] { + added = append(added, s) + } + } + for s := range was[module] { + if !will[module][s] { + removed = append(removed, s) + } + } + if len(added)+len(removed) == 0 { + continue + } + sort.Strings(added) + sort.Strings(removed) + out.Modules = append(out.Modules, meshgrants.GrantsForModulesItem{Module: module, Added: grantList(added), Removed: grantList(removed)}) + } + return out, nil +} + +func orWhy(err error, why string) string { + if err != nil { + return err.Error() + } + return why +} diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 251515b1..1db94a80 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -190,6 +190,10 @@ type Inputs struct { // EventSubjects are, by seat, the subjects of its events that carry parts, by the event's local name, as the definitions // give them: where a node-scoped seat's event names its machine (buscompose.Seat.EventSubjects). EventSubjects map[string]map[string]string + // AcceptCallers are, by pulled-by-kind seat and queue, the seats that may ask it (acceptCallersOf). + AcceptCallers map[string]map[string][]string + // VerbCallers are, by seat and verb, the callers each verb of the definitions admits (verbCallersOf). + VerbCallers map[string]map[string][]string // Defined are the events a seat definition of the seat list defines, by local name, and mesh-sdk's finding events: the // events a `*` emitter before them reaches each declaring module's own stream for (design 47 G §386, §474, §644). Defined map[string]bool @@ -370,6 +374,8 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { in.Kept = buscompose.KeptEventsOf(d.Definitions()) in.Chunked = buscompose.ChunkVerbsOf(d.Definitions()) in.EventSubjects = eventSubjectsOf(d.Definitions()) + in.AcceptCallers = acceptCallersOf(d.Definitions()) + in.VerbCallers = verbCallersOf(d.Definitions()) in.Defined = definedEventsOf(d.Definitions()) } nodes := map[string]bool{} diff --git a/modules/mesh-grants/internal/grants/kept.go b/modules/mesh-grants/internal/grants/kept.go index 42afb8bb..3994d901 100644 --- a/modules/mesh-grants/internal/grants/kept.go +++ b/modules/mesh-grants/internal/grants/kept.go @@ -135,3 +135,45 @@ func withEventSubjects(subjects map[string]map[string]string, d *buscompose.Decl } } } + +// acceptCallersOf is, by pulled-by-kind seat and then by queue, the seats that may ask the queue: the `seat:` entries +// of the callers of every accepted kind the definition puts on it (a kind's `stream` is its queue, else its name is). Never a +// name of the grants part's own: it reads the definitions' lists (design 47 G §543, §600, A-163). +func acceptCallersOf(defs map[string]seatdef.Definition) map[string]map[string][]string { + out := map[string]map[string][]string{} + for _, d := range defs { + if !d.PulledByKind { + continue + } + for _, a := range d.Accepts { + if a.Callers == nil || a.Callers.IsText() { + continue + } + queue := a.Name + if a.Stream != "" { + queue = a.Stream + } + for _, e := range a.Callers.List { + if seat, ok := strings.CutPrefix(e, seatdef.CallerSeatPrefix); ok && !slices.Contains(out[d.Seat][queue], seat) { + if out[d.Seat] == nil { + out[d.Seat] = map[string][]string{} + } + out[d.Seat][queue] = append(out[d.Seat][queue], seat) + } + } + } + } + return out +} + +// withAcceptCallers gives each pulled-by-kind seat a declaration holds or uses the askers of its queues (Seat.AcceptCallers). +func withAcceptCallers(askers map[string]map[string][]string, d *buscompose.Declared) { + if len(askers) == 0 { + return + } + for _, list := range []*[]buscompose.Seat{&d.Holds, &d.Uses, &d.Watches} { + for i := range *list { + (*list)[i].AcceptCallers = askers[(*list)[i].Name] + } + } +} diff --git a/modules/mesh-grants/internal/grants/named_asks_test.go b/modules/mesh-grants/internal/grants/named_asks_test.go new file mode 100644 index 00000000..bc58743c --- /dev/null +++ b/modules/mesh-grants/internal/grants/named_asks_test.go @@ -0,0 +1,36 @@ +package grants + +import ( + "slices" + "testing" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// The askers of a build seat's queue are read from the definition's callers lists (design 47 G §543, §600, A-163): a kind's +// `stream` is its queue, a text callers names none, and only `seat:` entries are askers. +func TestAcceptCallersOf(t *testing.T) { + msg := func(name, stream string, callers ...string) seatdef.Message { + m := seatdef.Message{Name: name, Stream: stream} + if callers != nil { + m.Callers = &seatdef.Callers{List: callers} + } + return m + } + got := acceptCallersOf(map[string]seatdef.Definition{ + "node-build-go": {Seat: "node-build-go", PulledByKind: true, Accepts: []seatdef.Message{ + msg("check", "", "seat:mesh-catalog", "operator"), + msg("step", "check", "seat:mesh-pipeline-runner"), + msg("delivery", "", "seat:mesh-builds"), + {Name: "recheck", Callers: &seatdef.Callers{Text: "the checker"}}, + }}, + "other": {Seat: "other", Accepts: []seatdef.Message{msg("go", "", "seat:x")}}, + }) + if len(got) != 1 { + t.Fatalf("seats: %v, want node-build-go alone", got) + } + q := got["node-build-go"] + if !slices.Equal(q["check"], []string{"mesh-catalog", "mesh-pipeline-runner"}) || !slices.Equal(q["delivery"], []string{"mesh-builds"}) || len(q) != 2 { + t.Errorf("queues: %v", q) + } +} diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 133dc6b4..2fa9228b 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -3,6 +3,7 @@ package grants import ( "fmt" "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" + sdkbc "git.novox.be/novox/mesh-sdk/go/buscompose" "maps" "reflect" "slices" @@ -101,10 +102,14 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { d.Watches, d.Consumes, d.Hears, keptHeard = keptOutOfHearing(in.Kept, d.Watches, d.Consumes, d.Hears) d.HearsAStream = d.HearsAStream || keptHeard d.Reads, d.ReadsObjects, d.ReadsRefused = readsOfObjects(m.Module, node, d.Reads, d.ReadsRefused, declared, values) + // The sent stores of the modules it is for (design 47 G §59, §124), read from the catalog graph's manifests and the + // definitions' callers. + d.ReadsObjects = append(d.ReadsObjects, sentStoresRead(m, in.Manifests, in.VerbCallers)...) // Its bindings as kept (bindings.go): the same source its membership's `bindings` is written from in this pass, // so a grant and its binding are written together, never one before the other (issue 1966, the kind of issue 249). d.Bindings = in.Bindings[m.Module] withEventSubjects(in.EventSubjects, &d) + withAcceptCallers(in.AcceptCallers, &d) return d } d := declare(m) @@ -136,6 +141,7 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { out.Engine = &buscompose.Declared{Module: m.Module, Consumes: m.Consumes, Watches: watches, Invokes: m.Invokes, State: bucketsFor(m, in.Nodes), Logs: logsOf(m), Streams: streamsOf(m)} withEventSubjects(in.EventSubjects, out.Engine) + withAcceptCallers(in.AcceptCallers, out.Engine) } return out, gaps } @@ -146,7 +152,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio fromModules, watches := consumedOf(m, seats, declarers) d := buscompose.Declared{ Module: m.Module, ToolRunner: catalogue.IsToolRunner(m), Emits: m.EmitsAll(), Consumes: fromModules, Watches: watches, Serves: m.Tools, - Invokes: m.Invokes, Waits: waitsOf(m, seats), State: bucketsOf(m), Reads: m.Reads, Logs: logsOf(m), + Invokes: m.Invokes, State: bucketsOf(m), Reads: m.Reads, Logs: logsOf(m), Streams: streamsOf(m), Queues: queueOwnersOf(m), Processes: processesOf(m), ServesOwn: ownServesOf(m), Checks: catalogue.HealthChecks(m), RunsAs: m.RunsAs, Issued: issuedTools(m), // The keys its served values fill, which every node may read where it runs (issue 1126). @@ -306,27 +312,6 @@ func loadsABundle(m catalogue.Manifest) bool { return false } -func waitsOf(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration) []buscompose.Wait { - var out []buscompose.Wait - for _, w := range m.Waits { - emitter, event, _ := strings.Cut(w, ".") - first, _, _ := strings.Cut(event, ".") - wait := buscompose.Wait{Name: w, Emitter: emitter, Event: event} - if s, isASeat := seats[emitter]; isASeat { - switch { - case s.Kinded && catalogue.SeatSays(s.Emits, first): - wait.Seat, wait.Last = true, "kind" - case s.NamedByCaller(first) && catalogue.SeatSays(s.Emits, first): - wait.Seat, wait.Last = true, "asker" - case catalogue.SeatSays(s.Emits, event): - wait.Seat = true - } - } - out = append(out, wait) - } - return out -} - func issuedTools(m catalogue.Manifest) []string { var out []string for _, o := range m.Provides { @@ -430,7 +415,7 @@ func streamsOf(m catalogue.Manifest) []buscompose.ModuleStream { for _, s := range m.Streams { events := make([]string, 0, len(s.Events)) for _, e := range s.Events { - events = append(events, buscompose.WirePattern(e)) + events = append(events, sdkbc.WirePattern(e)) } out = append(out, buscompose.ModuleStream{Module: m.Module, Name: s.BusName(), Events: events, MaxAgeDays: s.Days(), MaxMiB: s.Cap(), Restricted: s.Grant != nil, @@ -512,7 +497,7 @@ func hearsOf(m catalogue.Manifest, declared map[string]catalogue.Manifest, node for _, p := range parts { h := buscompose.Heard{Stream: p.Stream.BusName(), Emitter: p.Emitter, Restricted: p.Stream.Grant != nil} for _, pattern := range p.Patterns { - h.Filters = append(h.Filters, buscompose.ModuleStreamSubject(p.Emitter, buscompose.WirePattern(pattern))) + h.Filters = append(h.Filters, buscompose.ModuleStreamSubject(p.Emitter, sdkbc.WirePattern(pattern))) } out = append(out, h) } diff --git a/modules/mesh-grants/internal/grants/sentobjects.go b/modules/mesh-grants/internal/grants/sentobjects.go new file mode 100644 index 00000000..fc0082f1 --- /dev/null +++ b/modules/mesh-grants/internal/grants/sentobjects.go @@ -0,0 +1,86 @@ +package grants + +import ( + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// **A module's sent store is read by the modules it is for** (design 47 G §59, §124): the grants part derives, from the +// catalog graph and the seat definitions, never from a manifest field, a standing read of `_sent-objects` for the +// callers the module's seat definition names for the verbs it serves, and for the consumers whose `consumes` declare its +// events. The grant follows the catalog graph, so it is composed with each module's declaration at every pass. + +// verbCallersOf is, by seat and verb, the callers each verb of the definitions admits (a verb naming none takes its class's +// default). +func verbCallersOf(defs map[string]seatdef.Definition) map[string]map[string][]string { + out := map[string]map[string][]string{} + for _, d := range defs { + for _, v := range d.Verbs { + if out[d.Seat] == nil { + out[d.Seat] = map[string][]string{} + } + out[d.Seat][v.Name] = v.EffectiveCallers() + } + } + return out +} + +// admits says whether a verb's callers name the module: `invokers` (every module whose manifest invokes the verb), the module +// itself, or a seat it claims. +func admits(callers []string, m catalogue.Manifest) bool { + for _, c := range callers { + switch { + case c == seatdef.CallerInvokers, c == seatdef.CallerModulePrefix+m.Module: + return true + } + if seat, ok := strings.CutPrefix(c, seatdef.CallerSeatPrefix); ok && slices.ContainsFunc(m.Claims, func(x catalogue.Claim) bool { return x.Name == seat }) { + return true + } + } + return false +} + +// sentStoresRead are the bus names of the sent stores the module m reads: of each module whose event it consumes (the module's +// own, or the seat a module claims), and of each module claiming a seat whose verb m invokes where the verb's callers admit m. +// Never m's own store, which its binding grants it. +func sentStoresRead(m catalogue.Manifest, all map[string]catalogue.Manifest, callers map[string]map[string][]string) []string { + claimants := func(name string) []string { + var out []string + if _, ok := all[name]; ok { + out = append(out, name) + } + for module, x := range all { + if slices.ContainsFunc(x.Claims, func(c catalogue.Claim) bool { return c.Name == name }) { + out = append(out, module) + } + } + return out + } + var stores []string + for _, c := range m.Consumes { + name, _, ok := strings.Cut(strings.TrimPrefix(c, "seat:"), ".") + if !ok || name == "*" { + continue + } + for _, module := range claimants(name) { + stores = append(stores, catalogue.SentObjectsName(module)) + } + } + for _, in := range m.Invokes { + seat, verb, ok := catalogue.SeatInvoke(in) + if !ok || !admits(callers[seat][verb], m) { + continue + } + for module, x := range all { + if slices.ContainsFunc(x.Claims, func(c catalogue.Claim) bool { return c.Name == seat }) { + stores = append(stores, catalogue.SentObjectsName(module)) + } + } + } + stores = slices.DeleteFunc(stores, func(s string) bool { return s == catalogue.SentObjectsName(m.Module) }) + slices.Sort(stores) + return slices.Compact(stores) +} diff --git a/modules/mesh-grants/internal/grants/sentobjects_test.go b/modules/mesh-grants/internal/grants/sentobjects_test.go new file mode 100644 index 00000000..28e7c6cd --- /dev/null +++ b/modules/mesh-grants/internal/grants/sentobjects_test.go @@ -0,0 +1,38 @@ +package grants + +import ( + "slices" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// The sent stores a module reads follow the catalog graph (design 47 G §59, §124): the consumers of a module's events and the +// callers a seat's verb admits, and no other module. +func TestSentStoresRead(t *testing.T) { + holder := catalogue.Manifest{Module: "shop", Claims: []catalogue.Claim{{Name: "orders-seat"}}} + all := map[string]catalogue.Manifest{"shop": holder, "bank": {Module: "bank"}, "plain": {Module: "plain"}} + callers := map[string]map[string][]string{"orders-seat": { + "place": {"seat:billing", "module:plain"}, "list": seatdef.DefaultCallers(seatdef.ClassRead), "wipe": seatdef.DefaultCallers(seatdef.ClassAct)}} + for _, c := range []struct { + name string + m catalogue.Manifest + want []string + }{ + {"consumes a module's event", catalogue.Manifest{Module: "bank", Consumes: []string{"shop.sold"}}, []string{"shop_sent-objects"}}, + {"consumes a seat's event", catalogue.Manifest{Module: "bank", Consumes: []string{"orders-seat.sold"}}, []string{"shop_sent-objects"}}, + {"invokes a verb that names its seat", catalogue.Manifest{Module: "bank", Claims: []catalogue.Claim{{Name: "billing"}}, + Invokes: []string{"seat:orders-seat.place"}}, []string{"shop_sent-objects"}}, + {"invokes a verb that names it", catalogue.Manifest{Module: "plain", Invokes: []string{"seat:orders-seat.place"}}, []string{"shop_sent-objects"}}, + {"invokes a read, its callers include invokers", catalogue.Manifest{Module: "bank", Invokes: []string{"seat:orders-seat.list"}}, []string{"shop_sent-objects"}}, + {"invokes an act no one names it for", catalogue.Manifest{Module: "bank", Invokes: []string{"seat:orders-seat.wipe"}}, nil}, + {"invokes a verb that names others", catalogue.Manifest{Module: "bank", Invokes: []string{"seat:orders-seat.place"}}, nil}, + {"declares neither", catalogue.Manifest{Module: "bank"}, nil}, + {"its own store is its binding's", holder, nil}, + } { + if got := sentStoresRead(c.m, all, callers); !slices.Equal(got, c.want) { + t.Errorf("%s: %v, want %v", c.name, got, c.want) + } + } +} diff --git a/modules/mesh-grants/internal/grants/tools.go b/modules/mesh-grants/internal/grants/tools.go index 1942cb64..49fd88e0 100644 --- a/modules/mesh-grants/internal/grants/tools.go +++ b/modules/mesh-grants/internal/grants/tools.go @@ -14,8 +14,8 @@ import ( "mesh-grants/internal/buscompose" ) -// The part's tools, as its manifest lists them. The part claims no seat: the seat mesh-grants served the lease write -// alone, which went with the controller (novox/hq ADR 0590 §3, issue 1126). +// The part's tools, as its manifest lists them. The part claims the seat mesh-grants, which serves `grants-for` (design 47 G +// §401, §441); its own tools are named below. const ( ToolGrants = "grants_grants" ToolMembership = "grants_membership" @@ -40,7 +40,7 @@ const ( // Tools is every tool, in the manifest's order. var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolAccounts, ToolConsumerMatch, - ToolAcceptHashes, ToolFirstViews, ToolFirstViewAgain, ToolRunning, ToolWaits, ToolRelease, ToolSeatList, ToolSeatListAgain} + ToolAcceptHashes, ToolFirstViews, ToolFirstViewAgain, ToolRunning, ToolWaits, ToolRelease, ToolSeatList, ToolSeatListAgain, ToolCallers, ToolWhyRefused} // WritesGrants is what grants says of the part's own grants: written once the list the bus loaded carries a build's // accounts, and the one key the assigner reads (ADR 0571 clause 9, which changes ADR 0547 clause 5). diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 7b3efe8b..a5b98926 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -2,6 +2,15 @@ "module": "mesh-grants", "version": "0.1", "slug": "grants", + "claims": [ + { + "name": "mesh-grants", + "scope": "mesh", + "serves": [ + "grants-for" + ] + } + ], "reads": [ "mesh-config.resolved", "node-engine.have", @@ -73,7 +82,9 @@ "grants_waits", "grants_release", "grants_seat_list", - "grants_seat_list_again" + "grants_seat_list_again", + "grants_callers", + "grants_why_refused" ], "state": [ {