diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go index 554595ce..32069148 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go @@ -27,6 +27,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/health" "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/provisions" "mesh-delivery-assigner/internal/judge" "mesh-delivery-assigner/internal/assign" @@ -434,6 +435,61 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) { if !self { t.Error("ace's own want does not say it composes its own once placed") } + + // ADR 0603 (L3, D15): one source revision changes a provider (db), its new consumer (app) and a build module (tool, whose + // recipe moved: ace runs its first build and the second, under the agreed toolchain, is the one the source revision names). + // The keys are written as their writers write them: the indexer's modules and manifests, the builder's builds, the + // controller's grants, the placement seat's placement and the provisioner's provisions. The build module's want moves at + // once and the provider's with it; the consumer's waits for its provision, with the words naming it, and moves once the + // provisioner provides it (rule 8, ADR 0570 §4): nothing waits for a turn or an order, only for a key. + offer := `"provides":[{"name":"database","scope":"mesh","answered-by":"helper"}]` + dbDigest := b.manifest(t, "db", `{"module":"db","version":"1",`+offer+`}`) + appDigest := b.manifest(t, "app", `{"module":"app","version":"1","requires":["database"]}`) + toolDigest := b.manifest(t, "tool", `{"module":"tool","version":"1"}`) + asked := func(state, why string) { + raw, err := provisions.Encode(provisions.Provision{V: provisions.Version, Provision: "database", Consumer: provisions.End{Module: "app", Node: "ace"}, + Provider: provisions.End{Module: "db", Node: "ace"}, State: state, Why: why, At: time.Now()}) + if err != nil { + t.Fatal(err) + } + b.putJSON(t, provisions.ProvisionsBucket, "ace.app.database", raw) + } + // The provisioner keys it from the manifests, as soon as they are known and whether the provider is placed yet or not. + asked(provisions.Asked, "the provider's build is not done") + fpOf := func(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) } + toolOld := fpOf('1') + for module, row := range map[string]struct { + fp, digest string + }{"db": {fpOf('2'), dbDigest}, "app": {fpOf('3'), appDigest}, "tool": {fpOf('4'), toolDigest}} { + mod, _ := delivery.EncodeModule(delivery.Module{FP2: row.fp, Manifest: row.digest, Repository: "novox/" + module, Commit: strings.Repeat("2", 40), Path: delivery.RootPath}) + b.putJSON(t, delivery.ModulesBucket, module, mod) + fps := []string{row.fp} + if module == "tool" { + fps = append(fps, toolOld) + } + for _, fp := range fps { + built, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: row.digest}) + bkey, _ := delivery.BuilderBuildKey(module, fp) + b.putJSON(t, delivery.BuilderBuildsBucket, bkey, built) + gkey, _ := delivery.PartGrantKey(module, fp) + grant, _ := delivery.EncodeGrant(delivery.Grant{}) + b.putJSON(t, delivery.PartGrantsBucket, gkey, grant) + } + b.place(t, "ace", module) + } + runsOld, _ := delivery.EncodeHave(delivery.Have{FP: toolOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + b.putJSON(t, delivery.HaveBucket, "ace.tool", runsOld) + if w := b.wantNow(t, "ace", "tool", "the build module's want at the second build", func(w delivery.Want) bool { return w.FP == fpOf('4') }); w.FP != fpOf('4') { + t.Errorf("tool's want = %+v", w) + } + b.wantNow(t, "ace", "db", "the provider's want at its fingerprint", func(w delivery.Want) bool { return w.FP == fpOf('2') }) + if e, err := b.buckets[delivery.AssignerWantsBucket].Get(b.ctx, "want.ace.app"); err == nil { + t.Errorf("app was wanted while its provision was asked: %s", e.Value()) + } + asked(provisions.Provided, "") + if w := b.wantNow(t, "ace", "app", "the consumer's want once its provision is provided", func(w delivery.Want) bool { return w.FP == fpOf('3') }); w.FP != fpOf('3') { + t.Errorf("app's want = %+v", w) + } } // 1297/B9 (the assigner's half, ADR 0528 clause 6): started whole, the assigner holds a fingerprint whose health check diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go index 07368f8a..b87acaac 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go @@ -60,6 +60,8 @@ var readBuckets = []string{ delivery.PresenceBucket, delivery.HaveBucket, provisions.ProvisionsBucket, + delivery.SourceBucket, + delivery.MergesBucket, } // readFamilies are the states kept one bucket per node the assigner reads, each named in its manifest's reads: every @@ -140,6 +142,12 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any logf("the bucket %s cannot be opened (%v): the assigner's conditions are said straight to the router until it starts again", name, err) continue } + if (name == delivery.SourceBucket || name == delivery.MergesBucket) && err != nil { + // The gate's source and merges (ADR 0601 §1): without them no release is known to carry a build, so every move of + // a record module is asked of a person, as before. + logf("the bucket %s cannot be opened (%v): no release's Approve answers a record ask until it is bound", name, err) + continue + } if name == delivery.PartGrantsBucket && err != nil { // The grants part's grants (ADR 0571 clause 9): without them no module counts as granted until it is bound. logf("the bucket %s cannot be opened (%v): no grant is read until it is bound once the bus says it was made", name, err) @@ -158,7 +166,7 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any func startsWithout(name string) bool { switch name { case delivery.SeatHoldersBucket, provisions.ProvisionsBucket, assign.PoliciesBucket, assign.FindingsBucket, - delivery.AssignerPutsBucket: + delivery.AssignerPutsBucket, delivery.SourceBucket, delivery.MergesBucket: return true } return slices.Contains(seatBuckets, name) || name == delivery.PartGrantsBucket @@ -241,6 +249,8 @@ var watched = []string{ assign.PoliciesBucket, provisions.ProvisionsBucket, delivery.SeatHoldersBucket, + delivery.SourceBucket, + delivery.MergesBucket, } // follow watches every bucket a pass reads and makes a pass at start, once every watch handed what stands, and after @@ -336,8 +346,9 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, } started++ var opts []jetstream.WatchOpt - if name == delivery.HaveBucket { - opts = append(opts, jetstream.IncludeHistory()) // what each node ran healthy before, for a set-back + if name == delivery.HaveBucket || name == delivery.SourceBucket { + // what each node ran healthy before, for a set-back; and the release behind each source revision (ADR 0601 §1) + opts = append(opts, jetstream.IncludeHistory()) } if err := watch(name, opts...); err != nil { return err diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go index e3726890..73e1b9c4 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -158,7 +158,7 @@ func (p *passer) wants(ctx context.Context) { for name := range names { fp := trunk[name].FP2 name := name - m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], + m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Offered: offered(manifests), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], Self: func(node string) bool { return placed[node] }, Composed: func(node, fp string) (delivery.ComposedDigest, bool) { return p.w.composed(node, name, fp) // a placed node's own: the digest its own have names (ADR 0571 §6) @@ -174,7 +174,8 @@ func (p *passer) wants(ctx context.Context) { Allowed: func(node, digest string) bool { key, err := delivery.RecordAskKey(node, name, digest) return err == nil && p.answeredWith(key, assign.OptionYes) - }} + }, + Release: func(fp string) (string, bool) { return p.w.release(name, fp) }} if fp != "" { held, _ := delivery.HeldKey(name, fp) grant, _ := delivery.PartGrantKey(name, fp) @@ -819,7 +820,9 @@ func (p *passer) backupAsk(ctx context.Context, key, node, module string, want d // yeses writes a placed node's wants naming the build alone, and sees to the moves a node that composes its own judged a // person must allow (ADR 0547 §3): for a have of phase // waiting-yes it raises the record ask once, naming the composition waiting and the node's judgement, and on a yes writes -// the want again naming it; a yes once written is kept while the want stays at its build, so the node is not asked again. +// the want again naming it; a yes once written is kept while the want stays at its build, so the node is not asked again. A +// move of the build alone (the have says so) to a build an approved release carries is given its yes by that Approve, the +// want naming the release, with no ask (ADR 0601 §1). func (p *passer) yeses(ctx context.Context, module string, m assign.Module, before map[string]delivery.Want, out *assign.Outcome) { for _, node := range m.Nodes { if m.Self == nil || !m.Self(node) { @@ -836,25 +839,37 @@ func (p *passer) yeses(ctx context.Context, module string, m assign.Module, befo if w.Removed { continue } - yes := "" + yes, release := "", "" if had && was.FP == w.FP { - yes = was.Yes + yes, release = was.Yes, was.Release } key, err := delivery.HaveKey(node, module) if err != nil { continue } if h, err := delivery.DecodeHave(p.w.value(delivery.HaveBucket, key)); err == nil && h.Phase == delivery.PhaseWaitingYes && h.Waits != yes { - if m.Allowed != nil && m.Allowed(node, h.Waits) { - yes = h.Waits - } else { + switch { + case m.Allowed != nil && m.Allowed(node, h.Waits): + yes, release = h.Waits, "" + case h.BuildAlone && h.FP != w.FP && m.Release != nil: + // A move of the build alone, to the build an approved release carries: that Approve is the yes (ADR 0601 §1), + // named in the want. A move that changes a setting or a secret too, or one the node could not tell, is asked. + if r, ok := m.Release(w.FP); ok { + yes, release = h.Waits, r + break + } + p.recordAsk(ctx, module, w.FP, assign.RecordNeed{Node: node, Digest: h.Waits, Judgement: h.Judgement, Words: h.Detail}) + default: p.recordAsk(ctx, module, w.FP, assign.RecordNeed{Node: node, Digest: h.Waits, Judgement: h.Judgement, Words: h.Detail}) } } // A want carried over from before the node composed its own may still name the controller's composition; a placed // node's want names the build alone (ADR 0547 §2). - if yes != w.Yes || w.Composed != "" || !w.Self { - w.Yes, w.Composed, w.Self = yes, "", true + if yes == "" { + release = "" + } + if yes != w.Yes || release != w.Release || w.Composed != "" || !w.Self { + w.Yes, w.Release, w.Composed, w.Self = yes, release, "", true out.Wants[node] = w } } @@ -1071,6 +1086,26 @@ func (p *passer) seatHolders(ctx context.Context) { } } +// offered says whether some manifest of the trunk, or a module the mesh provides itself, offers a provision under its name +// (a module offers its own name too, catalogue.Manifest.Offers): what a requirement nobody offers is told from. +func offered(manifests map[string]catalogue.Manifest) func(string) bool { + names := map[string]bool{} + add := func(m catalogue.Manifest) { + for _, o := range m.Offers() { + names[o] = true + } + } + for _, m := range manifests { + add(m) + } + for _, module := range delivery.ProvidedModules() { + if m, _, err := delivery.ProvidedManifest(module); err == nil { + add(m) + } + } + return func(provision string) bool { return names[provision] } +} + // answering is, by provision, the manifests of the modules placed on some node that provide it: what a want's // provisions are judged by (catalogue.Awaited, issue 774). A provider in the catalogue placed nowhere answers nobody. func answering(manifests map[string]catalogue.Manifest, n assign.Nodes) func(string) []catalogue.Manifest { diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go index df5b9dc9..79baa718 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go @@ -111,6 +111,14 @@ func TestAHoldAskedAgainAfterALiftAndARestartHasANewID(t *testing.T) { // 1906/H4: an answer counts only for the asking it answered: an answer to an earlier hold of the same fingerprint, or to an ask // taken away, is not taken for the hold that stands now. func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) { + // ADR 0603 §5: a provision is offered by a manifest's provides, by a module's own name, and by a module the mesh provides + // itself (mesh-addressing, mesh-wireguard's); nothing else. + off := offered(map[string]catalogue.Manifest{"node-tools": {Module: "node-tools"}, "db": {Module: "db", Provides: []catalogue.Offer{{Name: "database"}}}}) + for provision, want := range map[string]bool{"node-tools": true, "database": true, "mesh-addressing": true, "queue": false} { + if off(provision) != want { + t.Errorf("offered(%s) = %v, want %v", provision, !want, want) + } + } key, _ := delivery.HoldAskKey("web", fpTest) p := newPasser(buckets{}, time.Now()) put := func(asking int) string { @@ -414,10 +422,11 @@ func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) { if last := logged[len(logged)-1]; last != "screen-lock on g14 has a want again" { t.Errorf("the clear said %q", last) } - // 1945 (the bus bar): a module that leaves neither list with no want is cleared in words saying so, never "has a want again". - bus := map[string]neitherOne{"novox.nats": {node: "novox", module: "nats", holding: assign.Holding{Bus: true, Words: "the bus moves only by the operator's put"}}} + // 1945: a module that leaves neither list with no want is cleared in words saying so, never "has a want again". The bus is + // on neither list by a rule as any module (ADR 0601 §1), nothing for the operator to do. + bus := map[string]neitherOne{"novox.nats": {node: "novox", module: "nats", holding: assign.Holding{Rule: delivery.RuleGrant, Words: "no credential"}}} p.sayNeither(bus, nil, nil) - if c := said[len(said)-1]; c.State != asks.StateRaised || !strings.HasPrefix(c.Explanation, asks.NeedsYou) || !strings.Contains(c.Explanation, "the bus moves only by the operator's put") { + if c := said[len(said)-1]; c.State != asks.StateRaised || !strings.HasPrefix(c.Explanation, asks.NothingToDo) || !strings.Contains(c.Explanation, "rule 6 holds its first want back: no credential") { t.Errorf("the bus's neither condition = %+v", c) } p.sayNeither(map[string]neitherOne{}, nil, map[string]string{"novox.nats": "it is held, and its held condition says so"}) @@ -474,7 +483,6 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) { {nil, assign.Outcome{Rules: map[string]assign.Holding{"shanks": {Rule: delivery.RuleGrant, Words: "no credential"}}}, "rofi on shanks gets no want: rule 6: no credential"}, {nil, wanted, "rofi on shanks has a want"}, {map[string]delivery.Want{"shanks": wanted.Wants["shanks"]}, assign.Outcome{}, ""}, - {nil, assign.Outcome{Rules: map[string]assign.Holding{"shanks": {Bus: true, Words: "the bus moves only by the operator's put"}}}, "rofi on shanks gets no want: the bus moves only by the operator's put"}, } { logged = nil p.sayNoWant("rofi", []string{"shanks"}, c.before, c.out) @@ -788,6 +796,74 @@ func mustKey(key string, err error) string { func TestARecordAskOfAHeldBackNodeSurvivesPassesAndItsYesMovesTheWant(t *testing.T) { // A placed node's yes comes from its have (issue 1126): folded here to hold the count. aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t) + aReleasesApproveAnswersTheMoveOfItsBuildAlone(t) +} + +// ADR 0601 §1: a placed node's have waiting for a yes to a move of the build alone, to a build an approved release carries, is +// given its yes by that Approve, the want naming the release, and no record ask is raised; a move that changes a setting or a +// secret too, one at the build it runs, and one no approved release carries are asked of a person as before. +func aReleasesApproveAnswersTheMoveOfItsBuildAlone(t *testing.T) { + fpOld := delivery.SourcePrefix2 + strings.Repeat("a", 64) + waiting, was := "sha256:"+strings.Repeat("e", 64), "sha256:"+strings.Repeat("d", 64) + commit := strings.Repeat("1", 40) + for _, c := range []struct { + name string + alone bool + at string // the fingerprint the have runs + mergedAt string // the commit the release merged web at; "" for no merge of the release + fpSrc uint64 // the source revision at which web's fingerprint last changed + yes, asked bool + }{ + {"the build alone, its release approved", true, fpOld, commit, 7, true, false}, + {"a setting changed too", false, fpOld, commit, 7, false, true}, + {"a setting changed at the build it runs", true, fpTest, commit, 7, false, true}, + {"no approved release carries it", true, fpOld, "", 7, false, true}, + {"the release merged another commit", true, fpOld, strings.Repeat("2", 40), 7, false, true}, + // Its fingerprint last moved at another release's revision, which merged nothing of web: that Approve answers nothing. + {"a fingerprint another release moved", true, fpOld, commit, 8, false, true}, + // Review rounds 1 and 2: moved by a recompute after the release (a toolchain, a package, a base) with no merge, the + // indexer writes fp-src 0, not known, though Src is still the release's: asked of a person. + {"a fingerprint moved by a recompute after the release", true, fpOld, commit, 0, false, true}, + } { + asksKV := &memKV{values: map[string][]byte{}} + p := newPasser(buckets{delivery.AssignerAsksBucket: asksKV}, time.Now()) + p.logf = t.Logf + mod, _ := delivery.EncodeModule(delivery.Module{FP2: fpTest, Repository: "novox/web", Commit: commit, Src: 7, FPSrc: c.fpSrc, Path: delivery.RootPath}) + p.w.put(delivery.ModulesBucket, "web", mod) + src, _ := delivery.EncodeSource(delivery.Source{Group: "feat/web", Heads: map[string]string{"novox/web": commit}}) + p.w.putEntry(delivery.SourceBucket, delivery.SourceMainKey, src, 7, time.Now()) + later, _ := delivery.EncodeSource(delivery.Source{Group: "feat/other", Heads: map[string]string{"novox/web": commit, "novox/x": commit}}) + p.w.putEntry(delivery.SourceBucket, delivery.SourceMainKey, later, 8, time.Now()) // a later merge leaves revision 7's release + if c.mergedAt != "" { + merge, err := delivery.EncodeMerge(delivery.Merge{Group: "feat/web", Ask: "merge.feat_web.0123456789ab", Pulls: []delivery.MergePull{ + {Head: delivery.Head{Repository: "novox/web", Number: 3, Head: strings.Repeat("3", 40)}, State: delivery.MergeMerged, Commit: c.mergedAt}}}) + if err != nil { + t.Fatal(err) + } + p.w.put(delivery.MergesBucket, "feat/web", merge) + other, err := delivery.EncodeMerge(delivery.Merge{Group: "feat/other", Ask: "merge.feat_other.0123456789ab", Pulls: []delivery.MergePull{ + {Head: delivery.Head{Repository: "novox/x", Number: 4, Head: strings.Repeat("4", 40)}, State: delivery.MergeMerged, Commit: commit}}}) + if err != nil { + t.Fatal(err) + } + p.w.put(delivery.MergesBucket, "feat/other", other) + } + have, _ := delivery.EncodeHave(delivery.Have{FP: c.at, Composed: was, Phase: delivery.PhaseWaitingYes, Health: delivery.HealthOK, + Judgement: delivery.JudgedRecreates, Waits: waiting, Detail: "it recreates web.server", BuildAlone: c.alone}) + p.w.put(delivery.HaveBucket, "g14.web", have) + m := assign.Module{Name: "web", FP: fpTest, Nodes: []string{"g14"}, Self: func(string) bool { return true }, + Release: func(fp string) (string, bool) { return p.w.release("web", fp) }} + out := assign.Outcome{Wants: map[string]delivery.Want{"g14": {FP: fpTest, Self: true}}} + p.yeses(context.Background(), "web", m, map[string]delivery.Want{"g14": {FP: fpOld, Self: true}}, &out) + w := out.Wants["g14"] + if got := w.Yes == waiting && w.Release == "feat/web"; got != c.yes || (!c.yes && (w.Yes != "" || w.Release != "")) { + t.Errorf("%s: the want is %+v", c.name, w) + } + akey, _ := delivery.RecordAskKey("g14", "web", waiting) + if _, there := asksKV.values[akey]; there != c.asked { + t.Errorf("%s: a record ask raised %v, want %v", c.name, there, c.asked) + } + } } // 774/S26/AC1 (ADR 0549 §5, ADR 0567 §8): the seat's placement alone names where modules run: no controller state is read, so a diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go index 0e202c95..fd93f700 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go @@ -385,7 +385,7 @@ func wantsVerbs(t *testing.T) { } manifest("web", "") manifest("photos", `,"upgrade":{"policy":"record","why":"a person checks the albums"}`) - manifest("nats", `,"provides":[{"name":"mesh-bus","scope":"mesh"}]`) + manifest("nats", `,"provides":[{"name":"mesh-bus","scope":"mesh"}],"upgrade":{"policy":"record","why":"its streams"}`) for node, fp := range map[string]string{"anchor": fpOld, "laptop": fpNew} { st[haveState].values[node+".nats"], _ = delivery.EncodeHave(delivery.Have{FP: fp, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) } @@ -406,7 +406,7 @@ func wantsVerbs(t *testing.T) { if p := policyOf("photos"); p.Policy != catalogue.PolicyRecord || p.From != catalogue.FromModule { t.Errorf("photos = %+v", p) } - if p := policyOf("nats"); p.Policy != catalogue.PolicyRecord || p.From != catalogue.FromBus { + if p := policyOf("nats"); p.Policy != catalogue.PolicyRecord || p.From != catalogue.FromModule { // the policy its manifest says (issue 774) t.Errorf("nats = %+v", p) } // compare: each node's own composition is the digest its have names, and the assigner's findings; nothing splits, the @@ -471,8 +471,12 @@ func wantsVerbs(t *testing.T) { len(said) != 1 || !strings.HasPrefix(said[0], "policy-changed policy-changed.web.") { t.Errorf("chosen %+v, asked %v, said %v", c, asked, said) } - if _, err := w.doUpgrade(map[string]any{"module": "nats", "policy": "roll", "why": "x"}, "op"); err == nil { - t.Error("the bus was given a policy") + // A person may choose the bus's policy as any module's (ADR 0601 §1: no part treats the module that provides the bus apart). + if _, err := w.doUpgrade(map[string]any{"module": "nats", "policy": "roll", "why": "x"}, "op"); err != nil { + t.Errorf("a person's policy for the bus was refused: %v", err) + } + if _, err := w.doUpgrade(map[string]any{"module": "nats", "policy": "default", "why": "back to the module"}, "op"); err != nil { + t.Fatal(err) } if _, err := w.doUpgrade(map[string]any{"module": "web", "policy": "roll"}, "op"); err == nil { t.Error("a policy without why was set") diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/wants_tools.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/wants_tools.go index 4749b289..d0c4092c 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/wants_tools.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/wants_tools.go @@ -197,18 +197,14 @@ func (s *wantsTools) doUpgrade(args map[string]any, by string) (any, error) { } // setPolicy writes a person's choice of a module's policy, or deletes it for default, records the act in the hand-act log -// and says policy-changed. The bus is never rolled: a choice for it is refused. +// and says policy-changed. The bus's choice is a person's as any module's (ADR 0601 §1). func (s *wantsTools) setPolicy(module, policy, why, by string, manifests map[string]catalogue.Manifest) (any, error) { if module == "" { return nil, errors.New("a policy is set for one module: name it") } - m, ok := manifests[module] - if !ok { + if _, ok := manifests[module]; !ok { return nil, fmt.Errorf("the indexer holds no manifest of %s", module) } - if catalogue.ProvidesBus(m) { - return nil, fmt.Errorf("%s is the bus, which is never rolled whoever says so (novox/hq ADR 0236): its step is the put on the operator's warrant", module) - } if why == "" { return nil, errors.New("a person's policy says why") } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go index 2277786d..1f33e577 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go @@ -37,6 +37,9 @@ type world struct { // placement is the seat's state placement assembled node by node from its keys in either form (mesh-sdk // delivery.PlacementKeys; research 072 row 37): the one reader of its keys in the assigner. placement *delivery.PlacementKeys + // sourceGroups is, by revision of the gate's source.main, the release (group) that moved it, from every revision its + // history keeps (watched with history): a build's source revision names the release that carries it (ADR 0601 §1). + sourceGroups map[uint64]string } // entryMeta is a value's revision in its bucket and when it was written. @@ -54,7 +57,8 @@ type parsed struct { func newWorld() *world { return &world{values: map[string]map[string][]byte{}, manifests: map[string]parsed{}, healthy: map[string][]string{}, changes: map[string]uint64{}, - meta: map[string]entryMeta{}, trunkBuilt: map[string]delivery.BuildLogEntry{}, placement: delivery.NewPlacementKeys()} + meta: map[string]entryMeta{}, trunkBuilt: map[string]delivery.BuildLogEntry{}, placement: delivery.NewPlacementKeys(), + sourceGroups: map[uint64]string{}} } // putEntry is put, keeping the value's revision and the time it was written. @@ -70,6 +74,44 @@ func (w *world) putEntry(bucket, key string, value []byte, revision uint64, at t if bucket == delivery.PlacementBucket { _, _ = w.placement.Put(key, value, revision, false) // put took it already; this is its revision } + if bucket == delivery.SourceBucket && key == delivery.SourceMainKey { + if s, err := delivery.DecodeSource(value); err == nil && s.Group != "" { + w.sourceGroups[revision] = s.Group + } + } +} + +// release is the release whose Approve carries module's build fp (ADR 0601 §1), and whether one does: the trunk's value of +// the module names fp, the source revision at which that fingerprint last changed (FPSrc, never the Src of its last merge, +// which a recompute with no merge keeps) names the release that moved it, and the gate's merge of that release, which it +// writes on the operator's Approve, holds the module's own trunk merge among its merged pull requests. Any of them +// unknown: none, and the move is asked of a person. +func (w *world) release(module, fp string) (string, bool) { + t, ok := w.trunk()[module] + if !ok || fp == "" || t.FP2 != fp || t.FPSrc == 0 || t.Commit == "" { + return "", false + } + w.mu.Lock() + group := w.sourceGroups[t.FPSrc] + w.mu.Unlock() + key, err := delivery.MergesKey(group) + if group == "" || err != nil { + return "", false + } + raw := w.value(delivery.MergesBucket, key) + if raw == nil { + return "", false + } + merge, err := delivery.DecodeMerge(raw) + if err != nil { + return "", false + } + for _, pull := range merge.Pulls { + if pull.State == delivery.MergeMerged && pull.Commit == t.Commit && delivery.SameRepository(pull.Repository, t.Repository) { + return group, true + } + } + return "", false } // metaOf is the revision and time of the value a bucket holds at key, as the watch said them. diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index f0e57eeb..d08194bd 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index 43cc4ec7..abad2969 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-assigner/internal/assign/asks.go b/modules/mesh-delivery-assigner/internal/assign/asks.go index b8125bee..70651e77 100644 --- a/modules/mesh-delivery-assigner/internal/assign/asks.go +++ b/modules/mesh-delivery-assigner/internal/assign/asks.go @@ -119,14 +119,6 @@ func NeitherCondition(node, module string, h Holding, raised bool) asks.Conditio if n := len([]rune(headline)); n > asks.HeadlineLength { headline = string([]rune(headline)[:asks.HeadlineLength]) } - if h.Bus { - needs := fmt.Sprintf("put %s on %s with assigner_put", module, node) - return asks.Condition{Key: key, Severity: asks.SeverityUrgent, Subject: subject, State: asks.StateRaised, - Summary: headline, Headline: headline, Needs: needs, - Explanation: fmt.Sprintf("%s%s. %s is assigned to %s, but the node holds no want for it and the node's own composition does not "+ - "hold it either, so nothing applies it there. The assigner never moves the bus: %s. This clears once the node has a want for it.", - asks.NeedsYou, needs, module, node, h.Words)} - } return asks.Condition{Key: key, Severity: asks.SeverityUrgent, Subject: subject, State: asks.StateRaised, Summary: headline, Headline: headline, Explanation: fmt.Sprintf("%s %s is assigned to %s, but the node holds no want for it and the node's own composition does not "+ diff --git a/modules/mesh-delivery-assigner/internal/assign/policy.go b/modules/mesh-delivery-assigner/internal/assign/policy.go index c3d94566..fab4630b 100644 --- a/modules/mesh-delivery-assigner/internal/assign/policy.go +++ b/modules/mesh-delivery-assigner/internal/assign/policy.go @@ -16,7 +16,7 @@ import ( // A person's upgrade policy is the assigner's (novox/hq ADR 0565 §7, §8, issue 1953 D4, D5): kept in its state `policies`, one // key per module, set and taken back through `mesh-wants.upgrade`. Every reader takes one derivation, mesh-sdk's -// catalogue.Upgrade: a person's choice over the module's word, its irreplaceable data and the default, the bus never rolled. +// catalogue.Upgrade: a person's choice over the module's word, its irreplaceable data and the default, the bus as any module (ADR 0601 §1). // PoliciesBucket is the assigner's state `policies`. var PoliciesBucket = delivery.AssignerModule + "_" + delivery.AssignerPoliciesState diff --git a/modules/mesh-delivery-assigner/internal/assign/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index 3859e68d..ac7cc4fa 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants.go @@ -36,6 +36,10 @@ type Module struct { // Answering is the manifests of the modules placed somewhere that provide a provision: what catalogue.Awaited // judges a want's provisions by (issue 774). Nil: none known, and no key is waited for that is not there. Answering func(provision string) []catalogue.Manifest + // Offered says whether any module offers a provision by name or by its own name (catalogue.Manifest.Offers) among every + // manifest the trunk holds and the modules the mesh provides itself, placed or not. Nil: not known, and no requirement + // is waited on for want of an offer. + Offered func(provision string) bool // Chosen is a person's choice of the module's upgrade policy (the assigner's `policies`, ADR 0565 §7); nil: none. Chosen *catalogue.PersonPolicy Nodes []string @@ -45,6 +49,10 @@ type Module struct { // Allowed says whether the operator answered yes to the record ask about the move of this module on node to the composition // with this digest (ADR 0539 §14). Nil: none did. Allowed func(node, digest string) bool + // Release names the release whose Approve carries the build fp, as mesh-delivery's gate holds it for the release's heads, + // read by the build's source revision (ADR 0601 §1): that Approve is the yes to a move of the build alone. False when no + // approved release is known to carry it. Nil: none is read. + Release func(fp string) (string, bool) // Released is a hold on FP the operator released (ADR 0539 §19): FP is not held again by a health check that failed. Released bool // Restored says whether the operator answered "restore" to the restore ask of this data module on node: it is then set back @@ -149,8 +157,36 @@ func (m Module) provisionWaiting(node string) (string, bool) { return fmt.Sprintf("provision %s has no key yet for %s on %s", missing[0], m.Name, node), true } keyed := m.Provisions(node) + // Only a provision the newest manifest still requires is waited on (ADR 0603 §9): a key of an older revision's edge, + // asked or retiring for the operator's answer (ADR 0570 §5), holds nothing. A requirement catalogue.Awaited leaves out is + // not waited on when something offers it (a module's own name, a module the mesh provides itself, a provision the + // composition does not key: optional, a toolchain on the module's own machine, a login the controller makes). One that + // nothing offers is waited on (ADR 0603 §5: an unknown provider has provided nothing), so a consumer never moves ahead of + // a provider whose module the indexer has not yet taken in; it holds loudly, since the watchman raises the hold past its + // bound (ADR 0603 §8). + needed := m.Manifest.Requires + if m.Answering != nil { + needed = catalogue.Awaited(m.Manifest, m.Answering) + have := map[string]bool{} + for _, p := range keyed { + have[p.Provision] = true + } + for _, r := range slices.Sorted(slices.Values(m.Manifest.Requires)) { + if have[r] && !m.Manifest.OptionalRequires[r] && !slices.Contains(needed, r) { + needed = append(needed, r) // the provisioner keys it from the manifests, whoever is placed yet + continue + } + if m.Offered == nil || m.Manifest.OptionalRequires[r] || len(m.Answering(r)) > 0 || m.Offered(r) || slices.Contains(needed, r) { + continue + } + needed = append(needed, r) + if !have[r] { + return fmt.Sprintf("no module offers %s yet for %s on %s: an unknown provider has provided nothing, so the want waits for one, and the watchman raises it past its bound", r, m.Name, node), true + } + } + } for _, key := range slices.Sorted(maps.Keys(keyed)) { - if p := keyed[key]; p.State != provisions.Provided { + if p := keyed[key]; p.State != provisions.Provided && slices.Contains(needed, p.Provision) { words := fmt.Sprintf("the provision %s of %s on %s is %s, not provided", key, m.Name, node, p.State) if p.Why != "" { words += ": " + p.Why @@ -268,29 +304,18 @@ type RecordNeed struct { Words string } -// Holding is a rule holding a node back, and why. Bus marks the bar on the bus instead of one of the seven rules (Rule is then -// 0): the bus moves only by the operator's put (ADR 0539 §15, ADR 0546 §8), never by the assigner (issue 1945: nats on novox, -// running an old-form build, got no want and the pass said nothing). +// Holding is a rule holding a node back, and why. The bus module is held back by the rules alone, as every module (ADR 0601 §1, +// §2): no bar of its own. type Holding struct { Rule int Words string - Bus bool } -// Reason is the holding in words: the rule and its words, or for the bus bar its words alone. +// Reason is the holding in words: the rule and its words. func (h Holding) Reason() string { - if h.Bus { - return h.Words - } return fmt.Sprintf("rule %d: %s", h.Rule, h.Words) } -// BusBarWords say why the assigner writes no want moving the bus on node: it runs prev, the trunk builds fp, and only the -// operator's put moves it (ADR 0539 §15, ADR 0546 §8). The words reach the operator, so they name no record. -func BusBarWords(node, prev, fp string) string { - return fmt.Sprintf("only your put moves the bus: %s runs %s, the trunk builds %s; put it with assigner_put", node, short(prev), short(fp)) -} - // Wants is the pass of one module m: for each of its nodes, the want by the six rules, starting from the want written // before (wants, by node); lastHealthy is, by node, the newest fingerprint other than m.FP the node ran healthy. A failed // health check of m.FP on a node holds it: every node wanting m.FP is set back to its last healthy fingerprint, but a @@ -425,14 +450,6 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st if need, wait := at.mustAsk(node, asked, hadLive, ran, running.FP, running.Composed == "", d); wait { if need != nil { out.Record = append(out.Record, *need) - } else if prev, barred := at.busBar(asked, hadLive, running.FP); barred { - // The bus is barred: no want moves it but the operator's put. Said, never silent (issue 1945): its holding is named, - // and a node that runs it with no live want has it on neither list, so its condition stays raised. - h := Holding{Bus: true, Words: BusBarWords(node, prev, at.FP)} - out.Rules[node] = h - if !hadLive && running.Phase != delivery.PhaseRemoved && !held { - out.Neither[node] = h - } } continue } @@ -659,10 +676,10 @@ func short(fp string) string { return part[:12] } -// mustAsk says whether the move to the composition d on node waits: for the operator's yes (need names the ask) or, for the bus, -// for the operator's hand (ADR 0539 §14, §15). A module the node never ran (it has no want and no have), a move judged neither, a module that is not of -// policy record and a want that stays are written as any want; so is a revision of the bus module's composition at the -// same fingerprint judged neither (a user list only, §7). +// mustAsk says whether the move to the composition d on node waits for the operator's yes (need names the ask, ADR 0539 §14). A +// module the node never ran (it has no want and no have), a move judged neither, a module that is not of policy record and a want +// that stays are written as any want. The bus module is no exception: it moves by the rules (ADR 0601 §1), its node backing up its +// streams before it applies (§2); the assigner asks no snapshot for it. func (m Module) mustAsk(node string, before delivery.Want, had, ran bool, running string, haveNamesNoComposition bool, d delivery.ComposedDigest) (*RecordNeed, bool) { if (!had && !ran) || before.Removed || (before.FP == m.FP && before.Composed == d.Digest) { return nil, false @@ -674,9 +691,6 @@ func (m Module) mustAsk(node string, before delivery.Want, had, ran bool, runnin if !had && ran && running == m.FP && haveNamesNoComposition && d.Judgement == delivery.JudgedUnchanged { return nil, false } - if _, barred := m.busBar(before, had, running); barred { - return nil, true // the bus is never moved by the assigner; the operator writes its want (§15) - } if !m.recorded(d) || d.Judgement == delivery.JudgedNeither || d.Judgement == delivery.JudgedUnchanged { return nil, false } @@ -686,17 +700,7 @@ func (m Module) mustAsk(node string, before delivery.Want, had, ran bool, runnin return &RecordNeed{Node: node, Digest: d.Digest, Judgement: d.Judgement, Words: d.Words}, true } -// busBar says whether m is the bus and the move would take it off the fingerprint it ran before, prev: its want's, or with no -// readable want the one its have names (ADR 0539 §15 bars a new one). -func (m Module) busBar(before delivery.Want, had bool, running string) (prev string, barred bool) { - prev = before.FP - if !had { - prev = running - } - return prev, catalogue.ProvidesBus(m.Manifest) && prev != m.FP -} - -// recorded says whether the module is of policy record: a person's choice at the assigner over everything but the bus (ADR +// recorded says whether the module is of policy record: a person's choice at the assigner over the manifest's word (ADR // 0565 §7, catalogue.Upgrade); for a module with none, as the controller holds it beside the composition's digest until the // lease write (ADR 0539 §14); on a composition made by hand, which carries none, as the manifest derives it. func (m Module) recorded(d delivery.ComposedDigest) bool { diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index 35af5f8a..39972c46 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -104,6 +104,27 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { t.Errorf("provided provisions held g14's want: %+v (%s)", out.Wants["g14"], out.Rules["g14"].Words) } } + // ADR 0603 §9: the newest source revision governs. The module now at fNew no longer requires the database an older revision + // asked for and requires oidc instead; the seat's keys follow the newest manifest, so the older edge is no wait: a provided + // oidc moves the want, an asked one holds it and names oidc and never database. + for _, c := range []struct { + name string + state, older string + moves bool + }{{"the replaced edge provided, the older edge retiring", provisions.Provided, provisions.Retiring, true}, + {"the replaced edge provided, the older edge asked", provisions.Provided, provisions.Asked, true}, + {"the replaced edge asked, the older edge retiring", provisions.Asked, provisions.Retiring, false}} { + m, n := ready() + m.Manifest.Requires = []string{"oidc"} + m.Provisions = func(node string) map[string]provisions.Provision { + return map[string]provisions.Provision{node + ".mail.oidc": {Provision: "oidc", State: c.state}, + node + ".mail.database": {Provision: "database", State: c.older, Why: "the operator's answer"}} + } + out := Wants(m, n, wantedOld(), nil) + if got := out.Wants["g14"].FP == fNew; got != c.moves || (!c.moves && (!strings.Contains(out.Rules["g14"].Words, "oidc") || strings.Contains(out.Rules["g14"].Words, "database"))) { + t.Errorf("%s: want %+v, held by %q", c.name, out.Wants["g14"], out.Rules["g14"].Words) + } + } // 1954/D1 (the lead, 2026-10-29): a required provision with no key yet holds the want, named; and only one the // composition keys (catalogue.Awaited, issue 774), replaying 2026-10-30: grafana (not handed) waited on ace for // oidc-client, which the controller still makes, and mesh-build-go (handed) for go-toolchain on its own machine. @@ -155,6 +176,24 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { } } } + // ADR 0603 §5: a requirement nobody offers is an unknown provider and waits, loudly; one something offers (a module's own + // name, a module the mesh provides itself) is left to catalogue.Awaited as before and does not. + for _, c := range []struct { + name, requires string + offered bool + }{{"a module's own name (node-tools)", "node-tools", true}, {"a module the mesh provides itself (mesh-addressing)", "mesh-addressing", true}, + {"a provision nobody offers", "database", false}} { + m, n := ready() + m.Manifest.Requires = []string{c.requires} + m.Answering = func(string) []catalogue.Manifest { return nil } + m.Offered = func(p string) bool { return c.offered && p == c.requires } + m.Provisions = func(string) map[string]provisions.Provision { return map[string]provisions.Provision{} } + out := Wants(m, n, wantedOld(), nil) + if got := out.Wants["g14"].FP == fNew; got != c.offered || (!c.offered && (out.Rules["g14"].Rule != delivery.RuleProvisions || + !strings.Contains(out.Rules["g14"].Words, "no module offers database yet") || !strings.Contains(out.Rules["g14"].Words, "past its bound"))) { + t.Errorf("%s: want %+v, held by %+v", c.name, out.Wants["g14"], out.Rules["g14"]) + } + } // 1960: rule 5 holds no want, and no module is left without a first want for a provider down or a node offline; the // provider down is said (Unreached). novox on 2026-10-29: route-proxy down held de-spiegel and link2pay, minio not // healthy held photos, and novox offline left a module with no canary. @@ -639,11 +678,13 @@ func TestAMoveJudgedNeitherOrNeverRunOrNotRecordedIsWrittenAtOnce(t *testing.T) // 1906/D4 (ADR 0539 §7, §15): the bus module's new fingerprint is never written by the assigner, but a revision of its // composition at the same fingerprint that only changes the user list (judged neither) is written at once. -func TestTheBusIsNotMovedButItsUserListRevisionIsWrittenAtOnce(t *testing.T) { +func TestTheBusMovesByTheRulesAndItsUserListRevisionIsWrittenAtOnce(t *testing.T) { m, n, _ := movingTo(delivery.JudgedNeither) m.Manifest.Provides = []catalogue.Offer{{Name: "mesh-bus"}} - if out := Wants(m, n, wantedOld(), nil); out.Wants["ace"].FP == fNew { - t.Errorf("the bus moved to a new fingerprint: %+v", out.Wants["ace"]) + // ADR 0601 §1, §2: the bus module's want is written by the rules, as any module's, and no snapshot is asked for it: its node + // backs its streams up before it applies. + if out := Wants(m, n, wantedOld(), nil); out.Wants["ace"].FP != fNew || len(out.Record) != 0 || len(out.Neither) != 0 { + t.Errorf("the bus did not move by the rules: %+v", out) } running := map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fOld)}, "g14": {FP: fNew, Composed: dig(fOld)}} out := Wants(m, n, running, nil) @@ -696,23 +737,6 @@ func TestTheBusIsNotMovedButItsUserListRevisionIsWrittenAtOnce(t *testing.T) { t.Errorf("a held bus did not take its user-list revision as a put: %+v", w) } m.Puts = nil - // 1945 (the bus bar made loud): a node that runs the bus at an old-form build and holds no want for it gets none, since only - // the operator's put moves the bus, and the pass says so: a holding naming the bar, and the node on neither list so its - // condition stays raised. A node with a live want keeps it and is not on neither list. - m.Composed = composedEverywhere - n.Haves["ace"]["mail"] = delivery.Have{FP: "b-1700000000-oldform", Phase: delivery.PhaseRunning, Health: delivery.HealthOK} - out = Wants(m, n, map[string]delivery.Want{"g14": {FP: fOld, Composed: dig(fOld)}}, nil) - if w, wrote := out.Wants["ace"]; wrote { - t.Errorf("the bus bar wrote ace's want %+v", w) - } - h, on := out.Neither["ace"] - if !on || !h.Bus || out.Rules["ace"] != h || !strings.Contains(h.Words, "only your put moves the bus") || !strings.Contains(h.Words, "ace runs b-1700000000-oldform") || - !strings.Contains(h.Words, "assigner_put") { - t.Errorf("the barred bus on ace: neither %+v (%v), rules %+v", h, on, out.Rules["ace"]) - } - if _, on := out.Neither["g14"]; on || out.Wants["g14"].FP == fNew { - t.Errorf("g14 has a live want: neither %+v, its want %+v", out.Neither, out.Wants["g14"]) - } stepsOfAPut(t) } @@ -889,8 +913,8 @@ func TestTheRecordPolicyIsTheOneTheControllerHolds(t *testing.T) { } } -// Review round 1, item 2 (ADR 0539 §15): a node that runs the bus with no readable want compares the new fingerprint with the one -// it runs, from its have: the same fingerprint is not a new one (written by the rules), another is barred. +// A node that runs the bus with no readable want gets its want by the rules, at the fingerprint it runs or at a new one alike: the +// bus has no bar of its own (ADR 0601 §1). func TestTheBusWithNoWantIsComparedWithWhatItRuns(t *testing.T) { m, n, _ := movingTo(delivery.JudgedNeither) m.Manifest.Provides = []catalogue.Offer{{Name: "mesh-bus"}} @@ -899,8 +923,8 @@ func TestTheBusWithNoWantIsComparedWithWhatItRuns(t *testing.T) { t.Errorf("the bus runs F with no want and was not given one: %+v %v", w, ok) } n.Haves["ace"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} - if w, ok := Wants(m, n, nil, nil).Wants["ace"]; ok { - t.Errorf("the bus was moved to a new fingerprint with no want: %+v", w) + if w, ok := Wants(m, n, nil, nil).Wants["ace"]; !ok || w.FP != fNew { + t.Errorf("the bus running an older fingerprint with no want was not given its want by the rules: %+v %v", w, ok) } } diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index d5e14e06..c594947e 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -34,8 +34,8 @@ func TestManifest(t *testing.T) { reads := slices.Clone(m.Reads) slices.Sort(reads) // ADR 0593 §8: it composes nothing, so it reads no candidate, held or resolved value, compose step or build log; it reads - // every node's published compositions to compare them. - want := []string{"mesh-delivery-builder.builds", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", + // every node's published compositions to compare them, and the gate's source and merges, for the release that carries a build (ADR 0601 §1). + want := []string{"mesh-delivery-builder.builds", "mesh-delivery-gate.merges", "mesh-delivery-gate.source", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-grants.grants", "mesh-host.composed", "mesh-provisioner.provisions", "node-engine.have"} if !reflect.DeepEqual(reads, want) { diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index 3c7deb4b..86ee65ec 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -25,6 +25,8 @@ ], "reads": [ "mesh-delivery-builder.builds", + "mesh-delivery-gate.merges", + "mesh-delivery-gate.source", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", diff --git a/modules/mesh-delivery-builder/buildrun/go.mod b/modules/mesh-delivery-builder/buildrun/go.mod index b79058c5..bd7aa559 100644 --- a/modules/mesh-delivery-builder/buildrun/go.mod +++ b/modules/mesh-delivery-builder/buildrun/go.mod @@ -3,7 +3,7 @@ module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-builder/buildrun/go.sum b/modules/mesh-delivery-builder/buildrun/go.sum index 43cc4ec7..abad2969 100644 --- a/modules/mesh-delivery-builder/buildrun/go.sum +++ b/modules/mesh-delivery-builder/buildrun/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go index c8a31c31..56516c70 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go @@ -98,7 +98,7 @@ func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, q Description: "Every build that ended since a time, oldest first, from the builder's log build-log (kept 30 days): per build its module, " + "fingerprint, source revision, repository, directory, commit and ref, the build node, when it ended, its outcome (done, failed or " + "queued), the manifest built, the bases it was built against and each artifact made with its digest and reference. more says the " + - "log held more than one answer carries: ask again from the last build's time. What mesh-catalog registers a build from. Read-only.", + "log held more than one answer carries: ask again from the last build's time. What the operator's command nox builds reads. Read-only.", Input: map[string]any{"type": "object", "required": []string{"since"}, "properties": map[string]any{ "since": map[string]any{"type": "string", "description": "RFC 3339 time; the builds that ended at or after it"}}}, Run: func(args map[string]any) (any, error) { diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go index 2c118885..bdfdb4a3 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go @@ -131,7 +131,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro trunks.start() b.Trunk = trunks.head // The builder's log (ADR 0571 §1): each build's start and end in its module log `build-log`, read by the view, the - // indexer and the assigner through their reads, and by mesh-catalog through the tool builder_builds_since. + // indexer and the assigner through their reads, and by the operator's command nox builds through the tool builder_builds_since. b.Log = func(e delivery.BuildLogEntry) { if err := appendLog(context.WithoutCancel(ctx), js, cred, e); err != nil { logger.Printf("%v", err) diff --git a/modules/mesh-delivery-builder/go.mod b/modules/mesh-delivery-builder/go.mod index 3fd55d9d..70c1b694 100644 --- a/modules/mesh-delivery-builder/go.mod +++ b/modules/mesh-delivery-builder/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-builder go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-builder/go.sum b/modules/mesh-delivery-builder/go.sum index 340e467e..8518fa28 100644 --- a/modules/mesh-delivery-builder/go.sum +++ b/modules/mesh-delivery-builder/go.sum @@ -1,7 +1,7 @@ git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a h1:oxgksCiq272/afXbUION/dxtAS5MPGnmyq6+AZQxmXg= git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a/go.mod h1:kpsX4Pgss5EeuBvel1zWa2ogj7MkR3jZso6oa/J0YUQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-builder/internal/build/run.go b/modules/mesh-delivery-builder/internal/build/run.go index 6cae6942..106916cf 100644 --- a/modules/mesh-delivery-builder/internal/build/run.go +++ b/modules/mesh-delivery-builder/internal/build/run.go @@ -944,7 +944,7 @@ func LogStarted(module string, m delivery.Module, ref, by string, at time.Time) } // LogEnded is the log's entry of that build ending: the outcome (done, failed or queued), the manifest built, the bases it -// was built against and each artifact made, as the done key has them: what mesh-catalog registers a build from. +// was built against and each artifact made, as the done key has them: what the operator's command nox builds shows of a build. func LogEnded(module string, m delivery.Module, ref, by string, at time.Time, result delivery.BuilderBuild) delivery.BuildLogEntry { e := LogStarted(module, m, ref, by, at) e.Event, e.Outcome, e.Manifest = delivery.BuildEndedEvent, result.State, m.Manifest diff --git a/modules/mesh-delivery-builder/internal/build/run_test.go b/modules/mesh-delivery-builder/internal/build/run_test.go index 03d64ce4..3a042d25 100644 --- a/modules/mesh-delivery-builder/internal/build/run_test.go +++ b/modules/mesh-delivery-builder/internal/build/run_test.go @@ -565,6 +565,7 @@ func TestAKeyIsBuiltOnlyWithTheToolchainItsFingerprintNamesAndNothingIsWrittenOt r.b.Module(context.Background(), "m", moduleValueWith(t, "modules/m", old)) r.b.Wait() if len(r.st.vals) != 0 || len(r.bm.calls) != 0 { + t.Run("a recipe that moves builds the module again under its new fingerprint", aRecipeThatMovesBuildsTheModuleAgainUnderItsNewFingerprint) t.Errorf("keys %v and %d calls for a fingerprint of go1.26.0 while the nodes agree on go1.27.1, want neither", r.st.vals, len(r.bm.calls)) } // A recipe that differs is a different toolchain too, and a value that names none (written before) is not built. @@ -589,6 +590,45 @@ func TestAKeyIsBuiltOnlyWithTheToolchainItsFingerprintNamesAndNothingIsWrittenOt } } +// ADR 0603 §3 (D9): a source revision changing the Go build module's recipe and a Go module builds the module at once with +// the recipe the nodes run and again, a second key under the new fingerprint, once the agreement moves to the new recipe; +// the first key is neither rebuilt nor waited on. +func aRecipeThatMovesBuildsTheModuleAgainUnderItsNewFingerprint(t *testing.T) { + r := newRig(t, goBundle) + r.consider(t, "modules/m") + calls := len(r.bm.calls) + fp2 := "src2:" + hex64('2') + for _, n := range []string{"ace", "novox"} { + b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "go", Version: "go1.27.1", Recipe: "2", At: time.Now()}) + if err != nil { + t.Fatal(err) + } + r.tools.Put(buildrun.GoBuildModule, n+".go", b, false) + } + r.b.Reconsider(context.Background()) + r.b.Wait() + if len(r.bm.calls) != calls { + t.Errorf("%d calls, was %d: the first fingerprint was built again for the new recipe", len(r.bm.calls), calls) + } + data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", FP: "src1:" + hex64('f'), FP2: fp2, + Manifest: mdigest('a'), Path: "modules/m", Toolchains: map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "2"}}}) + if err != nil { + t.Fatal(err) + } + r.b.Module(context.Background(), "m", data) + r.b.Wait() + second, err := delivery.BuilderBuildKey("m", fp2) + if err != nil { + t.Fatal(err) + } + if v := valueOf(t, r.st, second); v.State != delivery.BuildDone || v.Toolchains["go"].Recipe != "2" { + t.Errorf("second build = %+v, want done with recipe 2", v) + } + if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone { + t.Errorf("first build = %+v, want it left done", v) + } +} + func TestNodesTheyDisagreeOnLeaveTheKeyUnwritten(t *testing.T) { // 1615/AC4 r := newRig(t, goBundle) r.tools.Put(buildrun.GoBuildModule, "ace.go", tcValue(t, "go", "go1.26.0"), false) diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go index 2159d0e5..fd39fe86 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go @@ -244,7 +244,9 @@ func Tools(r check.Reader) []stdio.Tool { "build seats' streams, whose backlog the delivery view shows (novox/hq ADR 0525). held_by_turn names the candidates whose check an approved " + "merge's turn holds off (novox/hq ADR 0548 §3), with the repository and the merge that waits; the gate's turns tool reads every turn and its " + "hand-now tool steers one. dead_letters says whether the checker hears the asks the build seats give up on (issue 734), and why not: " + - "deaf, a try or check whose ask is given up waits until its bound. Read-only.", + "deaf, a try or check whose ask is given up waits until its bound. builds_not_ended names, of each running check, the builder's builds of " + + "its candidate's changed modules that are neither done nor failed: the check waits on them before it composes, within its build-wait-bound " + + "(novox/hq ADR 0603 §10); a failed one fails the check: rebuild it with the builder's builder_build_again, and the check asks itself again when the build is done. Read-only.", Input: map[string]any{"type": "object", "properties": map[string]any{}}, Run: func(map[string]any) (any, error) { return r.Status() }}, } @@ -283,7 +285,7 @@ func main() { StepStates: map[string]check.StateRead{"mesh-build-go": read(stdio.State("mesh-build-go.steps")), "mesh-build-node": read(stdio.State("mesh-build-node.steps"))}, HaveState: read(stdio.State("node-engine.have")), StepBound: stepBound(), TurnState: read(stdio.State("mesh-delivery-gate.turns")), HearingState: read(stdio.State(buildrun.HearingState)), - AskState: read(stdio.State(check.AsksState))} + AskState: read(stdio.State(check.AsksState)), BuildState: read(stdio.State("mesh-delivery-builder.builds"))} r.Runs = runsOf{read(stdio.State("mesh-delivery-indexer.modules"))} // the node-engine at the commit the mesh runs, as the checker reads it tries := stdio.State(check.TriesState) source := stdio.State("mesh-delivery-gate.source") diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/engines.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/engines.go index 22c6de0e..e16b49e0 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/engines.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/engines.go @@ -128,7 +128,9 @@ func (m *manifestVerdicts) put(module string, v *delivery.ManifestVerdict) { delete(m.by, module) return } - m.by[module] = *v + // The sandbox's composer may be the node-engine of an older delivery, which does not know the verdict waits: it is handed a + // wait as the refusal it is held as. + m.by[module] = v.AsRefused() } func (m *manifestVerdicts) all() map[string]delivery.ManifestVerdict { diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go index 4a03d309..823deb6c 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go @@ -398,7 +398,7 @@ func startBed(t *testing.T) *bed { delivery.CheckerOwedBucket: delivery.CheckerOwedHistory, delivery.PreviewsBucket: delivery.PreviewsHistory, delivery.CandidatesBucket: delivery.CandidatesHistory, delivery.ModulesBucket: delivery.ModulesHistory, delivery.ManifestsBucket: delivery.ManifestsHistory, delivery.SourceBucket: delivery.SourceHistory, delivery.PresenceBucket: delivery.PresenceHistory, delivery.HaveBucket: delivery.HaveHistory, check.TriesBucket: 1, - delivery.TurnsBucket: delivery.TurnsHistory} { + delivery.TurnsBucket: delivery.TurnsHistory, delivery.BuilderBuildsBucket: 1} { kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: uint8(history)}) if err != nil { t.Fatal(err) @@ -578,20 +578,72 @@ func (b *bed) owed(t *testing.T, number int, head, state string) delivery.Owed { // TestCheckerJudgesACandidateAndOwesItsStatus: a candidate the indexer wrote, one pull request of mesh-delivery, is fetched // merged onto the trunk the gate names, digested and tested at both levels on the node, kept, previewed; the verdict passes // with the counts of both levels and the composition not run, and the status mesh/check is owed on the head as success. -func TestCheckerJudgesACandidateAndOwesItsStatus(t *testing.T) { // N6/AC5, 1300/AC5, 1445/AC1, 1929/AC1 +func TestCheckerJudgesACandidateAndOwesItsStatus(t *testing.T) { // N6/AC5, 1300/AC5, 1445/AC1, 1929/AC1, ADR 0603 §10 b := startBed(t) - key := b.candidate(t, "feat/x", pull(repo, 7, head1)) + // ADR 0603 §10: the candidate changes the module m, whose manifest the trunk holds; the builder has not built m's + // candidate fingerprint yet, so the check waits on the builder's key before it composes. + manifest := `{"module":"m"}` + digest := delivery.ManifestDigest([]byte(manifest)) + stored, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: manifest}) + if err != nil { + t.Fatal(err) + } + mkey, _ := delivery.ManifestsKey(digest) + if _, err := b.buckets[delivery.ManifestsBucket].Put(b.ctx, mkey, stored); err != nil { + t.Fatal(err) + } + known, err := delivery.EncodeModule(delivery.Module{Repository: repo, Commit: trunk, Path: "modules/m", Manifest: digest, None2: "the fingerprint is computed with its chain"}) + if err != nil { + t.Fatal(err) + } + if _, err := b.buckets[delivery.ModulesBucket].Put(b.ctx, "m", known); err != nil { + t.Fatal(err) + } + fp := "src2:" + strings.Repeat("f", 64) + p := pull(repo, 7, head1) + p.Modules, p.Fps = []string{"m"}, map[string]delivery.CandidateModule{"m": {FP2: fp, Manifest: digest}} + key := b.candidate(t, "feat/x", p) // ADR 0593 §7, §9 (D12): n1 and n2 are placed; n1's engine answers compose-candidate on the bus, n2's never does. asked := b.engine(t, "n1", "n2") b.start(t, nil) - v := b.verdict(t, key, ended) - if v.State != delivery.VerdictPass || v.Levels[delivery.LevelUnit].Run != 2 || v.Levels[delivery.LevelIntegration].Run != 2 || v.Trunk[repo] != trunk { + b.said(t, "waits on the builder's builds of m before it composes") + if got := asked(); len(got) != 0 { + t.Fatalf("the engines were asked %+v before m's build ended", got) + } + bkey, _ := delivery.BuilderBuildKey("m", fp) + building, _ := json.Marshal(delivery.BuilderBuild{V: 1, State: delivery.BuildBuilding}) + if _, err := b.buckets[delivery.BuilderBuildsBucket].Put(b.ctx, bkey, building); err != nil { + t.Fatal(err) + } + if v := b.verdict(t, key, func(delivery.Verdict) bool { return true }); v.State != delivery.VerdictRunning || len(asked()) != 0 { + t.Fatalf("verdict %s and %d asks while m builds: the check composes only once its build ended", v.State, len(asked())) + } + // m's build fails: the check fails naming it. Built again (builder_build_again writes the key done), the check asks + // itself again for the same heads, with no recheck, and passes. + failedBuild, _ := json.Marshal(delivery.BuilderBuild{V: 1, State: delivery.BuildFailed, Error: "go build: undefined: x"}) + if _, err := b.buckets[delivery.BuilderBuildsBucket].Put(b.ctx, bkey, failedBuild); err != nil { + t.Fatal(err) + } + if v := b.verdict(t, key, ended); v.State != delivery.VerdictFail || v.Failed.Test != check.BuildFailedTest+"m" { + t.Fatalf("verdict %+v, want it failed on m's build", v) + } + built, _ := json.Marshal(delivery.BuilderBuild{V: 1, State: delivery.BuildDone, Manifest: digest}) + if _, err := b.buckets[delivery.BuilderBuildsBucket].Put(b.ctx, bkey, built); err != nil { + t.Fatal(err) + } + b.said(t, "the builds of m it failed on are done again, so it is checked again") + v := b.verdict(t, key, func(v delivery.Verdict) bool { return v.State == delivery.VerdictPass }) + // The check asked again takes the packages the failed check ran from their kept verdicts. + if v.State != delivery.VerdictPass || v.Levels[delivery.LevelUnit].Kept != 2 || v.Levels[delivery.LevelIntegration].Kept != 2 || v.Trunk[repo] != trunk { t.Fatalf("verdict = %+v", v) } if c := v.Composition; c == nil || c.State != delivery.CompositionPass || !slices.Equal(c.Machines, []string{"n1"}) || !strings.Contains(c.Words, "n2: "+delivery.ComposeNoAnswer) || len(v.Machines) != 1 || v.Machines[0] != "n1" { t.Errorf("composition %+v machines %v", v.Composition, v.Machines) } + if v.Composition != nil && strings.Contains(v.Composition.Words, "did not end") { + t.Errorf("the composition says %q: m's build ended within the bound", v.Composition.Words) + } if got := asked(); len(got) == 0 || got[0].Candidate != key { t.Errorf("n1's engine was asked %+v, want the check's candidate %s", got, key) } @@ -602,10 +654,11 @@ func TestCheckerJudgesACandidateAndOwesItsStatus(t *testing.T) { // N6/AC5, 1300 t.Errorf("the check ask named the nodes %v, want [n1] from the have's node record", names) } o := b.owed(t, 7, head1, "success") - if o.Context != delivery.StatusContextCheck || !strings.HasPrefix(o.Text, "pass 111111111111 on aaaaaaaaaaaa") || !strings.Contains(o.Text, "unit 2 run") { + if o.Context != delivery.StatusContextCheck || !strings.HasPrefix(o.Text, "pass 111111111111 on aaaaaaaaaaaa") || !strings.Contains(o.Text, "unit 0 run, 2 kept") { t.Errorf("owed = %+v", o) } - for tool, want := range map[string]int{"mesh-build-go.build_go_fetch": 1, "mesh-build-go.build_go_digest": 2, "mesh-build-go.build_go_test": 2} { + // Two checks of the same heads: the one failed on m's build and the one it asked itself again; the second runs no test. + for tool, want := range map[string]int{"mesh-build-go.build_go_fetch": 2, "mesh-build-go.build_go_digest": 4, "mesh-build-go.build_go_test": 2} { if got := b.fake.count(tool); got != want { t.Errorf("%s called %d times, want %d", tool, got, want) } diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go index c1fc816e..047e9ab0 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go @@ -8,10 +8,12 @@ import ( "sort" "strings" "sync" + "time" "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/process" @@ -31,6 +33,10 @@ type judgeDeps struct { forge check.Forge announce check.Announcer builds check.BuildStates + // ends waits on the builder's builds of a candidate before the check composes (ADR 0603 §10); nil composes without waiting. + ends check.BuildEnds + // buildWait is the setting build-wait-bound; check.DefaultBuildWait when 0. + buildWait time.Duration // compositions makes a check's composition from the engines' answers (ADR 0593 §7); nil leaves it not run. compositions check.CompositionReader // nodes names every node with a record; nil hands no names. @@ -97,7 +103,7 @@ func newJudge(d judgeDeps) check.Judge { nodes = d.nodes } return &check.Examiner{Nodes: nodes, Compositions: d.compositions, Asks: d.asks, Toolchains: d.tools, - Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Published: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Predefined: d.predefined, Logf: d.logf} + Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Published: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Predefined: d.predefined, Logf: d.logf} } // manifestKV reads a manifest by its digest. @@ -122,6 +128,42 @@ type repoModules struct { repoOf map[string]string // by module: its repository commit map[string]string // by module: the commit its record was computed at (the commit the mesh runs) typed map[string]string // by module: its manifest digest + parsed map[string]catalogue.Manifest // by manifest digest: the manifest as read, once (a digest's manifest never changes) +} + +// Manifest is the kept manifest of a digest, read from the indexer's manifests state once and then from memory. +func (r *repoModules) Manifest(ctx context.Context, digest string) (catalogue.Manifest, error) { + r.mu.Lock() + m, known := r.parsed[digest] + r.mu.Unlock() + if known { + return m, nil + } + key, err := delivery.ManifestsKey(digest) + if err != nil { + return catalogue.Manifest{}, err + } + raw, _, found, err := r.manifests.Get(ctx, key) + if err != nil { + return catalogue.Manifest{}, err + } + if !found { + return catalogue.Manifest{}, fmt.Errorf("the indexer keeps no manifest %s", digest) + } + kept, err := delivery.DecodeManifest(raw) + if err != nil { + return catalogue.Manifest{}, err + } + if m, err = parseManifest(kept.JSON); err != nil { + return catalogue.Manifest{}, err + } + r.mu.Lock() + if r.parsed == nil { + r.parsed = map[string]catalogue.Manifest{} + } + r.parsed[digest] = m + r.mu.Unlock() + return m, nil } // Put takes a change of the indexer's modules bucket in: a module's repository and the languages its manifest names. @@ -257,6 +299,69 @@ func (r buildReader) Build(ctx context.Context, module, fp2 string) (delivery.Bu return b, err == nil, err } +// buildEnds hears the builder's build keys of a candidate on the bus (mesh-delivery-builder_builds): a watch of exactly +// those keys gives each one's value now and every write the builder makes after, so the check waits on the part that owns +// the fact and polls nothing (novox/hq ADR 0603 §10). +type buildEnds struct{ kv jetstream.KeyValue } + +func (e buildEnds) Ended(ctx context.Context, builds map[string]string) (map[string]delivery.BuilderBuild, error) { + return e.until(ctx, builds, func(b delivery.BuilderBuild) bool { + return b.State == delivery.BuildDone || b.State == delivery.BuildFailed + }) +} + +func (e buildEnds) Done(ctx context.Context, builds map[string]string) bool { + done, err := e.until(ctx, builds, func(b delivery.BuilderBuild) bool { return b.State == delivery.BuildDone }) + return err == nil && len(done) == len(builds) +} + +// until hears the builds' keys until each holds a build that ended holds, or ctx ends, and answers those that do, by module. +func (e buildEnds) until(ctx context.Context, builds map[string]string, ended func(delivery.BuilderBuild) bool) (map[string]delivery.BuilderBuild, error) { + byKey := map[string]string{} + keys := make([]string, 0, len(builds)) + for module, fp2 := range builds { + key, err := delivery.BuilderBuildKey(module, fp2) + if err != nil { + return nil, err + } + byKey[key] = module + keys = append(keys, key) + } + out := map[string]delivery.BuilderBuild{} + if len(keys) == 0 { + return out, nil + } + w, err := e.kv.WatchFiltered(ctx, keys) + if err != nil { + return nil, err + } + defer func() { _ = w.Stop() }() + for len(out) < len(keys) { + select { + case <-ctx.Done(): + return out, nil // the bound: the builds not ended are left out + case entry, open := <-w.Updates(): + if !open { + return out, nil + } + if entry == nil || entry.Operation() != jetstream.KeyValuePut { + continue // the end of the values now, or a key deleted: it has not ended + } + module, ours := byKey[entry.Key()] + if !ours { + continue + } + b, err := delivery.DecodeBuilderBuild(entry.Value()) + if err == nil && ended(b) { + out[module] = b + } else { + delete(out, module) // asked again (builder_build_again), or not a build the check can read: not ended + } + } + } + return out, nil +} + // ManifestDigests is the manifest digest of every module the indexer holds, by module: the catalogue the indexer's judge // judges a change's manifests in (novox/hq ADR 0578 §2). func (r *repoModules) ManifestDigests() map[string]string { diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge_test.go index 24c64d28..0b88b7f0 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge_test.go @@ -170,4 +170,10 @@ func TestAManifestFallingBackToAPredefinedKeyIsRead(t *testing.T) { if _, err := parseManifest(manifest); err != nil { t.Errorf("manifestReader: %v", err) } + // The sandbox's composer may be an older node-engine, which knows no verdict waits: it is handed a wait as a refusal. + var held manifestVerdicts + held.put("nats", &delivery.ManifestVerdict{Module: "nats", State: delivery.VerdictManifestWaits, Unknown: []string{"core-credentials"}, Judge: "j"}) + if v := held.all()["nats"]; v.State != delivery.VerdictRefused || len(v.Problems) != 1 || len(v.Unknown) != 0 { + t.Errorf("a waiting verdict is handed to the sandbox as %+v", v) + } } diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go index ef544bcb..1cc2b977 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go @@ -166,14 +166,19 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // The builder's builds say how the candidates' fingerprints stand (issue 1684); where its bucket is not there yet the line // says nothing of the builds, until the checker starts again. var builds check.BuildStates - if kv := process.OptionalBucket(ctx, js, delivery.BuilderBuildsBucket, "the line says nothing of the builds until the checker starts again", logger.Printf); kv != nil { - builds = buildReader{kvStore{kv}} + var ends check.BuildEnds + if kv := process.OptionalBucket(ctx, js, delivery.BuilderBuildsBucket, "the line says nothing of the builds, and a check composes without waiting on them, until the checker starts again", logger.Printf); kv != nil { + builds, ends = buildReader{kvStore{kv}}, buildEnds{kv} + } + if ends != nil { + ch.Rebuilds = ends // a check failed on a build is asked again once that build is done again (ADR 0603 §10) } // The check's composition is the checker's, from what each placed node's engine answers (ADR 0593 §7, §9). - answerBound, err := composeAnswerBound(getenv("MESH_CHECKER_SETTINGS")) + set, err := readSettings(getenv("MESH_CHECKER_SETTINGS")) if err != nil { return err } + answerBound := set.answer holdCallsFor(answerBound) verdicts := &manifestVerdicts{} compositions := check.EngineCompositions{ @@ -212,7 +217,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro ch.Judge = newJudge(judgeDeps{ asks: buildrun.Asker{Publisher: asks, Waiter: waiter, Bound: stepBound}, tools: tools, trunks: trunks, kept: kvStore{buckets[delivery.CheckerKeptBucket]}, previews: kvStore{buckets[delivery.PreviewsBucket]}, - repos: repos, published: published, builds: builds, forge: forgeReads{call: caller.Call}, logf: logger.Printf, + repos: repos, published: published, builds: builds, ends: ends, buildWait: set.buildWait, forge: forgeReads{call: caller.Call}, logf: logger.Printf, announce: announce, compositions: compositions, nodes: openNodeNames(ctx, js, logger.Printf), predefined: keptPredefined{process.AwaitBucket(ctx, js, conn, delivery.IndexerModule+"_"+delivery.IndexerPredefinedState, diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/settings.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/settings.go index c3c44afd..19d895f3 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/settings.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/settings.go @@ -10,38 +10,57 @@ import ( "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/process" + + "mesh-delivery-checker/internal/check" ) // The checker's own settings (its config, written by the node-engine into ${dir:state}/settings.json, named to the // process by MESH_CHECKER_SETTINGS): compose-answer-bound, the seconds a check waits for a node's engine to answer -// compose-candidate (novox/hq ADR 0593 §7, §9: 60 by default; the checker owns it). +// compose-candidate (novox/hq ADR 0593 §7, §9: 60 by default), and build-wait-bound, the seconds a check waits for the +// builder's builds of its candidate before it composes (ADR 0603 §10: 1800 by default). The checker owns both. -// composeAnswerBound reads compose-answer-bound from the settings file at path: delivery.ComposeAnswerBound where no -// file or no value is there; a value that is no number of seconds from 1 to 600 is refused, naming it. -func composeAnswerBound(path string) (time.Duration, error) { +// settings are the checker's settings as read. +type settings struct { + // answer is compose-answer-bound; buildWait is build-wait-bound. + answer, buildWait time.Duration +} + +// readSettings reads the settings file at path: each value's default where no file or no value is there; a value that is +// no whole number of seconds within its range is refused, naming it. +func readSettings(path string) (settings, error) { + out := settings{answer: delivery.ComposeAnswerBound, buildWait: check.DefaultBuildWait} if path == "" { - return delivery.ComposeAnswerBound, nil + return out, nil } raw, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { - return delivery.ComposeAnswerBound, nil + return out, nil } if err != nil { - return 0, err + return settings{}, err } var s struct { - Bound *float64 `json:"compose-answer-bound"` + Answer *float64 `json:"compose-answer-bound"` + BuildWait *float64 `json:"build-wait-bound"` } if err := json.Unmarshal(raw, &s); err != nil { - return 0, fmt.Errorf("%s is no settings file: %w", path, err) + return settings{}, fmt.Errorf("%s is no settings file: %w", path, err) } - if s.Bound == nil { - return delivery.ComposeAnswerBound, nil + for _, v := range []struct { + name string + value *float64 + min, max float64 + into *time.Duration + }{{"compose-answer-bound", s.Answer, 1, 600, &out.answer}, {"build-wait-bound", s.BuildWait, 60, 7200, &out.buildWait}} { + if v.value == nil { + continue + } + if *v.value < v.min || *v.value > v.max || *v.value != float64(int(*v.value)) { + return settings{}, fmt.Errorf("%s is %v: a whole number of seconds from %v to %v", v.name, *v.value, v.min, v.max) + } + *v.into = time.Duration(*v.value) * time.Second } - if *s.Bound < 1 || *s.Bound > 600 || *s.Bound != float64(int(*s.Bound)) { - return 0, fmt.Errorf("compose-answer-bound is %v: a whole number of seconds from 1 to 600", *s.Bound) - } - return time.Duration(*s.Bound) * time.Second, nil + return out, nil } // holdCallsFor makes a bus call wait the compose-answer-bound (novox/hq ADR 0593 §7), not the SDK's CallTimeout of 30 s: diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/settings_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/settings_test.go index ad9a9863..02e1cc14 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/settings_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/settings_test.go @@ -8,19 +8,25 @@ import ( "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/process" + + "mesh-delivery-checker/internal/check" ) // ADR 0593 §7: the answer bound is the checker's setting compose-answer-bound, 60 s when the file or the value is -// absent; a value that is no whole number of seconds from 1 to 600 stops the start, naming it. +// absent; a value that is no whole number of seconds from 1 to 600 stops the start, naming it. ADR 0603 §10: the wait for +// the candidate's builds is build-wait-bound, 30 minutes when absent, from 60 to 7200 seconds. func TestTheAnswerBoundIsTheCheckersSetting(t *testing.T) { dir := t.TempDir() for _, row := range []struct { - body string - want time.Duration - err bool - }{{"", delivery.ComposeAnswerBound, false}, {`{}`, delivery.ComposeAnswerBound, false}, - {`{"compose-answer-bound": 90}`, 90 * time.Second, false}, {`{"compose-answer-bound": 0}`, 0, true}, - {`{"compose-answer-bound": 1.5}`, 0, true}} { + body string + want time.Duration + err bool + buildWait time.Duration + }{{"", delivery.ComposeAnswerBound, false, check.DefaultBuildWait}, {`{}`, delivery.ComposeAnswerBound, false, check.DefaultBuildWait}, + {`{"compose-answer-bound": 90}`, 90 * time.Second, false, check.DefaultBuildWait}, {`{"compose-answer-bound": 0}`, 0, true, 0}, + {`{"compose-answer-bound": 1.5}`, 0, true, 0}, + {`{"compose-answer-bound": 60, "build-wait-bound": 600}`, 60 * time.Second, false, 10 * time.Minute}, + {`{"build-wait-bound": 30}`, 0, true, 0}} { path := filepath.Join(dir, "settings.json") os.Remove(path) if row.body != "" { @@ -28,9 +34,10 @@ func TestTheAnswerBoundIsTheCheckersSetting(t *testing.T) { t.Fatal(err) } } - got, err := composeAnswerBound(path) - if (err != nil) != row.err || got != row.want { - t.Errorf("%q: %s, %v", row.body, got, err) + set, err := readSettings(path) + got := set.answer + if (err != nil) != row.err || got != row.want || set.buildWait != row.buildWait { + t.Errorf("%q: %s, %s, %v", row.body, got, set.buildWait, err) } // Issue 1126: the SDK's call gives up at CallTimeout (30 s), so the checker's calls must wait the bound read // (an answer at 31 s is heard under 60 s), and the bound never shortens the SDK's own. diff --git a/modules/mesh-delivery-checker/files/settings.json b/modules/mesh-delivery-checker/files/settings.json index b9a19671..c6d811e8 100644 --- a/modules/mesh-delivery-checker/files/settings.json +++ b/modules/mesh-delivery-checker/files/settings.json @@ -1,3 +1,4 @@ { - "compose-answer-bound": 60 + "compose-answer-bound": 60, + "build-wait-bound": 1800 } diff --git a/modules/mesh-delivery-checker/go.mod b/modules/mesh-delivery-checker/go.mod index 7024be45..2927751e 100644 --- a/modules/mesh-delivery-checker/go.mod +++ b/modules/mesh-delivery-checker/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-checker go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) @@ -25,3 +25,7 @@ require ( ) replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun => ../mesh-delivery-builder/buildrun + +require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk diff --git a/modules/mesh-delivery-checker/go.sum b/modules/mesh-delivery-checker/go.sum index 340e467e..8518fa28 100644 --- a/modules/mesh-delivery-checker/go.sum +++ b/modules/mesh-delivery-checker/go.sum @@ -1,7 +1,7 @@ git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a h1:oxgksCiq272/afXbUION/dxtAS5MPGnmyq6+AZQxmXg= git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a/go.mod h1:kpsX4Pgss5EeuBvel1zWa2ogj7MkR3jZso6oa/J0YUQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-checker/internal/check/build_change.go b/modules/mesh-delivery-checker/internal/check/build_change.go new file mode 100644 index 00000000..d22339e4 --- /dev/null +++ b/modules/mesh-delivery-checker/internal/check/build_change.go @@ -0,0 +1,111 @@ +package check + +import ( + "context" + "fmt" + "maps" + "slices" + "strings" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-sdk/go/catalogue" + + "git.novox.be/novox/mesh-delivery/sdk/graph" +) + +// What the check builds of a candidate before it composes (novox/hq ADR 0603 §10). The builder builds every fingerprint the +// indexer's candidate carries, as a key of its own (builds..), with the build modules that run; the +// check adds no build path of its own and waits on those keys (awaitBuilds), so the node-engines compose the candidate's +// modules with their builds. It does not wait on two kinds of changed module, and names them in its composition's warnings: +// +// - one built by a build module the change alters (the recipe of mesh-build-go or mesh-build-node enters the fingerprint +// of every module it builds, ADR 0530 §1): its candidate build is the running build module's, and the check cannot run +// the candidate's build module to build it (it runs it only for its own digest and tests, ADR 0535 §1), so it is not +// composed with a build of the changed build module; it is built again once the agreed toolchain moves (ADR 0603 §3). +// The build module's own build is waited on, as any other: the running build module builds its new source. +// - one that stands on a module the change alters (build.on): its candidate fingerprint names the base's trunk +// fingerprint, and no build module builds on another module's artifact (ADR 0487 §1), so its build stands on the +// trunk's base; the base's own build is waited on. +// +// A module the trunk holds no record of yet is not built by the builder until it is merged, so it is not waited on either. + +// buildsOfTheCheck are the builds the check waits on before it composes (module to fingerprint) and the words for the +// changed modules it composes without waiting on their build, one line each, sorted. A manifest that cannot be read leaves +// every build waited on and nothing said of build modules or bases (nothing is said that was not read). +func (st *state) buildsOfTheCheck(ctx context.Context) (map[string]string, []string) { + wait := map[string]string{} + changed := map[string]catalogue.Manifest{} + readable := true + for _, p := range st.c.Pulls { + for module, cm := range p.Fps { + if cm.FP2 == "" || cm.Manifest == "" { + continue // the builder builds no fingerprint of it (candidates.go: none of the new form, or no manifest) + } + wait[module] = cm.FP2 + if !readable || st.j.Read == nil { + readable = false + continue + } + m, err := st.j.Read.Manifest(ctx, cm.Manifest) + if err != nil { + st.j.logf("%s: the manifest of %s cannot be read, so the modules built with a changed build module or base are not named and every build is waited on: %v", st.c.Key, module, err) + readable = false + continue + } + changed[module] = m + } + } + var words []string + if st.j.Runs != nil { + var unheld []string + for _, module := range slices.Sorted(maps.Keys(wait)) { + if st.j.Runs.CommitOf(module) == "" { + unheld = append(unheld, module) + delete(wait, module) + } + } + if len(unheld) > 0 { + words = append(words, fmt.Sprintf("%s: the trunk holds no record of it yet, so the builder builds it once it is merged and the check composes it without a build (ADR 0603 §10)", + strings.Join(unheld, ", "))) + } + } + if !readable { + return wait, words + } + for _, bm := range slices.Sorted(maps.Keys(buildrun.LanguageOf)) { + if _, alters := changed[bm]; !alters { + continue + } + var built []string + for module, m := range changed { + if _, isBuildModule := buildrun.LanguageOf[module]; !isBuildModule && buildsWith(m, buildrun.LanguageOf[bm]) { + built = append(built, module) + delete(wait, module) + } + } + slices.Sort(built) + if len(built) > 0 { + words = append(words, fmt.Sprintf("the change alters the build module %s: %s, which it builds, are built by the running %s, not the changed one, which the check cannot run, so the check does not wait on their builds; they are built again once the agreed toolchain moves (ADR 0603 §3, §10)", + bm, strings.Join(built, ", "), bm)) + } + } + g := graph.OfManifests(changed) + for _, base := range slices.Sorted(maps.Keys(changed)) { + if on := g.Dependents(base); len(on) > 0 { + for _, module := range on { + delete(wait, module) + } + words = append(words, fmt.Sprintf("the change alters %s, a base of %s: their builds stand on the trunk's %s, since no build module builds on a candidate's base, so the check waits on the build of %s alone (ADR 0603 §10)", + base, strings.Join(on, ", "), base, base)) + } + } + return wait, words +} + +// buildsWith says whether a manifest has a bundle in the language: the artifacts whose fingerprint names that language's +// toolchain and its build module's recipe (an archive is packed with no toolchain, ADR 0478 clause 1). +func buildsWith(m catalogue.Manifest, language string) bool { + return m.Build != nil && slices.ContainsFunc(m.Build.Artifacts, func(a catalogue.Artifact) bool { + return a.Kind == catalogue.ArtifactBundle && a.Language == language + }) +} diff --git a/modules/mesh-delivery-checker/internal/check/builds.go b/modules/mesh-delivery-checker/internal/check/builds.go index f70115fb..2afa2d42 100644 --- a/modules/mesh-delivery-checker/internal/check/builds.go +++ b/modules/mesh-delivery-checker/internal/check/builds.go @@ -2,7 +2,12 @@ package check import ( "context" + "fmt" + "maps" + "slices" "sort" + "strings" + "time" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -64,3 +69,102 @@ func (j *Examiner) buildNotes(ctx context.Context, c *Case) []BuildNote { } return notes } + +// DefaultBuildWait is how long a check waits for the candidate's builds to end before it composes (the setting +// build-wait-bound, novox/hq ADR 0603 §10): past it the check composes without the builds not ended and names them. +const DefaultBuildWait = 30 * time.Minute + +// BuildEnds waits on the builder's build keys (builds..), hearing each key's changes on the bus as the +// builder writes them: the builder owns the fact, and nothing is polled. +type BuildEnds interface { + // Ended waits until the build of every module at its fingerprint (module to fingerprint) is done or failed, or ctx ends, + // and answers the builds that ended, by module. An error is only builds that cannot be heard at all. + Ended(ctx context.Context, builds map[string]string) (map[string]delivery.BuilderBuild, error) + // Done waits until the build of every module at its fingerprint is done (a failed one built again, builder_build_again), + // and says whether they all are; false when ctx ends first or they cannot be heard. + Done(ctx context.Context, builds map[string]string) bool +} + +// BuildFailedTest is what a failure of the check names a failed build of the candidate by: the test `build: `. +// A verdict failed on builds is asked again by the checker itself once each is built again (Checker.Rebuilt). +const BuildFailedTest = "build: " + +// failedBuilds are the candidate's builds a failed verdict names as failed (BuildFailedTest), by module, at the fingerprint +// the case carries for it; none for a verdict that names no failed build. +func failedBuilds(v delivery.Verdict, cs *Case) map[string]string { + if v.State != delivery.VerdictFail { + return nil + } + out := map[string]string{} + for _, f := range append([]delivery.FailedFirst{v.Failed}, v.Failures...) { + module, named := strings.CutPrefix(f.Test, BuildFailedTest) + if !named { + continue + } + for _, p := range cs.Pulls { + if cm, ok := p.Fps[module]; ok && cm.FP2 != "" { + out[module] = cm.FP2 + } + } + } + if len(out) == 0 { + return nil + } + return out +} + +func (j *Examiner) buildWait() time.Duration { + if j.BuildWait > 0 { + return j.BuildWait + } + return DefaultBuildWait +} + +// awaitBuilds waits for the candidate's builds before the check composes (ADR 0603 §10): the check builds what the builder +// builds of the candidate and composes with it. A failed build fails the check, naming the module and the builder's words; +// a build not ended within the bound is named, and the check composes without it. It answers the words of the composition's +// warnings: what the check composes without waiting on its build, and why. +func (st *state) awaitBuilds(ctx context.Context, v *delivery.Verdict) []string { + wait, words := st.buildsOfTheCheck(ctx) + if len(wait) == 0 { + return words + } + modules := slices.Sorted(maps.Keys(wait)) + if v.State == delivery.VerdictFail { + // The check has failed already: its red status is never held back by a build (the lead, 2026-11-03). + return append(words, fmt.Sprintf("the check failed before its builds were waited on, so it composes at once, without waiting on the builds of %s (ADR 0603 §10)", strings.Join(modules, ", "))) + } + if st.j.BuildEnds == nil { + return append(words, fmt.Sprintf("the builds of %s cannot be heard, so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", "))) + } + bound := st.j.buildWait() + waits := fmt.Sprintf("waits on the builder's builds of %s before it composes, at most %s (ADR 0603 §10)", strings.Join(modules, ", "), bound) + st.c.note(waits) + st.j.logf("%s %s", st.c.Key, waits) + at, cancel := context.WithTimeout(ctx, bound) + ended, err := st.j.BuildEnds.Ended(at, wait) + cancel() + if err != nil { + st.j.logf("%s: the builds of %s cannot be heard: %v", st.c.Key, strings.Join(modules, ", "), err) + return append(words, fmt.Sprintf("the builds of %s cannot be heard (%v), so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", "), err)) + } + var late []string + for _, module := range modules { + b, done := ended[module] + switch { + case !done: + late = append(late, module) + case b.State == delivery.BuildFailed: + f := delivery.FailedFirst{Test: BuildFailedTest + module, Words: fmt.Sprintf("the candidate's build at %s failed (rebuild with builder_build_again; the check asks itself again when the build is done): %s", wait[module], b.Error)} + if v.State != delivery.VerdictFail { + v.State, v.Failed = delivery.VerdictFail, f + continue + } + addFailure(v, f) + } + } + if len(late) > 0 && ctx.Err() == nil { + words = append(words, fmt.Sprintf("the builds of %s did not end within the build-wait-bound of %s, so the check composes without them (ADR 0603 §10)", strings.Join(late, ", "), bound)) + } + return words +} diff --git a/modules/mesh-delivery-checker/internal/check/checker.go b/modules/mesh-delivery-checker/internal/check/checker.go index 3fbffddb..4e8e91bc 100644 --- a/modules/mesh-delivery-checker/internal/check/checker.go +++ b/modules/mesh-delivery-checker/internal/check/checker.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "maps" + "slices" "sort" "strings" "sync" @@ -79,6 +81,9 @@ type Checker struct { } // Bound is how long one check may run; none when 0 (DefaultBound). Bound time.Duration + // Rebuilds hears the builder's build keys a failed verdict names (failedBuilds): once each is written done, the check of + // those heads is asked again by the checker itself (ADR 0603 §10), as a failure of the nodes is; nil asks none again. + Rebuilds BuildEnds // Asks keeps the asks each running check put, by verdict key (AsksState), for checker_ask_again; nil keeps none. Asks AsksKV // Link is the base of the address a status points at; the verdict key follows. @@ -104,6 +109,8 @@ type Checker struct { held map[string]string // by verdict key: the turn that holds its check off, as last said live map[string]liveCheck // by verdict key: the check running in this process, ended when its heads go settling map[string]*window // by candidates key: the window its heads settle in + rebuilt map[string]bool // by verdict key: every failed build its verdict names is done again + awaiting map[string]bool // by verdict key: its failed builds are heard (awaitRebuilt) // outsideSaid are the candidates outside the mesh said in the log, by group and heads. outsideSaid map[string]bool } @@ -162,6 +169,7 @@ func (ch *Checker) init() { ch.known, ch.inflight, ch.again, ch.tried = map[string]delivery.Candidate{}, map[string]bool{}, map[string]bool{}, map[string]bool{} ch.nodesFail, ch.held = map[string]uint64{}, map[string]string{} ch.live, ch.settling = map[string]liveCheck{}, map[string]*window{} + ch.rebuilt, ch.awaiting = map[string]bool{}, map[string]bool{} ch.outsideSaid = map[string]bool{} }) } @@ -380,8 +388,9 @@ func (ch *Checker) consider(ctx context.Context, key string, c delivery.Candidat // Every check is asked at once: the build seats' streams hold the backlog, a new head's check and a check after a // failure of the nodes on check, a re-judge after a trunk move on recheck, pulled after them (ADR 0525 §4, §9; // ADR 0536 §4). The checker holds no place and no capacity of its own. - ch.prepare(ctx, cs) + ch.prepare(ctx, key, cs) ch.try(ctx, run, key, cs) + ch.hearFailedBuilds(ctx, key, cs) // a check it failed on a build is asked again once that build is done again ch.mu.Lock() more := ch.again[vkey] delete(ch.again, vkey) @@ -518,7 +527,7 @@ const ( // or sibling commits that moved since is carried, its packages run only where the move changed their digest, and a pass's // commits are named as the carry's CarriedFrom. A verdict that stands is ranked with the carries: its claim finds nothing // to do. -func (ch *Checker) prepare(ctx context.Context, cs *Case) { +func (ch *Checker) prepare(ctx context.Context, group string, cs *Case) { cs.Kind, cs.CarriedFrom, cs.Composition = KindNew, nil, nil value, _, found, err := ch.Verdicts.Get(ctx, cs.Key) if err != nil || !found { @@ -531,6 +540,10 @@ func (ch *Checker) prepare(ctx context.Context, cs *Case) { case IsNodesFailure(v): cs.Kind = KindNodes return + case failedBuilds(v, cs) != nil: + cs.Kind = KindNodes // asked again on check once its builds are done again (awaitRebuilt), never by a recheck + ch.awaitRebuilt(ctx, group, cs.Key, failedBuilds(v, cs)) + return case IsConflictFailure(v): return // a head git refused: its next check is whole (ADR 0548 §7) case IsEnded(v), IsUntrunked(v): @@ -687,6 +700,8 @@ func (ch *Checker) claim(ctx context.Context, cs *Case, first bool, epoch uint64 return nil, nil case IsEnded(cur), ch.stale(cur, cs): retake = true // heads held again after their check was ended unjudged (issue 1890), or a trunk that moved + case cur.State == delivery.VerdictFail && ch.takeRebuilt(cs.Key): + retake = true // every failed build it names is done again (ADR 0603 §10) case cur.State == delivery.VerdictFail && IsNodesFailure(cur): ch.mu.Lock() at, ok := ch.nodesFail[cs.Key] @@ -1070,3 +1085,61 @@ func (ch *Checker) movedSince(cs *Case) bool { } return false } + +// awaitRebuilt hears, once per verdict key, the builder's keys of the builds a failed verdict names: when each is written +// done again (builder_build_again), the check of those heads is asked again (claim retakes it), as a failure of the nodes +// is after the nodes changed, so no recheck by hand is needed (ADR 0603 §10). Heard on the builder's own writes, never +// polled; it ends with the checker. +func (ch *Checker) awaitRebuilt(ctx context.Context, group, vkey string, builds map[string]string) { + if ch.Rebuilds == nil { + return + } + ch.mu.Lock() + if ch.awaiting[vkey] { + ch.mu.Unlock() + return + } + ch.awaiting[vkey] = true + ch.mu.Unlock() + ch.wg.Add(1) + go func() { + defer ch.wg.Done() + done := ch.Rebuilds.Done(ctx, builds) + ch.mu.Lock() + delete(ch.awaiting, vkey) + if done { + ch.rebuilt[vkey] = true + } + c, known := ch.known[group] + ch.mu.Unlock() + if done && known && ctx.Err() == nil { + ch.logf("%s: the builds of %s it failed on are done again, so it is checked again (ADR 0603 §10)", vkey, strings.Join(slices.Sorted(maps.Keys(builds)), ", ")) + ch.consider(ctx, group, c) + } + }() +} + +// hearFailedBuilds starts awaitRebuilt for the case's verdict when it names failed builds (failedBuilds). +func (ch *Checker) hearFailedBuilds(ctx context.Context, group string, cs *Case) { + if ch.Rebuilds == nil || ctx.Err() != nil { + return + } + value, _, found, err := ch.Verdicts.Get(ctx, cs.Key) + if err != nil || !found { + return + } + if v, err := delivery.DecodeVerdict(value); err == nil { + if builds := failedBuilds(v, cs); builds != nil { + ch.awaitRebuilt(ctx, group, cs.Key, builds) + } + } +} + +// takeRebuilt says whether every failed build the verdict of vkey names is done again, and forgets it: one retake each. +func (ch *Checker) takeRebuilt(vkey string) bool { + ch.mu.Lock() + defer ch.mu.Unlock() + was := ch.rebuilt[vkey] + delete(ch.rebuilt, vkey) + return was +} diff --git a/modules/mesh-delivery-checker/internal/check/composition_test.go b/modules/mesh-delivery-checker/internal/check/composition_test.go index 5e3e6d4e..72ec4ecb 100644 --- a/modules/mesh-delivery-checker/internal/check/composition_test.go +++ b/modules/mesh-delivery-checker/internal/check/composition_test.go @@ -3,9 +3,12 @@ package check import ( "context" "errors" + "maps" + "slices" "strings" "testing" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -30,6 +33,7 @@ func (f *fakeCompositions) Compose(ctx context.Context, c *Case) (delivery.Compo // 1297/AC3 (its unit half): a check whose tests pass takes the assigner's composition of it, by the check's own key, // into its verdict: a pass says it passed with the mesh-sdk and machines it composed, and is no longer "not run". func TestAPassingCheckTakesTheAssignersComposition(t *testing.T) { + t.Run("a change to a build module or base names the modules built on it", aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt) b := newBench(t) f := &fakeCompositions{v: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.CompositionPass, SDK: "v0.1.14-x", Machines: []string{"g14"}, Took: 40}} b.ex.Compositions = f @@ -99,3 +103,148 @@ func TestACompositionNotHadIsNotRun(t *testing.T) { type fixedTrunks struct{ heads map[string]string } func (f fixedTrunks) Heads() map[string]string { return f.heads } + +// fakeEnds answers the builder's builds of a candidate as fakeBuilds holds them, recording the builds waited on; the +// composition must not have been asked yet when it is (ADR 0603 §10: the check composes with the builds). +type fakeEnds struct { + builds fakeBuilds + asked map[string]string + composed *fakeCompositions + early bool +} + +func (f *fakeEnds) Ended(_ context.Context, builds map[string]string) (map[string]delivery.BuilderBuild, error) { + f.asked = builds + f.early = f.composed != nil && len(f.composed.asked) > 0 + out := map[string]delivery.BuilderBuild{} + for module, fp := range builds { + if b, ok := f.builds[module+" "+fp]; ok && (b.State == delivery.BuildDone || b.State == delivery.BuildFailed) { + out[module] = b + } + } + return out, nil +} + +func (f *fakeEnds) Done(context.Context, map[string]string) bool { return false } + +// knownRuns are the modules the trunk holds a record of (the indexer's modules): any commit. +type knownRuns []string + +func (k knownRuns) CommitOf(module string) string { + if slices.Contains(k, module) { + return trunkA + } + return "" +} + +// ADR 0603 §10: before it composes, the check waits on the builder's builds of the candidate's changed modules and composes +// with them; a failed build fails the check naming it, and one not ended within the bound is named. It does not wait on a +// module built by a build module the change alters, nor on one standing on a base the change alters, nor on one the trunk +// holds no record of, and names each in the composition's warnings (and so the check's line); a change that alters none of +// these says nothing. +func aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) { + dig := func(c string) string { return strings.Repeat(c, 64) } + fp := "src2:" + dig("f") + goBuild := &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "bin", Kind: catalogue.ArtifactBundle, Language: "go"}}} + tsBuild := &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "app", Kind: catalogue.ArtifactBundle, Language: "typescript"}}} + done := delivery.BuilderBuild{State: delivery.BuildDone} + for _, row := range []struct { + name string + manifest map[string]catalogue.Manifest + builds map[string]delivery.BuilderBuild // by module, at fp; nil leaves the check without the builds' ends + runs knownRuns // nil: the trunk holds every module + want []string + waited string // the modules waited on, sorted + failed string // the failure's test, when the check fails on a build + tests bool // the tests fail: the check fails before it would wait + }{ + {name: "the Go build module changes", + manifest: map[string]catalogue.Manifest{"mesh-build-go": {Module: "mesh-build-go", Build: goBuild}, "web": {Module: "web", Build: goBuild}, + "mail": {Module: "mail", Build: goBuild}, "docs": {Module: "docs"}}, + builds: map[string]delivery.BuilderBuild{"mesh-build-go": done, "docs": done}, + want: []string{"the build module mesh-build-go: mail, web, which it builds"}, waited: "docs, mesh-build-go"}, + {name: "the Node build module changes", + manifest: map[string]catalogue.Manifest{"mesh-build-node": {Module: "mesh-build-node", Build: goBuild}, "panel": {Module: "panel", Build: tsBuild}, + "web": {Module: "web", Build: goBuild}}, + builds: map[string]delivery.BuilderBuild{"mesh-build-node": done, "web": done}, + want: []string{"the build module mesh-build-node: panel, which it builds"}, waited: "mesh-build-node, web"}, + {name: "a base changes", + manifest: map[string]catalogue.Manifest{"image": {Module: "image"}, + "web": {Module: "web", Build: &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "BASE", Module: "image", Artifact: "img"}}}}}, + builds: map[string]delivery.BuilderBuild{"image": done}, + want: []string{"alters image, a base of web", "waits on the build of image alone"}, waited: "image"}, + {name: "neither changes", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}}, + builds: map[string]delivery.BuilderBuild{"web": done}, waited: "web"}, + {name: "a build fails", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}, "mail": {Module: "mail", Build: goBuild}}, + builds: map[string]delivery.BuilderBuild{"web": {State: delivery.BuildFailed, Error: "go build: undefined: x"}, "mail": done}, + waited: "mail, web", failed: "build: web"}, + {name: "the tests fail", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}}, + builds: map[string]delivery.BuilderBuild{"web": {State: delivery.BuildBuilding}}, tests: true, + want: []string{"composes at once, without waiting on the builds of web"}}, + {name: "a build does not end within the bound", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}}, + builds: map[string]delivery.BuilderBuild{"web": {State: delivery.BuildBuilding}}, + want: []string{"the builds of web did not end within the build-wait-bound"}, waited: "web"}, + {name: "a module the trunk does not hold", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}, "new": {Module: "new", Build: goBuild}}, + builds: map[string]delivery.BuilderBuild{"web": done}, runs: knownRuns{"web"}, + want: []string{"new: the trunk holds no record of it yet"}, waited: "web"}, + {name: "the builds cannot be heard", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}}, + want: []string{"the builds of web cannot be heard"}}, + } { + b := candidateBench(t, true) + if row.tests { + b.test = func(c stepCall) (any, error) { + r := captured[Tested](t, "go-test-failing") + r.Level, _ = c.args["level"].(string) + return r, nil + } + } + comps := &fakeCompositions{v: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.CompositionPass, SDK: "x", Machines: []string{"g14"}}} + b.ex.Compositions = comps + var ends *fakeEnds + if row.builds != nil { + ends = &fakeEnds{builds: fakeBuilds{}, composed: comps} + for module, build := range row.builds { + ends.builds[module+" "+fp] = build + } + b.ex.BuildEnds = ends + } + if row.runs != nil { + b.ex.Runs = row.runs + } + read, fps := fixedManifests{}, map[string]delivery.CandidateModule{} + for module, m := range row.manifest { + d := dig(string(rune('a' + len(read)))) + read[d], fps[module] = m, delivery.CandidateModule{FP2: fp, Manifest: d} + } + b.ex.Read = read + p := pullOf(catalogueRepository, 9, head1) + p.Fps, p.Modules = fps, slices.Sorted(maps.Keys(fps)) + v := b.judge(t, caseOf(t, p)).Verdict + if v.Composition == nil || len(comps.asked) != 1 { + t.Fatalf("%s: composed %d times: %+v", row.name, len(comps.asked), v) + } + if ends != nil { + if got := strings.Join(slices.Sorted(maps.Keys(ends.asked)), ", "); got != row.waited || ends.early { + t.Errorf("%s: waited on %q (after the composition: %v), want %q before it composes", row.name, got, ends.early, row.waited) + } + } + switch { + case row.failed != "" && (v.State != delivery.VerdictFail || v.Failed.Test != row.failed || !strings.Contains(v.Failed.Words, "undefined: x") || + !strings.Contains(v.Failed.Words, "the check asks itself again when the build is done")): + t.Errorf("%s: the verdict is %s failing %+v, want it failed naming %s, the builder's words and how it is asked again", row.name, v.State, v.Failed, row.failed) + case row.tests && v.State != delivery.VerdictFail: + t.Errorf("%s: %+v, want the tests' failure", row.name, v) + case row.failed == "" && !row.tests && v.State != delivery.VerdictPass: + t.Errorf("%s: %+v", row.name, v) + } + words := compositionWords(v.Composition, v.Took.Composition) + for _, w := range row.want { + if !strings.Contains(words, w) { + t.Errorf("%s: the line says %q, want it to name %q", row.name, words, w) + } + } + if row.want == nil && v.Composition.Words != "" { + t.Errorf("%s: warned %q though the check waited on every build of the change", row.name, v.Composition.Words) + } + } +} diff --git a/modules/mesh-delivery-checker/internal/check/engines.go b/modules/mesh-delivery-checker/internal/check/engines.go index 64c8579b..2e077789 100644 --- a/modules/mesh-delivery-checker/internal/check/engines.go +++ b/modules/mesh-delivery-checker/internal/check/engines.go @@ -147,6 +147,12 @@ func JudgeEngines(group, heads, composerName string, nodes []string, answers map v.Warnings = append(v.Warnings, node+": "+delivery.ComposeNoAnswer) continue } + if a.Verdict == delivery.ComposeCandidateNotJudged { + // Composed in the sandbox and not judged (ADR 0593 §9): named as no answer, with the composer's words; decided by the + // verdict, never by the words (ADR 0601 §4's kind). + v.Warnings = append(v.Warnings, node+": "+delivery.ComposeNoAnswer+" ("+a.Refused+")") + continue + } v.Machines = append(v.Machines, node) if a.Verdict != delivery.ComposeCandidateRefused { continue @@ -221,8 +227,11 @@ func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node s return a, fmt.Errorf("the composer at the node-engine's head %s did not answer for %s: %w", head, node, err) } out := delivery.ComposeCandidateAnswer{V: a.V, Node: node, Candidate: a.Candidate, Verdict: delivery.ComposeCandidateComposed} + notJudged := "" for _, cv := range verdict.Cases { - if m, ok := cv.Machines[node]; ok && !m.Composes { + if m, ok := cv.Machines[node]; ok && m.NotJudged != "" { + notJudged = m.NotJudged // the composer did not judge the node: neither composed nor refused (ADR 0593 §9) + } else if ok && !m.Composes { out.Verdict = delivery.ComposeCandidateRefused out.Refused = strings.Join(append(append([]string{m.Reason}, m.Problems...), m.Refusals...), "; ") } @@ -234,5 +243,8 @@ func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node s } } sort.Slice(out.Modules, func(i, j int) bool { return out.Modules[i].Module < out.Modules[j].Module }) + if notJudged != "" && out.Verdict == delivery.ComposeCandidateComposed { + out.Verdict, out.Refused = delivery.ComposeCandidateNotJudged, notJudged + } return out, nil } diff --git a/modules/mesh-delivery-checker/internal/check/engines_test.go b/modules/mesh-delivery-checker/internal/check/engines_test.go index 52e98c68..fed2774f 100644 --- a/modules/mesh-delivery-checker/internal/check/engines_test.go +++ b/modules/mesh-delivery-checker/internal/check/engines_test.go @@ -168,6 +168,25 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { if v.State != delivery.CompositionFail || v.SDK != head2 || !strings.Contains(v.Words, "the new composer refuses it") { t.Errorf("verdict = %+v", v) } + // ADR 0593 §9 (ADR 0601 §4's kind): a node the composer did not judge is named as no answer with its words, by the + // contract's field, and the check passes; words in Reason alone are no such mark. + sb.err = nil + for _, row := range []struct { + m composer.Machine + pass bool + }{ + {composer.Machine{NotJudged: "its node-engine sends no running state for web"}, true}, + {composer.Machine{Reason: "not judged: its node-engine sends no running state for web"}, false}, + } { + sb.v = func(node string) composer.Verdict { + return composer.Verdict{V: composer.Version, Cases: []composer.CaseVerdict{{Name: "change", Machines: map[string]composer.Machine{node: row.m}}}} + } + v, err = ec.Compose(context.Background(), host) + named := len(v.Warnings) == 1 && strings.Contains(v.Warnings[0], "anchor: "+delivery.ComposeNoAnswer+" (its node-engine sends no running state for web)") + if err != nil || (v.State == delivery.CompositionPass) != row.pass || named != row.pass { + t.Errorf("%+v: verdict %+v, %v", row.m, v, err) + } + } // Review finding 3 (ADR 0593 §9 lets only a node that does not answer pass): a composer at the head that cannot // run fails the composition, naming the head, never passing as a silent node. sb.err = errors.New("the build of mesh-compose failed") diff --git a/modules/mesh-delivery-checker/internal/check/examiner_test.go b/modules/mesh-delivery-checker/internal/check/examiner_test.go index 6e5b4734..7da5b258 100644 --- a/modules/mesh-delivery-checker/internal/check/examiner_test.go +++ b/modules/mesh-delivery-checker/internal/check/examiner_test.go @@ -20,6 +20,7 @@ import ( gobuild "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/composer" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -1000,6 +1001,17 @@ func (k keptList) PredefinedList(context.Context) (json.RawMessage, error) { return json.RawMessage(k), nil } +// fixedManifests are the indexer's kept manifests by digest as a test gives them. +type fixedManifests map[string]catalogue.Manifest + +func (f fixedManifests) Manifest(_ context.Context, digest string) (catalogue.Manifest, error) { + m, ok := f[digest] + if !ok { + return catalogue.Manifest{}, errors.New("no manifest " + digest) + } + return m, nil +} + // fixedDigests are the indexer's manifest digests as a test gives them. type fixedDigests map[string]string @@ -1017,17 +1029,33 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { // untouched is the judge's refusal of mail, which the change leaves as it is, and trunkMail its refusal on the trunk: // a problem the trunk had is a warning, one the change causes fails (ADR 0579 §2) untouched, trunkMail string + // cycle reads the manifests, so the change's module graph is worked out (ADR 0603 §2): the change's web awaits + // mail's site while mail awaits web's database; trunkCycle has the trunk's web await it too. + cycle, trunkCycle bool }{ {name: "accepted"}, {name: "an untouched manifest's problem the trunk had", untouched: "mail says no credential form", trunkMail: "mail says no credential form"}, {name: "a problem the change causes in an untouched manifest", untouched: "mail reads a state web no longer keeps", fails: "refuses the manifest of mail: mail reads a state web no longer keeps"}, + {name: "a cycle the change makes", cycle: true, fails: "the change's module graph has the cycle mail -> web -> mail"}, + {name: "a cycle the trunk has", cycle: true, trunkCycle: true}, {name: "refused", refused: "web requires \"amqp\"\nweb has no id", fails: "the indexer's judge at mesh-delivery's trunk " + strings.Repeat("d", 40) + ` refuses the manifest of web: web requires "amqp"; web has no id`}, } { b := newBench(t) b.ex.Manifests = fixedDigests{"mail": strings.Repeat("a", 64), "web": strings.Repeat("b", 64)} b.ex.Trunks = fixedTrunks{heads: map[string]string{DeliveryRepository: trunk}} b.ex.Predefined = keptList(`{"font-size": {"type": "number", "description": "the size"}}`) + if row.cycle { + offer := func(name string) []catalogue.Offer { + return []catalogue.Offer{{Name: name, Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}} + } + trunkWeb := catalogue.Manifest{Module: "web", Provides: offer("database")} + if row.trunkCycle { + trunkWeb.Requires = []string{"site"} + } + b.ex.Read = fixedManifests{strings.Repeat("a", 64): {Module: "mail", Provides: offer("site"), Requires: []string{"database"}}, + strings.Repeat("b", 64): trunkWeb, web: {Module: "web", Provides: offer("database"), Requires: []string{"site"}}} + } var judged []delivery.ComposeJob answer := b.asks.Answer b.asks.Answer = func(a delivery.StepAsk) delivery.Step { diff --git a/modules/mesh-delivery-checker/internal/check/graph_cycle.go b/modules/mesh-delivery-checker/internal/check/graph_cycle.go new file mode 100644 index 00000000..673ff548 --- /dev/null +++ b/modules/mesh-delivery-checker/internal/check/graph_cycle.go @@ -0,0 +1,74 @@ +package check + +import ( + "context" + "fmt" + "maps" + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/composer" + "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" +) + +// The check refuses a change whose module graph has a cycle and names it (novox/hq ADR 0603 §2): modules that would each +// wait for the other for good, through their bases or the provisions they await. The graph is worked out from the +// catalogue as the change leaves it (every trunk manifest, each one a pull request touches replaced by its candidate's), +// with the contract's ManifestGraph, the way the indexer works out its graph's edges. A cycle the trunk already has among +// the same modules is the trunk's: said in the verdict's notes, not the change's failure. + +// ManifestReader reads a kept manifest by its digest (the indexer's manifests state). +type ManifestReader interface { + Manifest(ctx context.Context, digest string) (catalogue.Manifest, error) +} + +// refuseCycle fails the check for a cycle of the change's module graph, naming it; nil when the case touches no manifest, +// when nothing reads manifests, or when there is none. A manifest that cannot be read fails the check, said, never passes it. +func (st *state) refuseCycle(ctx context.Context) *delivery.Verdict { + if st.j.Manifests == nil || st.j.Read == nil || st.c.Try != nil { + return nil + } + trunk := st.j.Manifests.ManifestDigests() + req, touched := judgeRequest(trunk, st.c.Pulls) + if len(touched) == 0 { + return nil + } + read := func(digests map[string]composer.Manifest) (map[string]catalogue.Manifest, error) { + out := map[string]catalogue.Manifest{} + for _, module := range slices.Sorted(maps.Keys(digests)) { + m, err := st.j.Read.Manifest(ctx, digests[module].Digest) + if err != nil { + return nil, fmt.Errorf("the manifest %s of %s: %w", digests[module].Digest, module, err) + } + out[module] = m + } + return out, nil + } + change, err := read(req.Cases[0].Manifests) + if err != nil { + return fail(goModule, "the change's module graph cannot be worked out, so no cycle can be ruled out: "+err.Error()) + } + cycle := graph.OfManifests(change).Cycle() + if cycle == nil { + st.c.note("the change's module graph has no cycle") + return nil + } + words := strings.Join(cycle, " -> ") + if before, err := read(req.Cases[1].Manifests); err == nil { + among := graph.Graph{} + for name, n := range graph.OfManifests(before) { + if slices.Contains(cycle, name) { + among[name] = n + } + } + if among.Cycle() != nil { + st.c.note("the module graph has the cycle " + words + ", which the trunk already has among the same modules (ADR 0603 §2)") + return nil + } + } + return fail(goModule, fmt.Sprintf("the change's module graph has the cycle %s: these modules would each wait for the other for "+ + "good, through their bases or the provisions they await (ADR 0603 §2)", words)) +} diff --git a/modules/mesh-delivery-checker/internal/check/judge.go b/modules/mesh-delivery-checker/internal/check/judge.go index b561a1fb..cb77a6e5 100644 --- a/modules/mesh-delivery-checker/internal/check/judge.go +++ b/modules/mesh-delivery-checker/internal/check/judge.go @@ -102,6 +102,11 @@ type Examiner struct { // Builds says how the build of a fingerprint the candidate carries stands (the builder's builds bucket); nil says nothing // of the builds. Builds BuildStates + // BuildEnds waits on the builder's builds of the candidate before the check composes (ADR 0603 §10); nil composes + // without waiting, said in the composition's warnings. + BuildEnds BuildEnds + // BuildWait is how long the check waits for those builds (the setting build-wait-bound); DefaultBuildWait when 0. + BuildWait time.Duration // Announce says a preview was written under the key (EventPreviewWritten); nil says nothing. Announce Announcer // Packages names, of a repository, the paths (repository-relative) its TypeScript package artifacts are built from, from @@ -114,6 +119,9 @@ type Examiner struct { // Manifests are the indexer's manifest digests by module: the catalogue a change's manifests are judged in by the // indexer's judge (manifests_judge.go, novox/hq ADR 0578 §2); nil judges no manifest. Manifests TrunkManifests + // Read reads a kept manifest by its digest: the change's module graph is worked out from them (graph_cycle.go, novox/hq + // ADR 0603 §2); nil works out no graph. + Read ManifestReader // Predefined reads the catalogue's predefined keys the indexer keeps (manifests_judge.go); nil reads none. Predefined PredefinedList // Compositions makes a check's composition (ADR 0593 §7): the engines' answers (EngineCompositions); nil leaves the @@ -242,7 +250,7 @@ func (j *Examiner) Judge(ctx context.Context, c *Case) (Outcome, error) { // Every member and level runs to its end, and every manifest the change touches is judged by the indexer's judge (ADR // 0578 §2), also after a failure: the verdict names every failure, not only the first (the operator, 2026-11-02). tests := st.judgeAll(ctx) - if v := settled(joinFailures(tests, st.judgeManifests(ctx))); v != nil { + if v := settled(joinFailures(tests, st.judgeManifests(ctx), st.refuseCycle(ctx))); v != nil { return st.finish(ctx, *v), nil } return st.finish(ctx, delivery.Verdict{State: delivery.VerdictPass}), nil @@ -277,6 +285,7 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) { strings.Join(st.unpreviewed, ", ") + " (its head does not merge or its fetch did not answer), so the change cannot be composed"} return } + words := st.awaitBuilds(ctx, v) // ADR 0603 §10: the check composes with the candidate's builds comp, err := st.j.Compositions.Compose(ctx, st.c) if err != nil { // Said in the verdict, never left as a composition not yet run (review finding 4). @@ -284,6 +293,7 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) { v.Composition = &delivery.Composition{State: delivery.CompositionNotRun, Words: "not made: " + err.Error()} return } + comp.Warnings = append(comp.Warnings, words...) // ADR 0603 §10 withComposition(v, comp) if v.Composition != nil && v.Composition.State != delivery.CompositionNotRun { v.Composition.FPSet = st.c.FPSet // a carry keeps it while the fingerprints stay the same (ADR 0548 §5) diff --git a/modules/mesh-delivery-checker/internal/check/read.go b/modules/mesh-delivery-checker/internal/check/read.go index 90e57f28..1fc0efbe 100644 --- a/modules/mesh-delivery-checker/internal/check/read.go +++ b/modules/mesh-delivery-checker/internal/check/read.go @@ -3,6 +3,7 @@ package check import ( "encoding/json" "fmt" + "slices" "sort" "strings" "time" @@ -47,6 +48,9 @@ type Reader struct { Runs interface{ CommitOf(module string) string } // HearingState is the checker's state hearing: whether it hears DEAD_LETTERS (issue 734). HearingState StateRead + // BuildState is the builder's builds (mesh-delivery-builder.builds): the builds of a running check's candidate that have + // not ended, which the check waits on before it composes (ADR 0603 §10); without it none is named. + BuildState StateRead // AskState is the checker's state asks: the asks each running check put (AsksState), which checker_ask_again reads. AskState StateRead Now func() time.Time @@ -344,6 +348,10 @@ type Status struct { // Lost are the candidates whose verdict is running while no step of theirs can still run and their newest step ended, by // group, with that step: nothing will judge them (the lost check of 2026-10-28); checker_ask_again asks one again. Lost map[string]string `json:"lost"` + // BuildsNotEnded are, of each candidate whose check runs, the builder's builds of its changed modules that are neither + // done nor failed, by group: ` `, sorted (no key yet is `not begun`). A check waits on them before it + // composes (ADR 0603 §10), but for the modules its line names as composed without them; builder_build_again builds a failed one again, and the check asks itself again when it is done. + BuildsNotEnded map[string]string `json:"builds_not_ended,omitempty"` // DeadLetters says whether the checker hears the asks the build seats give up on (issue 734): deaf, a try or check // whose ask is given up waits until its bound. DeadLetters DeadLettersHeard `json:"dead_letters"` @@ -500,6 +508,12 @@ func (r Reader) Status() (Status, error) { if why := read.lostWhy(c); why != "" { st.Lost[c.Group] = why } + if words := r.buildsNotEnded(c); words != "" { + if st.BuildsNotEnded == nil { + st.BuildsNotEnded = map[string]string{} + } + st.BuildsNotEnded[c.Group] = words + } case IsNodesFailure(v): st.Waiting[KindNodes] = append(st.Waiting[KindNodes], c.Group) case IsUntrunked(v): @@ -663,3 +677,38 @@ func (r Reader) leftWhy(module string, step delivery.Step, now time.Time, bound } return "" } + +// buildsNotEnded are the builder's builds of the candidate's changed modules that are neither done nor failed, as +// Status.BuildsNotEnded says them; empty when none, or when the builder's builds are not readable here. +func (r Reader) buildsNotEnded(c delivery.Candidate) string { + if r.BuildState.Get == nil { + return "" + } + var out []string + for _, p := range c.Pulls { + for module, cm := range p.Fps { + if cm.FP2 == "" || cm.Manifest == "" { + continue + } + key, err := delivery.BuilderBuildKey(module, cm.FP2) + if err != nil { + continue + } + raw, err := r.BuildState.Get(key) + if err != nil { + continue + } + state := "not begun" + if len(raw) > 0 { + b, err := delivery.DecodeBuilderBuild(raw) + if err != nil || b.State == delivery.BuildDone || b.State == delivery.BuildFailed { + continue + } + state = b.State + } + out = append(out, module+" "+state) + } + } + sort.Strings(out) + return strings.Join(slices.Compact(out), ", ") +} diff --git a/modules/mesh-delivery-checker/internal/check/read_test.go b/modules/mesh-delivery-checker/internal/check/read_test.go index 9a866222..033ba635 100644 --- a/modules/mesh-delivery-checker/internal/check/read_test.go +++ b/modules/mesh-delivery-checker/internal/check/read_test.go @@ -147,10 +147,24 @@ func TestTheStatusToolReadsRunningSteps(t *testing.T) { passed, _ := delivery.EncodeStep(delivery.Step{ID: "abc2", Tool: "build_go_test", Node: "ace", State: delivery.StepPassed}) cand := candidateOf(t, "feat/x", pullOf(repoA, 7, head1)) ckey, _ := delivery.CandidatesKey("feat/x") + // ADR 0603 §10: feat/y's check runs and waits on the builds of its changed modules: web building, mail not begun; api + // is done and named nowhere. + fp, man := "src2:"+strings.Repeat("f", 64), strings.Repeat("e", 64) + py := pullOf(repoA, 8, head2) + py.Modules, py.Fps = []string{"api", "mail", "web"}, map[string]delivery.CandidateModule{"web": {FP2: fp, Manifest: man}, "mail": {FP2: fp, Manifest: man}, "api": {FP2: fp, Manifest: man}} + candY := candidateOf(t, "feat/y", py) + ykey, _ := delivery.CandidatesKey("feat/y") + runningV, _ := delivery.EncodeVerdict(delivery.Verdict{Group: "feat/y", Heads: "0123456789ab", State: delivery.VerdictRunning}) + builds := fakeState{} + for module, state := range map[string]string{"web": delivery.BuildBuilding, "api": delivery.BuildDone} { + k, _ := delivery.BuilderBuildKey(module, fp) + builds[k], _ = json.Marshal(delivery.BuilderBuild{V: 1, State: state}) + } rd := Reader{ StepStates: map[string]StateRead{"mesh-build-go": fakeState{"shanks.abc1": running, "ace.abc2": passed}.read()}, - CandidateState: fakeState{ckey: cand}.read(), - VerdictState: fakeState{}.read(), + CandidateState: fakeState{ckey: cand, ykey: candY}.read(), + VerdictState: fakeState{keyOf(t, "feat/y", candY): runningV}.read(), + BuildState: builds.read(), HaveState: fakeState{"shanks.mesh-build-go": haveOf(t, delivery.PhaseRunning)}.read(), HearingState: fakeState{"dead-letters": []byte(`{"heard":false,"streams":["SEAT_NODE_BUILD_GO"],` + `"why":"DEAD_LETTERS is not heard, so an ask a build seat gives up on is waited for until its bound: SEAT_NODE_BUILD_GO: permissions violation",` + @@ -167,6 +181,9 @@ func TestTheStatusToolReadsRunningSteps(t *testing.T) { if len(st.Unjudged) != 1 || st.Unjudged[0] != "feat/x" { t.Errorf("unjudged = %v", st.Unjudged) } + if got := st.BuildsNotEnded; len(got) != 1 || got["feat/y"] != "mail not begun, web building" { + t.Errorf("builds not ended = %v, want feat/y's mail not begun and web building", got) + } // Issue 734, review round 1: a checker that does not hear DEAD_LETTERS says so in its status, with why. if st.DeadLetters.Heard || !strings.Contains(st.DeadLetters.Why, "permissions violation") { t.Errorf("dead letters = %+v, want not heard, saying why", st.DeadLetters) diff --git a/modules/mesh-delivery-checker/manifest_test.go b/modules/mesh-delivery-checker/manifest_test.go index 7d995a3d..6a379ebe 100644 --- a/modules/mesh-delivery-checker/manifest_test.go +++ b/modules/mesh-delivery-checker/manifest_test.go @@ -174,13 +174,16 @@ func TestManifestProcess(t *testing.T) { // 1678/AC1 if len(env) != 2 || env["MESH_BROKER_FILE"] != "${dir:state}/broker" || env["MESH_CHECKER_SETTINGS"] != "${dir:state}/settings.json" { t.Errorf("env = %v, want the broker file and the settings file", p["env"]) } - // Its settings hold its answer bound alone (ADR 0593 §7: compose-answer-bound, 60 s, the checker's), never a capacity - // of its own (ADR 0525 §4). + // Its settings hold its answer bound (ADR 0593 §7: compose-answer-bound, 60 s, the checker's) and its wait for the + // candidate's builds (ADR 0603 §10: build-wait-bound, 1800 s), never a capacity of its own (ADR 0525 §4). for _, r := range m.Resources { - if r["id"] == "settings" && !reflect.DeepEqual(r["takes-config"], []any{"compose-answer-bound"}) { - t.Errorf("the settings resource %v takes more than compose-answer-bound", r) + if r["id"] == "settings" && !reflect.DeepEqual(r["takes-config"], []any{"compose-answer-bound", "build-wait-bound"}) { + t.Errorf("the settings resource %v takes other than compose-answer-bound and build-wait-bound", r) } } + if c, ok := m.Config["build-wait-bound"]; !ok || c.Default != float64(1800) || c.Trusted { + t.Errorf("build-wait-bound = %+v, want 1800 by default, not trusted", c) + } if c, ok := m.Config["compose-answer-bound"]; !ok || c.Default != float64(60) || c.Trusted { t.Errorf("compose-answer-bound = %+v, want 60 by default, not trusted", c) } diff --git a/modules/mesh-delivery-checker/module.json b/modules/mesh-delivery-checker/module.json index 767884cb..537551f1 100644 --- a/modules/mesh-delivery-checker/module.json +++ b/modules/mesh-delivery-checker/module.json @@ -81,6 +81,14 @@ "maximum": 600, "trusted": false, "default": 60 + }, + "build-wait-bound": { + "type": "number", + "description": "seconds a check waits for the builder's builds of its candidate to end before it composes with them (novox/hq ADR 0603 §10); a build failed fails the check, naming it, and a build not ended by then is named in the verdict and the check composes without it.", + "minimum": 60, + "maximum": 7200, + "trusted": false, + "default": 1800 } }, "resources": [ @@ -106,7 +114,8 @@ "mode": "0600", "owner": "mesh-delivery-checker", "takes-config": [ - "compose-answer-bound" + "compose-answer-bound", + "build-wait-bound" ], "merge": "json", "template": "files/settings.json" diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go index deb2a428..c8bb3b00 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go @@ -74,10 +74,6 @@ func handle(ctx context.Context, m delivered, pub publisher, trees bridge.Trees, logger.Printf("%v; heard again in %s", err, retryDelay) _ = m.NakWithDelay(retryDelay) return - case errors.Is(err, bridge.ErrNoModules): - logger.Printf("%v; no merged message", err) - _ = m.Ack() - return case errors.Is(err, bridge.ErrNotHandled): logger.Printf("%v; nothing written", err) _ = m.Ack() diff --git a/modules/mesh-delivery-forge-bridge/go.mod b/modules/mesh-delivery-forge-bridge/go.mod index 94aeabd2..c3e1e343 100644 --- a/modules/mesh-delivery-forge-bridge/go.mod +++ b/modules/mesh-delivery-forge-bridge/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-forge-bridge go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-forge-bridge/go.sum b/modules/mesh-delivery-forge-bridge/go.sum index 43cc4ec7..abad2969 100644 --- a/modules/mesh-delivery-forge-bridge/go.sum +++ b/modules/mesh-delivery-forge-bridge/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-forge-bridge/internal/bridge/bridge.go b/modules/mesh-delivery-forge-bridge/internal/bridge/bridge.go index c8cb1a32..774bc6b1 100644 --- a/modules/mesh-delivery-forge-bridge/internal/bridge/bridge.go +++ b/modules/mesh-delivery-forge-bridge/internal/bridge/bridge.go @@ -29,9 +29,6 @@ var ErrNotHandled = errors.New("event not handled") // heard again later. var ErrRetry = errors.New("the forge could not say") -// ErrNoModules says a merge into the trunk left a repository with no module.json anywhere: no merged message. -var ErrNoModules = errors.New("no module.json in the merge") - // Trunk is the branch a merged message is written for. const Trunk = "main" @@ -100,7 +97,9 @@ func unreadable(subject, why string, args ...any) error { // Convert turns one event of the forge module, by its bus subject and body, into a message. An event it cannot read // is an ErrUnreadable error naming the subject and why; an event it writes nothing for is ErrNotHandled. A merge into // the trunk asks trees once and becomes a merged message; a merge into any other branch is a closed pull message, as the -// pull request left its group. A forge that cannot say is ErrRetry; a merge with no module.json is ErrNoModules. +// pull request left its group. A forge that cannot say is ErrRetry. A merge that leaves no module.json anywhere is a merged message with no +// trees all the same (issue 1126): the indexer takes it in and drops the repository's modules, and the view reads the +// repository as reached at it. func Convert(subject string, data []byte, trees Trees, files Files) (Message, error) { return ConvertKept(subject, data, trees, files, nil) } @@ -294,9 +293,6 @@ func merged(subject string, p pull, trees Trees, files Files, kept Kept) (Messag if err != nil { return Message{}, fmt.Errorf("%w: %s: the trees of %s at the merge commit %s: %v", ErrRetry, subject, repository, p.MergeSHA, err) } - if len(dirs) == 0 { - return Message{}, fmt.Errorf("%w: %s: %s at the merge commit %s", ErrNoModules, subject, repository, p.MergeSHA) - } names := make([]string, 0, len(dirs)) for dir := range dirs { names = append(names, dir) @@ -339,7 +335,11 @@ func merged(subject string, p pull, trees Trees, files Files, kept Kept) (Messag if err != nil { return Message{}, unreadable(subject, "%v", err) } - return Message{Event: event, Data: body}, nil + msg := Message{Event: event, Data: body} + if len(dirs) == 0 { + msg.Said = fmt.Sprintf("%s at the merge commit %s holds no module.json: a merged message with no module directories", repository, p.MergeSHA) + } + return msg, nil } // openPull is an entry of gitea_open_pulls's answer (mesh-catalog modules/gitea/client.ts:369-381 openPulls, with the diff --git a/modules/mesh-delivery-forge-bridge/internal/bridge/bridge_test.go b/modules/mesh-delivery-forge-bridge/internal/bridge/bridge_test.go index 95367380..3bd1116f 100644 --- a/modules/mesh-delivery-forge-bridge/internal/bridge/bridge_test.go +++ b/modules/mesh-delivery-forge-bridge/internal/bridge/bridge_test.go @@ -269,11 +269,19 @@ func TestTreesRefusedWritesNothingAndSaysWhy(t *testing.T) { // 1236/AC3 (the co } } -func TestNoModuleJSONAnywhereWritesNoMergedMessage(t *testing.T) { // 1236 (notes: a repository with no module.json) +// 1126 (note 94): a merge that leaves no module.json anywhere (hq, or a repository whose last module was removed) is a merged +// message with no trees, so the indexer takes it in and the repository's reach is not held for ever. +func TestNoModuleJSONAnywhereWritesAMergedMessageWithNoTrees(t *testing.T) { var calls []call msg, err := Convert(mergedSubject, mergedBody(nil), treesOf(&calls, "r0", map[string]string{}, nil), anyFiles) - if !errors.Is(err, ErrNoModules) || msg.Data != nil { - t.Errorf("err = %v data = %s", err, msg.Data) + if err != nil { + t.Fatal(err) + } + f, err := delivery.DecodeForge(msg.Data) + want := delivery.Forge{V: delivery.Version, Kind: delivery.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, + Head: head, Merge: merge, Root: "r0"} + if err != nil || msg.Event != "forge.novox.mesh-delivery.merged" || !delivery.SameForge(f, want) || !strings.Contains(msg.Said, "no module.json") { + t.Errorf("event %q message %+v said %q, %v; want a merged message with no trees", msg.Event, f, msg.Said, err) } } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go new file mode 100644 index 00000000..62df17fb --- /dev/null +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go @@ -0,0 +1,103 @@ +package main + +import ( + "fmt" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" + + "mesh-delivery-indexer/internal/index" +) + +// CatalogTools are the seat mesh-catalog's four verbs as tools named ., with the descriptions and inputs the +// seat's contract gives them (novox/hq ADR 0604 §1, §2), answered from the indexer's own states. They are served once the +// indexer's manifest claims the seat, in the release after L3's (the lead's decision): until then main serves them not. +func CatalogTools(c index.CatalogRead) []stdio.Tool { + seat, ok := catalogue.SeatNamed(catalogue.CatalogSeat) + if !ok { + return nil + } + str := func(args map[string]any, k string) string { s, _ := args[k].(string); return s } + runs := map[string]func(map[string]any) (any, error){ + "modules": func(map[string]any) (any, error) { return c.CatalogModulesAnswer() }, + "module": func(a map[string]any) (any, error) { return c.CatalogModuleAnswer(str(a, "module")) }, + "provides": func(a map[string]any) (any, error) { return c.CatalogProvidesAnswer(str(a, "provision")) }, + "dependents": func(a map[string]any) (any, error) { return c.CatalogDependentsAnswer(str(a, "module")) }, + } + var out []stdio.Tool + for _, v := range seat.Serves { + if run, ok := runs[v.Name]; ok { + out = append(out, stdio.Tool{Name: catalogue.CatalogSeat + "." + v.Name, Description: v.Description, Input: v.Input, Run: run}) + } + } + return out +} + +// catalogRead reads the indexer's modules, manifests and graph states for the seat's verbs. +func catalogRead(modules, manifests, nodes stdio.KeptState) index.CatalogRead { + return index.CatalogRead{ + Modules: func() (map[string]delivery.Module, error) { + keys, err := modules.Keys() + if err != nil { + return nil, err + } + out := map[string]delivery.Module{} + for _, k := range keys { + e, err := modules.Get(k) + if err != nil { + return nil, fmt.Errorf("modules.%s: %w", k, err) + } + if e == nil { + continue + } + if m, err := delivery.DecodeModule(e.Value); err == nil { + out[k] = m + } + } + return out, nil + }, + Manifest: func(digest string) (string, error) { + key, err := delivery.ManifestsKey(digest) + if err != nil { + return "", err + } + e, err := manifests.Get(key) + if err != nil { + return "", err + } + if e == nil { + return "", fmt.Errorf("the manifests state holds no %s", digest) + } + kept, err := delivery.DecodeManifest(e.Value) + return kept.JSON, err + }, + Graph: func() (graph.Graph, error) { + keys, err := nodes.Keys() + if err != nil { + return nil, err + } + g := graph.Graph{} + for _, k := range keys { + e, err := nodes.Get(k) + if err != nil { + return nil, fmt.Errorf("graph.%s: %w", k, err) + } + if e == nil { + continue + } + if n, err := graph.Decode(e.Value); err == nil { + g[k] = n + } + } + return g, nil + }, + } +} + +// catalogTools are the seat's verbs over the indexer's own states, as main serves them once the indexer claims the seat. +func catalogTools() []stdio.Tool { + return CatalogTools(catalogRead(stdio.State("modules"), stdio.State("manifests"), stdio.State(graph.State))) +} diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go index 5529d02f..cfc4ff71 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go @@ -1,6 +1,7 @@ // Command mesh-delivery-indexer-tools serves the indexer's doing tool (novox/hq issue 1945): the runtime launches it for a // call. indexer_take_in writes one value of the indexer's takeins state and waits for the indexer's answer there. The -// indexer is the other program: it serves no request subject (ADR 0437 §4). The read tools of the indexer's states are +// indexer is the other program: it serves no request subject (ADR 0437 §4); indexer_judge asks the same way, and +// indexer_verdicts reads the indexer's verdicts state. The read tools of the indexer's states are // mesh-delivery-view's (indexer_module, indexer_modules, indexer_candidate, indexer_candidates). package main @@ -21,8 +22,8 @@ import ( // asker writes a takeins value and waits for the indexer's answer to it. type asker func(key string, value []byte) (index.TakeInAsk, error) -// Tools are the indexer's tools. -func Tools(ask asker, now func() time.Time, counts func() (index.OldSectionsCount, error)) []stdio.Tool { +// Tools are the indexer's tools. read is the indexer's state of verdicts, which indexer_verdicts reads; counts the old sections' count. +func Tools(ask asker, now func() time.Time, read stdio.KeptState, counts func() (index.OldSectionsCount, error)) []stdio.Tool { str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) } return []stdio.Tool{ {Name: index.OldSectionsTool, @@ -47,12 +48,68 @@ func Tools(ask asker, now func() time.Time, counts func() (index.OldSectionsCoun "indexer) is read again at that head and written when it changed. Answers stored, from which " + "repository, commit and path, or why not (held already, no registration, the forge's error, no manifest there). " + "The indexer reads such a module again by itself on the next source revision and merged message; this asks now. " + - "indexer_module (mesh-delivery-view) reads the result.", + "indexer_module (mesh-delivery-view) reads the result. With repository instead of module it takes in the head the gate's " + + "source names for that repository, for a merge whose merged message the indexer never heard (the forge bridge has no " + + "tool): a head whose trunk holds no module.json is taken in as a merge that leaves none (every module of the repository " + + "is dropped, one another repository's trunk holds handed over from there first, listed in handed and dropped); a head " + + "that holds modules is answered \"take each module in by name\".", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "module": map[string]any{"type": "string", "description": "the module's name, as the catalogue registers it"}, + "repository": map[string]any{"type": "string", "description": "instead of module: the repository (owner/name) whose head is taken in"}, + "why": map[string]any{"type": "string", "description": "why it is taken in now, in plain words"}}}, + Run: func(args map[string]any) (any, error) { + var key string + var value []byte + var err error + if str(args, "repository") != "" && str(args, "module") == "" { + key, value, err = index.NewTakeInRepository(str(args, "repository"), str(args, "why"), stdio.CallerOf(args), now()) + } else { + key, value, err = index.NewTakeIn(str(args, "module"), str(args, "why"), stdio.CallerOf(args), now()) + } + if err != nil { + return nil, err + } + a, err := ask(key, value) + if err != nil { + return nil, err + } + return a.Result, nil + }}, + {Name: index.VerdictsTool, + Description: "The indexer's verdicts on manifests that wait or are refused (all: every verdict), each as its verdicts state holds it: " + + "the module, the digest, the state (waits: the manifest carries a field the indexer's judge does not know yet, named in " + + "unknown, and every reader holds the module as a refused one and keeps what runs; refused: the judge's problems), the " + + "judge that made it and when. A module's name narrows it to that module. A waiting manifest is judged again at each " + + "new indexer and refused only once the newest build of the indexer on the trunk still does not know the field; " + + "indexer_judge judges now.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "module": map[string]any{"type": "string", "description": "only this module's verdicts"}, + "all": map[string]any{"type": "boolean", "description": "list accepted verdicts too"}}}, + Run: func(args map[string]any) (any, error) { + keys, err := read.Keys() + if err != nil { + return nil, fmt.Errorf("the verdicts state cannot be listed: %w", err) + } + all, _ := args["all"].(bool) + rows, unread := index.ListVerdicts(keys, func(key string) ([]byte, error) { + e, err := read.Get(key) + if err != nil || e == nil { + return nil, err + } + return e.Value, nil + }, str(args, "module"), all) + return map[string]any{"verdicts": rows, "unread": unread}, nil + }}, + {Name: index.JudgeAgainTool, + Description: "Judge the manifests again now, as the indexer does at its start and after each merge, and answer how the module's " + + "verdict stands (waits, refused or accepted, and why). For a manifest that waits for a judge that knows its fields, or " + + "after a manifest was mended where the indexer has not heard it. A waiting manifest becomes refused here only when the " + + "running indexer is the newest build of itself the trunk holds. indexer_verdicts reads the result.", Input: map[string]any{"type": "object", "required": []string{"module"}, "properties": map[string]any{ "module": map[string]any{"type": "string", "description": "the module's name, as the catalogue registers it"}, - "why": map[string]any{"type": "string", "description": "why it is taken in now, in plain words"}}}, + "why": map[string]any{"type": "string", "description": "why it is judged again now, in plain words"}}}, Run: func(args map[string]any) (any, error) { - key, value, err := index.NewTakeIn(str(args, "module"), str(args, "why"), stdio.CallerOf(args), now()) + key, value, err := index.NewJudgeAgain(str(args, "module"), str(args, "why"), stdio.CallerOf(args), now()) if err != nil { return nil, err } @@ -133,7 +190,7 @@ func heldCounts(s stdio.KeptState) func() (index.OldSectionsCount, error) { } func main() { - if err := stdio.Serve("", Tools(askTakeIn(stdio.State(index.TakeInsState), 25*time.Second), time.Now, heldCounts(stdio.State(index.OldSectionsState)))); err != nil { + if err := stdio.Serve("", Tools(askTakeIn(stdio.State(index.TakeInsState), 25*time.Second), time.Now, stdio.State(index.VerdictsState), heldCounts(stdio.State(index.OldSectionsState)))); err != nil { fmt.Fprintf(os.Stderr, "[mesh-delivery-indexer-tools] %v\n", err) os.Exit(1) } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go index 5685dc01..a28b8db5 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go @@ -229,7 +229,7 @@ func TestIndexerWritesModulesAtAMerge(t *testing.T) { // 1239/AC9; I1, I3: the m // its inputs and the reason it has no fingerprint of the new form, and manifests. with the manifest byte for byte. // // And it judges them (novox/hq ADR 0578, issue 1585): with three manifests more, one valid, one refused and one with a -// field the decoder does not know, mesh-sdk's bus reader read before the indexer judged composes all three, the field set +// field the decoder does not know (it waits, ADR 0601 §3), mesh-sdk's bus reader read before the indexer judged composes all three, the field set // aside and named (a missing verdict is accepted, §3); the indexer writes the three verdicts, says manifest-refused for // each refusal, and the reader then composes the valid one and holds the others, named, never ending its read. func TestIndexerKeepsManifestsAtAMerge(t *testing.T) { // 1444/AC4, 1449/AC4, 1585 @@ -300,7 +300,7 @@ func TestIndexerKeepsManifestsAtAMerge(t *testing.T) { // 1444/AC4, 1449/AC4, 15 if err != nil { t.Fatal(err) } - want := map[string]string{"good": delivery.VerdictAccepted, "bad": delivery.VerdictRefused, "later": delivery.VerdictRefused} + want := map[string]string{"good": delivery.VerdictAccepted, "bad": delivery.VerdictRefused, "later": delivery.VerdictManifestWaits} got := map[string]delivery.ManifestVerdict{} for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && len(got) < len(want); time.Sleep(50 * time.Millisecond) { for name := range want { @@ -316,10 +316,12 @@ func TestIndexerKeepsManifestsAtAMerge(t *testing.T) { // 1444/AC4, 1449/AC4, 15 t.Errorf("the verdict on %s = %+v within 10 s, want %s by the running judge", name, v, state) } } - if !strings.Contains(strings.Join(got["bad"].Problems, " "), `provides "amqp"`) || !strings.Contains(strings.Join(got["later"].Problems, " "), "a-field-from-later") { - t.Errorf("the refusals name no problem: %+v", got) + // ADR 0601 §3: the field this judge does not know waits and is named, since no node-engine answers which build of the + // indexer runs here (the unit tests of the index package prove it refused once that build is the trunk's). + if !strings.Contains(strings.Join(got["bad"].Problems, " "), `provides "amqp"`) || strings.Join(got["later"].Unknown, " ") != "a-field-from-later" { + t.Errorf("the refusal names no problem or the wait no field: %+v", got) } - // manifest-refused, said once for each refusal. + // manifest-refused, said once for each refusal (a wait is no refusal). events, err := js.Stream(ctx, "INDEXER_EVENTS") if err != nil { t.Fatal(err) @@ -335,8 +337,8 @@ func TestIndexerKeepsManifestsAtAMerge(t *testing.T) { // 1444/AC4, 1449/AC4, 15 said[r.Module] = true } } - if !said["bad"] || !said["later"] || said["good"] { - t.Errorf("manifest-refused said for %v, want bad and later", said) + if !said["bad"] || said["later"] || said["good"] { + t.Errorf("manifest-refused said for %v, want bad only", said) } // How a composer holds a refused module by these verdicts is the node-engine's to test (ADR 0593 §7: the composer // and its bus reader are its own). diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go index ee6e8f37..0d896889 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go @@ -26,6 +26,8 @@ import ( "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" "git.novox.be/novox/mesh-sdk/go/process" "mesh-delivery-indexer/internal/index" @@ -299,11 +301,14 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // How many modules use each old section (issue 1966 D4): a state this delivery brings, bound when it appears; until then // the judge refuses no use of an old section. oldSections := process.AwaitBucket(ctx, js, conn, index.OldSectionsBucket, "the count of the old sections is not kept, so the judge refuses no use of one", logger.Printf) + // The module graph (ADR 0603 §2): a state this delivery brings, bound when it appears; until then no graph is written. + graph := process.AwaitBucket(ctx, js, conn, graph.Bucket, "no module graph is written", logger.Printf) ix := &index.Indexer{OldSections: awaitedStore{oldSections}, Store: kvStore{candidates}, Modules: kvStore{modules}, Manifests: kvStore{manifests}, Taken: awaitedStore{taken}, Builds: known, Verdicts: awaitedStore{verdicts}, SayRefused: sayRefused(js, cred, logger.Printf), SayAccepted: sayAccepted(js, cred, logger.Printf), Predefined: awaitedStore{predefined}, ConfigCandidates: awaitedStore{configCandidates}, Logf: logger.Printf, Named: namedVia(calls.Call), Trunk: trunkVia(calls.Call), Contains: containsVia(calls.Call), Read: readVia(calls.Call), - ReadOpen: openVia(calls.Call), Packages: awaitedStore{packages}, Files: filesVia(calls.Call), GoSum: goSumVia(calls.Call), GoMod: goModVia(calls.Call)} + ReadOpen: openVia(calls.Call), Packages: awaitedStore{packages}, Files: filesVia(calls.Call), GoSum: goSumVia(calls.Call), GoMod: goModVia(calls.Call), + OwnBuild: ownBuildVia(calls.Call, cred.Node), Graph: awaitedStore{graph}} // The log's entries at the start are read before any fingerprint is computed or registered module indexed (below); each // new one indexes the module it names when that module is registered and the bucket holds none (registered.go). var started atomic.Bool @@ -424,8 +429,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro logger.Printf("judging the manifests at start: %v; judged again at the next message heard", err) return } - logger.Printf("judged %d manifests at start: %d verdicts written, %d with problems, of which outgrown and kept accepted: %v", - j.Judged, j.Written, len(j.Refused), j.Outgrown) + logger.Printf("judged %d manifests at start: %d verdicts written, %d with problems, of which outgrown and kept accepted: %v, waiting for a judge that knows their fields: %v", + j.Judged, j.Written, len(j.Refused), j.Outgrown, j.Waiting) }) // A build module says each time it wrote its toolchains key: its stream, and the indexer's durable consumer on it, are // made by the controller as the forge's are. Where the stream is not there yet the indexer says so and is started again diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named_test.go index b3040ed6..8db30456 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named_test.go @@ -50,6 +50,19 @@ func TestTheTrunkIsTheDirectoriesTheForgeListsTrees(t *testing.T) { // 1900/AC3 if _, has := gotArgs.(map[string]any)["paths"]; has { t.Error("the trunk asked for paths") } + // The build the indexer runs is the engine's word (Runs.FP of its composition verb), asked of its own machine. + var asked string + own := func(key string, body any) (json.RawMessage, error) { + asked = key + return json.RawMessage(`{"runs":{"fp":"src2:abc"}}`), nil + } + if fp, err := ownBuildVia(own, "n1")(context.Background()); err != nil || fp != "src2:abc" || asked != "seat:node-engine.composition@n1" { + t.Errorf("own build = %q %v asked %q", fp, err, asked) + } + none := func(string, any) (json.RawMessage, error) { return json.RawMessage(`{}`), nil } + if _, err := ownBuildVia(none, "n1")(context.Background()); err == nil { + t.Error("an engine that says nothing runs gave a build") + } } func TestReadViaReadsManifestsAndLocks(t *testing.T) { diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/ownbuild.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/ownbuild.go new file mode 100644 index 00000000..79a31448 --- /dev/null +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/ownbuild.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// compositionVerb is the node-engine seat's read verb that says what runs of a module on a machine (its Runs.FP), asked of +// that machine's own engine as `seat:node-engine.composition@`. The indexer learns the build it runs from it, never +// from the trunk's value of itself or the haves bucket. +const compositionVerb = "seat:node-engine.composition" + +// ownBuildVia asks the engine of node which fingerprint of the indexer runs there. +func ownBuildVia(call toolCaller, node string) func(ctx context.Context) (string, error) { + return func(context.Context) (string, error) { + raw, err := call(compositionVerb+"@"+node, map[string]any{"module": delivery.IndexerModule}) + if err != nil { + return "", fmt.Errorf("%s: %w", compositionVerb, err) + } + var a struct { + Runs *struct { + FP string `json:"fp"` + } `json:"runs"` + } + if err := json.Unmarshal(raw, &a); err != nil { + return "", fmt.Errorf("the answer of %s is not a composition: %w", compositionVerb, err) + } + if a.Runs == nil || a.Runs.FP == "" { + return "", errors.New("the engine says no fingerprint of the indexer runs") + } + return a.Runs.FP, nil + } +} diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/takein.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/takein.go index f0c7b024..a32aea21 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/takein.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/takein.go @@ -14,6 +14,8 @@ import ( // takerIn is the part of the indexer a takeins value asks through. type takerIn interface { TakeIn(ctx context.Context, module string) index.TakenIn + TakeInRepository(ctx context.Context, repository string) index.TakenIn + JudgeAgain(ctx context.Context, module string) index.TakenIn } // takenIn answers one value of the takeins state (the tool indexer_take_in, issue 1945): a value not yet answered takes the @@ -31,9 +33,17 @@ func takenIn(ctx context.Context, ix takerIn, kv jetstream.KeyValue, e jetstream if !a.Answered.IsZero() { return } - r := ix.TakeIn(ctx, a.Module) + asked, r := "take-in", index.TakenIn{} + if a.Repository != "" { + asked, r = "take-in of a repository", ix.TakeInRepository(ctx, a.Repository) + a.Module = a.Repository + } else if a.Judge { + asked, r = "judging again", ix.JudgeAgain(ctx, a.Module) + } else { + r = ix.TakeIn(ctx, a.Module) + } a.Result, a.Answered = &r, time.Now().UTC() - logf("%s: a take-in asked by %s (%s): %s", a.Module, a.By, a.Why, r.Answer) + logf("%s: a %s asked by %s (%s): %s", a.Module, asked, a.By, a.Why, r.Answer) data, err := json.Marshal(a) if err != nil { return diff --git a/modules/mesh-delivery-indexer/go.mod b/modules/mesh-delivery-indexer/go.mod index eba3ec65..36f08934 100644 --- a/modules/mesh-delivery-indexer/go.mod +++ b/modules/mesh-delivery-indexer/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-indexer go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103142843-f8bd12e4b7b1 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) @@ -23,3 +23,7 @@ require ( ) replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun => ../mesh-delivery-builder/buildrun + +require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk diff --git a/modules/mesh-delivery-indexer/go.sum b/modules/mesh-delivery-indexer/go.sum index d2717419..abad2969 100644 --- a/modules/mesh-delivery-indexer/go.sum +++ b/modules/mesh-delivery-indexer/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103142843-f8bd12e4b7b1 h1:T66tg9T/1hHg6KN/bDScHPT41IHkxLAGGO8VJiAOlb8= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103142843-f8bd12e4b7b1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-indexer/internal/index/candidates.go b/modules/mesh-delivery-indexer/internal/index/candidates.go index 09f613a6..b1620ac0 100644 --- a/modules/mesh-delivery-indexer/internal/index/candidates.go +++ b/modules/mesh-delivery-indexer/internal/index/candidates.go @@ -67,6 +67,8 @@ type Indexer struct { // Packages is the packages state (delivery.PackagesBucket, packages.go; novox/hq ADR 0593 §2); nil publishes none, // and every fingerprint takes the version line. Packages Store + // Graph is the module graph's state (graph.Bucket, graph.go; novox/hq ADR 0603 §2); nil writes no graph. + Graph Store // Files asks the forge for every blob under each directory at a commit, by directory then path relative to it, each // with its git object id (gitea.gitea_commit_trees with files): the listing a package's digest is computed from. // Nil publishes no package. @@ -98,22 +100,27 @@ type Indexer struct { // closed message the indexer missed heals at the next one (issue 1126); nil asks none. ReadOpen func(ctx context.Context) ([]delivery.OpenPull, error) - mu sync.Mutex // guards source, toolchains and previews, which the watches and the forge messages all reach - source sourceNow - previews map[string]delivery.Preview // by previews key (previews.go) - toolchains map[string]map[string]delivery.Toolchain // language, then node (toolchains.go) - tried map[string]bool // repository, commit and directory a registered module's manifest was tried at (registered.go) - unread map[string]bool // registered modules whose last read the forge could not answer, read again by RetryUnread - moving map[string]delivery.Forge // by modules key: a merge naming a module another repository holds, waiting for that trunk to drop it (moves.go) - retrying atomic.Bool // a RetryUnread runs in the background (kickRetry) - retryAgain atomic.Bool // an event asked for one more RetryUnread after the running one - retryWG sync.WaitGroup // the background RetryUnread, for tests to wait on - recompute sync.Mutex // one Recompute at a time - untaken map[string]string // repository to the latest merge applied while the taken state was missing (taken.go) - filling atomic.Bool // a FillTaken runs in the background (KickFill) - fillAgain atomic.Bool // a kick arrived while it ran - atHeads map[string]map[string]delivery.PublishedPackages // a member head's published Go modules, by repository@head (candidatePublished) - judgePending atomic.Bool // the start's judge pass failed: judged again at the next message (verdicts.go) + mu sync.Mutex // guards source, toolchains and previews, which the watches and the forge messages all reach + source sourceNow + previews map[string]delivery.Preview // by previews key (previews.go) + toolchains map[string]map[string]delivery.Toolchain // language, then node (toolchains.go) + tried map[string]bool // repository, commit and directory a registered module's manifest was tried at (registered.go) + unread map[string]bool // registered modules whose last read the forge could not answer, read again by RetryUnread + moving map[string]delivery.Forge // by modules key: a merge naming a module another repository holds, waiting for that trunk to drop it (moves.go) + retrying atomic.Bool // a RetryUnread runs in the background (kickRetry) + retryAgain atomic.Bool // an event asked for one more RetryUnread after the running one + retryWG sync.WaitGroup // the background RetryUnread, for tests to wait on + recompute sync.Mutex // one Recompute at a time + untaken map[string]string // repository to the latest merge applied while the taken state was missing (taken.go) + filling atomic.Bool // a FillTaken runs in the background (KickFill) + fillAgain atomic.Bool // a kick arrived while it ran + atHeads map[string]map[string]delivery.PublishedPackages // a member head's published Go modules, by repository@head (candidatePublished) + // OwnBuild answers the fingerprint of the build this program runs, from the node-engine's seat; nil knows none. + OwnBuild func(ctx context.Context) (string, error) + judgeMu sync.Mutex // one judge pass at a time (verdicts.go) + ownMu sync.Mutex // guards ownFP + ownFP string // the fingerprint of the build this program runs, asked at the start pass (learnOwnBuild) + judgePending atomic.Bool // the start's judge pass failed: judged again at the next message (verdicts.go) } func (ix *Indexer) logf(format string, args ...any) { diff --git a/modules/mesh-delivery-indexer/internal/index/catalog.go b/modules/mesh-delivery-indexer/internal/index/catalog.go new file mode 100644 index 00000000..33807d48 --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/catalog.go @@ -0,0 +1,149 @@ +package index + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" +) + +// The seat mesh-catalog's verbs, answered from the indexer's own states (novox/hq ADR 0604 §1, §2): modules, module, +// provides and dependents read the modules state, the manifests state and the module graph, and write nothing. The +// indexer serves no request subject (ADR 0437 §4): its tools bundle serves them as .. Each answer keeps the +// field names of the old catalog module's tool it replaces (catalog_modules, catalog_module, catalog_provides, +// catalog_dependents): the answers are the seat's contract (catalogue.CatalogModules and the rest), built here. + +// CatalogRead reads the states the seat's verbs answer from. +type CatalogRead struct { + // Modules is every value of the modules state, by module. + Modules func() (map[string]delivery.Module, error) + // Manifest is the manifest kept under a digest in the manifests state, as its JSON. + Manifest func(digest string) (string, error) + // Graph is the module graph, by module. + Graph func() (graph.Graph, error) +} + +func entryOf(name string, m delivery.Module) catalogue.CatalogEntry { + return catalogue.CatalogEntry{Module: name, Repository: m.Repository, Path: m.Path, Commit: m.Commit, FP: m.FP2, None: m.None2} +} + +// CatalogModulesAnswer is the verb modules: every module the modules state holds, by name. +func (c CatalogRead) CatalogModulesAnswer() (catalogue.CatalogModules, error) { + mods, err := c.Modules() + if err != nil { + return catalogue.CatalogModules{}, fmt.Errorf("the indexer's modules cannot be read: %w", err) + } + out := []catalogue.CatalogEntry{} + for _, name := range slices.Sorted(maps.Keys(mods)) { + out = append(out, entryOf(name, mods[name])) + } + return catalogue.CatalogModules{Modules: out}, nil +} + +// CatalogModuleAnswer is the verb module: one module whole, its manifest included. +func (c CatalogRead) CatalogModuleAnswer(name string) (catalogue.CatalogOne, error) { + name = strings.TrimSpace(name) + if name == "" { + return catalogue.CatalogOne{}, fmt.Errorf("name the module") + } + mods, err := c.Modules() + if err != nil { + return catalogue.CatalogOne{}, fmt.Errorf("the indexer's modules cannot be read: %w", err) + } + m, ok := mods[name] + if !ok { + return catalogue.CatalogOne{}, fmt.Errorf("the trunk holds no module %s", name) + } + one := catalogue.CatalogModule{CatalogEntry: entryOf(name, m)} + if m.Manifest == "" { + return catalogue.CatalogOne{}, fmt.Errorf("the indexer keeps no manifest of %s", name) + } + raw, err := c.Manifest(m.Manifest) + if err != nil { + return catalogue.CatalogOne{}, fmt.Errorf("the manifest %s of %s cannot be read: %w", m.Manifest, name, err) + } + one.Manifest = json.RawMessage(raw) + return catalogue.CatalogOne{Module: one}, nil +} + +// CatalogProvidesAnswer is the verb provides: the modules that provide a provision, each with its scope (a module provides +// its own name, at node scope), and the modules that require it, from the module graph's provision edges. +func (c CatalogRead) CatalogProvidesAnswer(provision string) (catalogue.CatalogProvides, error) { + provision = strings.TrimSpace(provision) + if provision == "" { + return catalogue.CatalogProvides{}, fmt.Errorf("name the provision") + } + g, mods, err := c.graphAndModules() + if err != nil { + return catalogue.CatalogProvides{}, err + } + provides, requires := []catalogue.CatalogEnd{}, []catalogue.CatalogEnd{} + for _, name := range g.Providers(provision) { + end := catalogue.CatalogEnd{Module: name, Commit: mods[name].Commit, Scope: catalogue.ScopeNode} + if name != provision { + end.Scope = c.scopeOf(mods[name], provision) + } + provides = append(provides, end) + } + for _, name := range g.Requirers(provision) { + requires = append(requires, catalogue.CatalogEnd{Module: name, Commit: mods[name].Commit}) + } + return catalogue.CatalogProvides{Provision: provision, Provides: provides, Requires: requires}, nil +} + +// scopeOf is the scope the module's manifest offers provision at, "" when its manifest cannot be read. +func (c CatalogRead) scopeOf(m delivery.Module, provision string) string { + if m.Manifest == "" { + return "" + } + raw, err := c.Manifest(m.Manifest) + if err != nil { + return "" + } + man, err := catalogue.Decode([]byte(raw)) + if err != nil { + return "" + } + for _, o := range man.Provides { + if o.Name == provision { + return o.At() + } + } + return "" +} + +// CatalogDependentsAnswer is the verb dependents: the modules whose build edges reach the module, directly or through +// others, from the module graph. +func (c CatalogRead) CatalogDependentsAnswer(module string) (catalogue.CatalogDependents, error) { + module = strings.TrimSpace(module) + if module == "" { + return catalogue.CatalogDependents{}, fmt.Errorf("name the module") + } + g, mods, err := c.graphAndModules() + if err != nil { + return catalogue.CatalogDependents{}, err + } + out := []catalogue.CatalogEnd{} + for _, name := range g.Dependents(module) { + out = append(out, catalogue.CatalogEnd{Module: name, Commit: mods[name].Commit}) + } + return catalogue.CatalogDependents{Module: module, Dependents: out}, nil +} + +func (c CatalogRead) graphAndModules() (graph.Graph, map[string]delivery.Module, error) { + g, err := c.Graph() + if err != nil { + return nil, nil, fmt.Errorf("the module graph cannot be read: %w", err) + } + mods, err := c.Modules() + if err != nil { + return nil, nil, fmt.Errorf("the indexer's modules cannot be read: %w", err) + } + return g, mods, nil +} diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_test.go b/modules/mesh-delivery-indexer/internal/index/catalog_test.go new file mode 100644 index 00000000..03c0b68c --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/catalog_test.go @@ -0,0 +1,127 @@ +package index + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" +) + +// capturedKeys are the field names of the first object at path (a list's first element when the value is a list) in the +// old catalog module's answer captured with mesh_call on 2026-11-03 (testdata/catalog; trimmed to a few modules, and the +// manifest of catalog_module to three of its keys). +func capturedKeys(t *testing.T, file string, path ...string) []string { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "catalog", file)) + if err != nil { + t.Fatal(err) + } + return keysAt(t, raw, path...) +} + +func keysAt(t *testing.T, raw []byte, path ...string) []string { + t.Helper() + var v any + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatal(err) + } + for _, p := range path { + v = v.(map[string]any)[p] + if l, ok := v.([]any); ok { + v = l[0] + } + } + var out []string + for k := range v.(map[string]any) { + out = append(out, k) + } + slices.Sort(out) + return out +} + +// catalogVerbsAnswerFromTheStates: the seat mesh-catalog's four verbs answer from the indexer's modules, manifests and +// graph, with the field names of the old catalog module's answers (ADR 0604 §1, §2). +func catalogVerbsAnswerFromTheStates(t *testing.T) { + withAcme := strings.Replace(renamed(t, "audit-logger", "acme"), `"module": "acme",`, + `"module": "acme", "provides": [{"name": "internal-acme-ca", "scope": "mesh", "answered-by": "helper"}],`, 1) + ix, mods := newFormIndexer(t, mod{"mesh-tools", renamed(t, "avahi", "mesh-tools")}, + mod{"avahi", standingOn(t, catalogueManifest(t, "avahi"), "mesh-tools")}, + mod{"ca-trust", catalogueManifest(t, "ca-trust")}, mod{"acme", withAcme}) + reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") + c := CatalogRead{ + Modules: func() (map[string]delivery.Module, error) { + out := map[string]delivery.Module{} + for k := range mods.vals { + m, _ := moduleOf(t, mods, k) + out[k] = m + } + return out, nil + }, + Manifest: func(digest string) (string, error) { + raw, _, err := ix.manifestRaw(context.Background(), digest) + return raw, err + }, + Graph: func() (graph.Graph, error) { + g := graph.Graph{} + for k := range ix.Graph.(*memStore).vals { + g[k] = graphOf(t, ix, k) + } + return g, nil + }, + } + answer := func(v any, err error) []byte { + t.Helper() + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(v) + return raw + } + contains := func(got, want []string) bool { + for _, w := range want { + if !slices.Contains(got, w) { + return false + } + } + return true + } + raw := answer(c.CatalogModulesAnswer()) + if got, want := keysAt(t, raw, "modules"), capturedKeys(t, "catalog_modules.json", "modules"); !contains(got, want) { + t.Errorf("modules' fields %v, want the old catalog's %v", got, want) + } + if !strings.Contains(string(raw), `"module":"avahi"`) || !strings.Contains(string(raw), `"fp":"src2:`) { + t.Errorf("modules = %s", raw) + } + raw = answer(c.CatalogModuleAnswer("ca-trust")) + want := slices.DeleteFunc(capturedKeys(t, "catalog_module.json", "module"), func(k string) bool { return k == "ref" }) // the trunk's, no ref + if got := keysAt(t, raw, "module"); !contains(got, want) || !strings.Contains(string(raw), `"internal-acme-ca"`) { + t.Errorf("module ca-trust = %s, want the fields %v and its manifest", raw, want) + } + if _, err := c.CatalogModuleAnswer("gone"); err == nil || !strings.Contains(err.Error(), "no module gone") { + t.Errorf("a module the trunk does not hold: %v", err) + } + raw = answer(c.CatalogProvidesAnswer("internal-acme-ca")) + if got, want := keysAt(t, raw, "provides"), capturedKeys(t, "catalog_provides.json", "provides"); !reflect.DeepEqual(got, want) || + !reflect.DeepEqual(keysAt(t, raw, "requires"), capturedKeys(t, "catalog_provides.json", "requires")) || + !strings.Contains(string(raw), `"provides":[{"module":"acme","commit":"`+merge+`","scope":"mesh"}]`) || + !strings.Contains(string(raw), `"requires":[{"module":"ca-trust"`) { + t.Errorf("provides internal-acme-ca = %s", raw) + } + raw = answer(c.CatalogDependentsAnswer("mesh-tools")) + if got, want := keysAt(t, raw), capturedKeys(t, "catalog_dependents.json"); !reflect.DeepEqual(got, want) || + !strings.Contains(string(raw), `"dependents":[{"module":"avahi"`) { + t.Errorf("dependents of mesh-tools = %s", raw) + } + raw = answer(c.CatalogDependentsAnswer("mesh-build-go")) // the build module reaches every Go module it builds + if !strings.Contains(string(raw), `"avahi"`) || !strings.Contains(string(raw), `"mesh-tools"`) { + t.Errorf("dependents of mesh-build-go = %s", raw) + } +} diff --git a/modules/mesh-delivery-indexer/internal/index/graph.go b/modules/mesh-delivery-indexer/internal/index/graph.go new file mode 100644 index 00000000..d50caa64 --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/graph.go @@ -0,0 +1,135 @@ +package index + +import ( + "context" + "errors" + "fmt" + "maps" + "slices" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/process" + + "git.novox.be/novox/mesh-delivery/sdk/graph" +) + +// The module graph (novox/hq ADR 0603 §2): at every recompute the indexer writes, in its state graph +// (graph.Bucket), one key per module it holds the inputs of, its build and provision edges as that recompute +// computed them, and deletes the key of a module the modules state no longer holds. A key is written only when its value +// changes, so its history of 5 is five changes of the module's edges. The delivery view, the checker and the seat +// mesh-catalog's verbs read it; nothing stores an order (ADR 0603 §1). + +// GraphMost is the most modules the graph holds (ADR 0603 §2): past it, the modules after the first GraphMost in name +// order are not written, and it is said in the log. The cap is the indexer's code, not a manifest field, until a judge +// that knows such a field runs (the lead's decision of L3). +const GraphMost = 2000 + +// graphEdges is what a recompute knows of one module for its graph node. +type graphEdges struct { + module string + cur delivery.Module + manifest catalogue.Manifest + fp, none string + tcs map[string]delivery.ToolchainUsed + pkgs delivery.PackageVersions +} + +// graphNode is the graph node of e: its edges, the awaited provisions judged against providers (catalogue.Awaited, as +// the assigner judges them). +func graphNode(e graphEdges, providers func(string) []catalogue.Manifest) graph.Node { + if e.fp == "" && e.none == "" { + e.none = "its fingerprint was not computed" + } + n := graph.EdgesOf(e.manifest, providers) + n.FP, n.None, n.Repository, n.Path = e.fp, e.none, e.cur.Repository, e.cur.Path + n.Packages = slices.Sorted(maps.Keys(e.pkgs)) + tcs := e.tcs + if len(tcs) == 0 { + tcs = e.cur.Toolchains // no fingerprint now: the toolchain it was last computed with still builds it + } + for language, t := range tcs { + builder, known := ToolchainModules[language] + if !known { + continue + } + if n.Toolchains == nil { + n.Toolchains = map[string]graph.Toolchain{} + } + n.Toolchains[language] = graph.Toolchain{Module: builder, Version: t.Version, Recipe: t.Recipe} + } + return n +} + +// writeGraph writes the graph node of every module of edges whose value changed, at most GraphMost of them, then +// deletes each graph key whose module the modules state no longer holds. Nil Graph writes nothing, and neither does a +// graph state that is not on the bus yet (process.ErrNotYet; main.go: until then no graph is written), said in the log; +// a listing that fails otherwise skips the deletion pass, said, and never fails the merge or the recompute that wrote. +func (ix *Indexer) writeGraph(ctx context.Context, edges map[string]graphEdges) error { + if ix.Graph == nil { + return nil + } + keys, listErr := ix.Graph.Keys(ctx) + if errors.Is(listErr, process.ErrNotYet) { + ix.logf("the module graph is not written: its state is not on the bus yet") + return nil + } + names := slices.Sorted(maps.Keys(edges)) + if len(names) > GraphMost { + ix.logf("the module graph holds at most %d modules: %d are known, so %s and the %d after it are not written", + GraphMost, len(names), names[GraphMost], len(names)-GraphMost-1) + names = names[:GraphMost] + } + providers := func(provision string) []catalogue.Manifest { + var out []catalogue.Manifest + for _, name := range slices.Sorted(maps.Keys(edges)) { + if slices.Contains(edges[name].manifest.Offers(), provision) { + out = append(out, edges[name].manifest) + } + } + return out + } + for _, name := range names { + key, err := graph.Key(name) + if err != nil { + ix.logf("graph.%s is not written: %v", name, err) + continue + } + next := graphNode(edges[name], providers) + err = write(ctx, ix.Graph, key, func(value []byte, found bool) ([]byte, bool, error) { + if found { + if cur, err := graph.Decode(value); err == nil && graph.Same(cur, next) { + return nil, false, nil + } + } + data, err := graph.Encode(next) + if err != nil { + return nil, false, fmt.Errorf("graph.%s: %w", key, err) + } + return data, false, nil + }) + if err != nil { + ix.logf("graph.%s is not written: %v", key, err) + } + } + held, err := ix.Modules.Keys(ctx) + switch { + case listErr != nil: + ix.logf("the graph keys of modules the modules state no longer holds are not deleted: listing the graph: %v", listErr) + return nil + case err != nil: + ix.logf("the graph keys of modules the modules state no longer holds are not deleted: listing the modules: %v", err) + return nil + } + for _, key := range keys { + if slices.Contains(held, key) { + continue + } + if err := write(ctx, ix.Graph, key, func(_ []byte, found bool) ([]byte, bool, error) { return nil, found, nil }); err != nil { + ix.logf("graph.%s is not deleted: %v", key, err) + continue + } + ix.logf("graph.%s deleted: the modules state holds no module %s", key, key) + } + return nil +} diff --git a/modules/mesh-delivery-indexer/internal/index/index_test.go b/modules/mesh-delivery-indexer/internal/index/index_test.go index e6f9da4d..b584aa67 100644 --- a/modules/mesh-delivery-indexer/internal/index/index_test.go +++ b/modules/mesh-delivery-indexer/internal/index/index_test.go @@ -19,6 +19,7 @@ const ( // key under the indexer; unbound, when set, is every read's error (a state not yet on the bus). type memStore struct { unbound error + keysErr error // the listing's error vals map[string][]byte revs map[string]uint64 next uint64 @@ -36,6 +37,9 @@ func (m *memStore) Get(_ context.Context, key string) ([]byte, uint64, bool, err return v, m.revs[key], ok, nil } func (m *memStore) Keys(context.Context) ([]string, error) { + if m.keysErr != nil { + return nil, m.keysErr + } var keys []string for k := range m.vals { keys = append(keys, k) diff --git a/modules/mesh-delivery-indexer/internal/index/manifests_test.go b/modules/mesh-delivery-indexer/internal/index/manifests_test.go index 064dc488..b9817ec9 100644 --- a/modules/mesh-delivery-indexer/internal/index/manifests_test.go +++ b/modules/mesh-delivery-indexer/internal/index/manifests_test.go @@ -33,7 +33,7 @@ func manifestsIndexer(t *testing.T, recs ...[2]any) (*Indexer, *memStore, *memSt t.Helper() ix, mods := modulesIndexer(t, recs...) manifests := newMem() - ix.Manifests = manifests + ix.Manifests, ix.Graph = manifests, newMem() return ix, mods, manifests } @@ -45,6 +45,7 @@ func withManifests(f delivery.Forge, manifests map[string]string, locks map[stri func TestAMergedManifestIsKeptOnceByItsDigestAndNamedByTheModule(t *testing.T) { t.Run("kept by its digest and named by the module", mergedManifestIsKeptByItsDigestAndNamedByTheModule) t.Run("the same manifest applied again is written once", sameManifestAppliedAgainIsWrittenOnce) + t.Run("the seat mesh-catalog's verbs answer from the states", catalogVerbsAnswerFromTheStates) } func mergedManifestIsKeptByItsDigestAndNamedByTheModule(t *testing.T) { // 1449/AC1 diff --git a/modules/mesh-delivery-indexer/internal/index/modules.go b/modules/mesh-delivery-indexer/internal/index/modules.go index a8cc9842..fece3567 100644 --- a/modules/mesh-delivery-indexer/internal/index/modules.go +++ b/modules/mesh-delivery-indexer/internal/index/modules.go @@ -117,8 +117,10 @@ func (ix *Indexer) applyMoving(ctx context.Context, f delivery.Forge, drop bool, overlay[key] = next } // A module of this repository the merge commit holds no module.json for is gone from the trunk, and so is its key - // (issue 1900). A message that carries no manifests (an older writer) or one that cannot be read whole says nothing. - if drop && len(f.Manifests) > 0 && every { + // (issue 1900). A message with no trees says the merge commit holds no module.json at all, so every module of the + // repository goes (issue 1126 note 94: a repository that removed its last module). A message with trees but no + // manifests (an older writer) or one that cannot be read whole says nothing. + if drop && (len(f.Manifests) > 0 || len(f.Trees) == 0) && every { if err := ix.dropRemoved(ctx, f.Repository, held, func(key string, _ delivery.Module) bool { return named[key] }); err != nil { return w, err } @@ -214,6 +216,7 @@ func (ix *Indexer) namedObjects(ctx context.Context, f delivery.Forge) (map[stri // held(key, module) says whether the trunk holds it. A module of another repository, or of none known, is never touched. func (ix *Indexer) dropRemoved(ctx context.Context, repository string, held map[string]delivery.Module, trunkHolds func(key string, m delivery.Module) bool) error { var handed []string + elsewhere := map[string]delivery.Forge{} for _, key := range slices.Sorted(maps.Keys(held)) { cur := held[key] if cur.Repository == "" || !delivery.SameRepository(cur.Repository, repository) || trunkHolds(key, cur) { @@ -223,6 +226,12 @@ func (ix *Indexer) dropRemoved(ctx context.Context, repository string, held map[ handed = append(handed, key) // written in place by the repository it moves to, never deleted first continue } + // The wait of moves.go is memory, which a restart forgets (issue 1367): another repository's trunk that holds the + // module is asked, so the move survives it. + if f, ok := ix.heldByAnotherTrunk(ctx, key, repository); ok { + elsewhere[key] = f + continue + } err := write(ctx, ix.Modules, key, func(value []byte, found bool) ([]byte, bool, error) { if !found { return nil, false, nil @@ -242,6 +251,11 @@ func (ix *Indexer) dropRemoved(ctx context.Context, repository string, held map[ return err } } + for _, key := range slices.Sorted(maps.Keys(elsewhere)) { + if err := ix.handOverFrom(ctx, key, elsewhere[key]); err != nil { + return err + } + } return nil } diff --git a/modules/mesh-delivery-indexer/internal/index/modules_test.go b/modules/mesh-delivery-indexer/internal/index/modules_test.go index b75b5a8f..b7c32974 100644 --- a/modules/mesh-delivery-indexer/internal/index/modules_test.go +++ b/modules/mesh-delivery-indexer/internal/index/modules_test.go @@ -219,6 +219,32 @@ func TestAnUnchangedModuleWritesNothing(t *testing.T) { // 1239 (unchanged value } return ix.Apply(context.Background(), mergedMsg(repo, three)) }}, + // Issue 1126 note 94: a merge that leaves no module.json anywhere (hq, or a repository whose last module was removed) is a + // merged message with no trees; it drops the repository's modules and moves its taken key, and an older one, heard late, + // goes through the same order check and drops nothing. + {name: "a merge that leaves no module.json drops every module and is taken in", writes: 3, commit: map[string]string{"a": "", "b": "", "c": ""}, taken: next, + said: "modules written: none", + then: func(ix *Indexer) error { + m := mergedMsg(repo, nil) + m.Merge = next + return ix.Apply(context.Background(), m) + }}, + {name: "an earlier merge with no module.json heard after a later one drops nothing", writes: 0, commit: map[string]string{"a": merge, "b": merge, "c": merge}, taken: next, + said: "the merged message is older than taken.novox/mesh-delivery at cccccccccccc", + then: func(ix *Indexer) error { + const earlier = "dddddddddddddddddddddddddddddddddddddddd" + ix.Contains = func(_ context.Context, _, commit, in string) (bool, error) { + return commit == in || commit == earlier && in == next, nil + } + later := mergedMsg(repo, three) + later.Merge = next + if err := ix.Apply(context.Background(), later); err != nil { + return err + } + gone := mergedMsg(repo, nil) + gone.Merge = earlier + return ix.Apply(context.Background(), gone) + }}, // Review round 2: a 502 whose words carry "404" inside a sha is no not-found; the late message is left for redelivery. {name: "a late message whose order the forge fails with a 502 writes nothing and is left for redelivery", writes: 1, commit: map[string]string{"a": next, "b": merge, "c": merge}, taken: next, err: true, said: "502 Bad Gateway", @@ -305,8 +331,8 @@ func TestAnUnchangedModuleWritesNothing(t *testing.T) { // 1239 (unchanged value if got := mods.writes - writes; got != tc.writes { t.Errorf("%d values written, want %d", got, tc.writes) } - for module, want := range tc.commit { - if m, ok := moduleOf(t, mods, module); !ok || m.Commit != want { + for module, want := range tc.commit { // "" is a module the merge deleted + if m, ok := moduleOf(t, mods, module); ok != (want != "") || m.Commit != want { t.Errorf("%s = %+v (%v), want commit %.8s", module, m, ok, want) } } @@ -419,31 +445,76 @@ func (d *deletesSeen) Delete(ctx context.Context, key string, rev uint64) error // holding repository's merge that drops it hands it over in place, never deleting its key; merged the other way round, // the module is deleted and then written new. func TestAModuleMovesBetweenRepositoriesOnlyWhenItsTrunkDropsIt(t *testing.T) { - const from, to = "novox/mesh-controller", "novox/mesh-networking" + const from, to, toHead = "novox/mesh-controller", "novox/mesh-networking", "bbbbbbbbbbbb" held := mergedMsg(from, map[string]string{"modules/a": "t", "modules/b": "t"}) named := mergedMsg(to, map[string]string{"modules/a": "t2"}) dropped := mergedMsg(from, map[string]string{"modules/b": "t"}) + gone := mergedMsg(from, nil) // issue 1367: a merge that leaves no module.json + gone.Trees, gone.Manifests = nil, nil for _, tc := range []struct { name string merges []delivery.Forge repository string // modules.a's repository at the end, "" for none deletedKeyA bool + // Issue 1367: a fresh indexer, no wait in memory, the source naming heads; trunk is the module directories the forge + // reads at the head of `to`; takeIn is a repository taken in after the merges (indexer_take_in), whose answer must + // hold answer and be stored or not. + heads bool + trunk []string + takeIn string + answer string + stored bool + bGoneAtEnd bool }{ - {"named while its trunk holds it: left", []delivery.Forge{held, named}, from, false}, - {"named, then dropped by its trunk: handed over in place", []delivery.Forge{held, named, dropped}, to, false}, - {"dropped by its trunk, then named: deleted, then written new", []delivery.Forge{held, dropped, named}, to, true}, - {"dropped by its trunk with no merge naming it: deleted", []delivery.Forge{held, dropped}, "", true}, + {name: "named while its trunk holds it: left", merges: []delivery.Forge{held, named}, repository: from}, + {name: "named, then dropped by its trunk: handed over in place", merges: []delivery.Forge{held, named, dropped}, repository: to}, + {name: "dropped by its trunk, then named: deleted, then written new", merges: []delivery.Forge{held, dropped, named}, repository: to, deletedKeyA: true}, + {name: "dropped by its trunk with no merge naming it: deleted", merges: []delivery.Forge{held, dropped}, deletedKeyA: true}, + {name: "no module.json left, another trunk holds it, nothing waits in memory: written from that trunk, never deleted", + merges: []delivery.Forge{held, gone}, heads: true, trunk: []string{"modules/a", "modules/other"}, repository: to, bGoneAtEnd: true}, + {name: "no module.json left, no other trunk holds it: deleted", + merges: []delivery.Forge{held, gone}, heads: true, deletedKeyA: true, bGoneAtEnd: true}, + {name: "taken in by repository, a head with no module.json: handed over from the other trunk", + merges: []delivery.Forge{held}, heads: true, trunk: []string{"modules/a"}, takeIn: from, answer: "handed over", stored: true, repository: to, bGoneAtEnd: true}, + {name: "taken in by repository, a head with modules: answered by name", + merges: []delivery.Forge{held}, heads: true, trunk: []string{"modules/a"}, takeIn: to, answer: "by name", repository: from}, + {name: "taken in by repository, no head named: answered so", + merges: []delivery.Forge{held}, heads: true, takeIn: "novox/nobody", answer: "names no head", repository: from}, } { t.Run(tc.name, func(t *testing.T) { mods := &deletesSeen{memStore: newMem()} ix := &Indexer{Store: newMem(), Modules: mods, Builds: NewBuilds()} var logs []string ix.Logf = func(format string, args ...any) { logs = append(logs, format) } + if tc.heads { + ix.source = sourceNow{rev: 7, heads: map[string]string{from: merge, to: toHead}} + ix.Trunk = func(_ context.Context, repository, sha string) ([]string, error) { + if repository == to && sha == toHead { + return tc.trunk, nil + } + if repository == from && tc.takeIn == from { + return nil, nil + } + return nil, nil + } + ix.Read = func(_ context.Context, repository, sha, dir string) (RepoRead, error) { + if repository != to || sha != toHead || dir != "modules/a" { + t.Errorf("read %s at %s %q, want only %s at %s modules/a", repository, sha, dir, to, toHead) + } + return RepoRead{Root: "r2", Trees: map[string]string{dir: "t2"}, Manifests: map[string]string{dir: `{"module":"a","version":"1"}`}}, nil + } + } for _, f := range tc.merges { if err := ix.Apply(context.Background(), f); err != nil { t.Fatal(err) } } + if tc.takeIn != "" { + got := ix.TakeInRepository(context.Background(), tc.takeIn) + if got.Stored != tc.stored || !strings.Contains(got.Answer, tc.answer) { + t.Errorf("take-in answered %+v, want stored %v and %q", got, tc.stored, tc.answer) + } + } m, ok := moduleOf(t, mods.memStore, "a") if got := map[bool]string{true: m.Repository}[ok]; got != tc.repository { t.Errorf("modules.a of %q, want %q (logs %v)", got, tc.repository, logs) @@ -451,11 +522,14 @@ func TestAModuleMovesBetweenRepositoriesOnlyWhenItsTrunkDropsIt(t *testing.T) { if ok && m.Path != "modules/a" { t.Errorf("modules.a at %q, want modules/a", m.Path) } + if ok && m.Repository == to && tc.heads && m.Commit != toHead { + t.Errorf("modules.a at %s, want the other trunk's head %s", m.Commit, toHead) + } if deleted := slices.Contains(mods.deleted, "a"); deleted != tc.deletedKeyA { t.Errorf("modules.a deleted %v, want %v", deleted, tc.deletedKeyA) } - if _, ok := mods.vals["b"]; !ok { - t.Error("modules.b, which its trunk still holds, is gone") + if _, ok := mods.vals["b"]; ok == tc.bGoneAtEnd { + t.Errorf("modules.b held %v, want %v", ok, !tc.bGoneAtEnd) } if len(logs) == 0 { t.Error("nothing was said") diff --git a/modules/mesh-delivery-indexer/internal/index/moves.go b/modules/mesh-delivery-indexer/internal/index/moves.go index 8567d4d4..e192731a 100644 --- a/modules/mesh-delivery-indexer/internal/index/moves.go +++ b/modules/mesh-delivery-indexer/internal/index/moves.go @@ -3,10 +3,66 @@ package index import ( "context" "fmt" + "maps" + "slices" + "strings" "git.novox.be/novox/mesh-sdk/go/delivery" ) +// heldByAnotherTrunk reads, for a module the trunk of repository no longer holds, whether the trunk of another repository +// holds it at the head the latest source names: the module directories there are asked of the forge (Trunk) and the manifest of +// each directory named like the module is read (Read). It answers the merge to hand the module over with, or none: a forge +// that cannot say leaves the module to be dropped as it was, said (issue 1367). +func (ix *Indexer) heldByAnotherTrunk(ctx context.Context, module, repository string) (delivery.Forge, bool) { + if ix.Trunk == nil || ix.Read == nil { + return delivery.Forge{}, false + } + ix.mu.Lock() + heads := maps.Clone(ix.source.heads) + ix.mu.Unlock() + for _, other := range slices.Sorted(maps.Keys(heads)) { + head := heads[other] + if head == "" || delivery.SameRepository(other, repository) { + continue + } + dirs, err := ix.Trunk(ctx, other, head) + if err != nil { + ix.logf("%s at %s: the modules of the trunk cannot be read, so %s is not looked for there: %v", other, head, module, err) + continue + } + for _, d := range dirs { + dir := strings.Trim(d, "/") + if nameOfDirectory(dir) != module { + continue + } + read, err := ix.Read(ctx, other, head, dir) + if err != nil { + ix.logf("%s at %s: %q cannot be read, so %s is not handed over from it: %v", other, head, dir, module, err) + continue + } + raw := read.Manifests[dir] + if read.Trees[dir] == "" || manifestModule(raw) != module { + continue + } + f := delivery.Forge{V: delivery.Version, Kind: delivery.ForgeMerged, Repository: other, Merge: head, Root: read.Root, + Trees: map[string]string{dir: read.Trees[dir]}, Manifests: map[string]string{dir: raw}} + if l := read.Locks[dir]; len(l) > 0 { + f.Locks = map[string]map[string]string{dir: l} + } + if p := read.Packages[dir]; len(p) > 0 { + f.Packages = map[string][]string{dir: p} + } + ix.logf("%s at %s: the trunk holds the module %s at %q, so it is handed over from there and not deleted (issue 1367)", other, head, module, dir) + return f, true + } + } + return delivery.Forge{}, false +} + +// nameOfDirectory is the last element of a module directory. +func nameOfDirectory(dir string) string { return dir[strings.LastIndex(dir, "/")+1:] } + // A module moves between repositories by the merges of the delivery group that moves it (novox/hq ADR 0420 clause 7, ADR // 0501 clause 3; issue 1126: route-proxy leaves the controller's repository, which is archived). The merge of the // repository it moves to names it while the indexer holds it of another: that merge leaves it, says so, and waits here. @@ -57,6 +113,11 @@ func (ix *Indexer) handOver(ctx context.Context, key string) error { if !ok { return nil } + return ix.handOverFrom(ctx, key, f) +} + +// handOverFrom writes the module at key from the merge f, which names it, in place. +func (ix *Indexer) handOverFrom(ctx context.Context, key string, f delivery.Forge) error { w, err := ix.applyMoving(ctx, f, false, map[string]bool{key: true}) if err != nil { return fmt.Errorf("modules.%s, moving to %s: %w", key, f.Repository, err) diff --git a/modules/mesh-delivery-indexer/internal/index/registered.go b/modules/mesh-delivery-indexer/internal/index/registered.go index f541990d..cf0940bb 100644 --- a/modules/mesh-delivery-indexer/internal/index/registered.go +++ b/modules/mesh-delivery-indexer/internal/index/registered.go @@ -213,6 +213,10 @@ type TakenIn struct { Commit string `json:"commit,omitempty"` Path string `json:"path,omitempty"` Answer string `json:"answer"` + // Handed and Dropped are the modules a take-in of a repository (TakeInRepository) moved to another repository's trunk + // and deleted. + Handed []string `json:"handed,omitempty"` + Dropped []string `json:"dropped,omitempty"` } // TakeIn reads a registered module's manifest now, whatever was tried before, and writes it as a merge would (the tool @@ -358,3 +362,61 @@ func (ix *Indexer) takeInHeld(ctx context.Context, module string) (TakenIn, bool } return out, true } + +// TakeInRepository takes in the head the gate's source names for a repository whose merged message the indexer never +// heard (the tool indexer_take_in with repository, issue 1367). A head whose trunk holds no module.json is applied as +// the merged message of a merge that leaves none: every module of the repository goes, one another trunk holds handed over +// from there first. A head whose trunk holds modules is answered "by name": each is taken in with its own name. +func (ix *Indexer) TakeInRepository(ctx context.Context, repository string) TakenIn { + out := TakenIn{Repository: repository} + ix.mu.Lock() + head := "" + for repo, h := range ix.source.heads { + if delivery.SameRepository(repo, repository) && h != "" { + head, out.Repository = h, repo + } + } + ix.mu.Unlock() + if head == "" || ix.Modules == nil || ix.Trunk == nil { + out.Answer = "the gate's source names no head of " + repository + ", or the indexer reads no forge or writes no modules" + return out + } + out.Commit = head + dirs, err := ix.Trunk(ctx, out.Repository, head) + if err != nil { + out.Answer = fmt.Sprintf("the modules of %s at %.12s cannot be read, so nothing is taken in: %v", repository, head, err) + return out + } + if len(dirs) > 0 { + out.Answer = fmt.Sprintf("%s at %.12s holds modules (%s): take each module in by name (module)", repository, head, strings.Join(dirs, ", ")) + return out + } + before, err := ix.heldModules(ctx) + if err != nil { + out.Answer = "the modules state cannot be read: " + err.Error() + return out + } + f := delivery.Forge{V: delivery.Version, Kind: delivery.ForgeMerged, Repository: out.Repository, Merge: head} + if _, err := ix.ApplyMessage(ctx, f); err != nil { + out.Answer = fmt.Sprintf("the merge of %s at %.12s could not be taken in: %v", repository, head, err) + return out + } + after, err := ix.heldModules(ctx) + if err != nil { + out.Answer = "the modules state cannot be read: " + err.Error() + return out + } + for _, key := range slices.Sorted(maps.Keys(before)) { + if !delivery.SameRepository(before[key].Repository, repository) { + continue + } + if now, ok := after[key]; ok { + out.Handed = append(out.Handed, key+" to "+now.Repository+" at "+now.Commit) + } else { + out.Dropped = append(out.Dropped, key) + } + } + out.Stored = true + out.Answer = fmt.Sprintf("%s at %.12s holds no module.json: taken in as a merge that leaves none; handed over %v, dropped %v", repository, head, out.Handed, out.Dropped) + return out +} diff --git a/modules/mesh-delivery-indexer/internal/index/source.go b/modules/mesh-delivery-indexer/internal/index/source.go index f71a3674..7dd92d34 100644 --- a/modules/mesh-delivery-indexer/internal/index/source.go +++ b/modules/mesh-delivery-indexer/internal/index/source.go @@ -82,6 +82,9 @@ func (ix *Indexer) ApplySource(ctx context.Context, rev uint64, s delivery.Sourc return nil, false, nil } cur.Src = rev + if cur.FPSrc == 0 { + cur.FPSrc = rev // its fingerprint moved at this merge, whose revision is heard only now (ADR 0601 §1) + } data, err := delivery.EncodeModule(cur) return data, false, err }) diff --git a/modules/mesh-delivery-indexer/internal/index/source_test.go b/modules/mesh-delivery-indexer/internal/index/source_test.go index 45260ab8..8bb56ebd 100644 --- a/modules/mesh-delivery-indexer/internal/index/source_test.go +++ b/modules/mesh-delivery-indexer/internal/index/source_test.go @@ -31,7 +31,7 @@ func TestASourceRevisionStampsTheModulesOfTheRepositoriesHeadItNames(t *testing. } m, ok := moduleOf(t, mods, "a") want := before - want.Src = 7 + want.Src, want.FPSrc = 7, 7 // its fingerprint moved at that merge (ADR 0601 §1) if !ok || !reflect.DeepEqual(m, want) { t.Errorf("module a = %+v (%v), want %+v: the fingerprint and the commit as the merge wrote them, with Src 7", m, ok, want) } diff --git a/modules/mesh-delivery-indexer/internal/index/takein.go b/modules/mesh-delivery-indexer/internal/index/takein.go index 9d3915fa..10866eb0 100644 --- a/modules/mesh-delivery-indexer/internal/index/takein.go +++ b/modules/mesh-delivery-indexer/internal/index/takein.go @@ -25,9 +25,13 @@ const ( // TakeInAsk is a value of the takeins state: which module a person asked to be taken in, why, who and when, and, once the // indexer read it, its answer. type TakeInAsk struct { - V int `json:"v"` - Module string `json:"module,omitempty"` - Why string `json:"why,omitempty"` + V int `json:"v"` + Module string `json:"module,omitempty"` + // Repository asks the head the gate's source names for a repository to be taken in (TakeInRepository) instead of a module. + Repository string `json:"repository,omitempty"` + Why string `json:"why,omitempty"` + // Judge asks the manifests to be judged again now (indexer_judge_again) instead of the module to be taken in. + Judge bool `json:"judge,omitempty"` By string `json:"by,omitempty"` At time.Time `json:"at"` Result *TakenIn `json:"result,omitempty"` @@ -46,8 +50,8 @@ func DecodeTakeIn(data []byte) (TakeInAsk, error) { if a.V != 1 { return a, fmt.Errorf("a takeins value of version %d, not 1", a.V) } - if a.Module == "" { - return a, errors.New("a takeins value naming no module") + if a.Module == "" && a.Repository == "" { + return a, errors.New("a takeins value naming no module or repository") } return a, nil } @@ -65,3 +69,14 @@ func NewTakeIn(module, why, by string, at time.Time) (string, []byte, error) { data, err := json.Marshal(TakeInAsk{V: 1, Module: module, Why: strings.TrimSpace(why), By: by, At: at.UTC()}) return key, data, err } + +// NewTakeInRepository is the value indexer_take_in writes for a repository: its key and the value. +func NewTakeInRepository(repository, why, by string, at time.Time) (string, []byte, error) { + repository = strings.TrimSpace(repository) + key, err := delivery.RepositoryKey(repository) + if err != nil { + return "", nil, err + } + data, err := json.Marshal(TakeInAsk{V: 1, Repository: repository, Why: strings.TrimSpace(why), By: by, At: at.UTC()}) + return "repository." + key, data, err +} diff --git a/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_dependents.json b/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_dependents.json new file mode 100644 index 00000000..8f6b152b --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_dependents.json @@ -0,0 +1,4 @@ +{ + "dependents": [], + "module": "mesh-build-go" +} diff --git a/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_module.json b/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_module.json new file mode 100644 index 00000000..eecc2522 --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_module.json @@ -0,0 +1,18 @@ +{ + "module": { + "commit": "b5475cfa5c3daffd52b5b25eb7063e745b976026", + "manifest": { + "module": "gitea", + "requires": [ + "postgres-database", + "route", + "secret" + ], + "version": "1" + }, + "module": "gitea", + "path": "modules/gitea", + "ref": "refs/pull/571/head", + "repository": "novox/mesh-catalog" + } +} diff --git a/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_modules.json b/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_modules.json new file mode 100644 index 00000000..e5d979fa --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_modules.json @@ -0,0 +1,22 @@ +{ + "modules": [ + { + "commit": "b5475cfa5c3daffd52b5b25eb7063e745b976026", + "module": "gitea", + "path": "modules/gitea", + "repository": "novox/mesh-catalog" + }, + { + "commit": "dc90b3f5bd1386747ac70f2792169babf8eeed73", + "module": "mesh-controller", + "path": ".", + "repository": "novox/mesh-controller" + }, + { + "commit": "432326264f6c34ceec1fb67f5ca9d450b4caaa1d", + "module": "postgres", + "path": "modules/postgres", + "repository": "novox/mesh-catalog" + } + ] +} diff --git a/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_provides.json b/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_provides.json new file mode 100644 index 00000000..b4b1606f --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_provides.json @@ -0,0 +1,15 @@ +{ + "provides": [ + { + "commit": "432326264f6c34ceec1fb67f5ca9d450b4caaa1d", + "module": "postgres", + "scope": "mesh" + } + ], + "requires": [ + { + "commit": "b5475cfa5c3daffd52b5b25eb7063e745b976026", + "module": "gitea" + } + ] +} diff --git a/modules/mesh-delivery-indexer/internal/index/toolchains.go b/modules/mesh-delivery-indexer/internal/index/toolchains.go index 1789b439..2b13d2f7 100644 --- a/modules/mesh-delivery-indexer/internal/index/toolchains.go +++ b/modules/mesh-delivery-indexer/internal/index/toolchains.go @@ -111,6 +111,7 @@ type toolchainOrNone struct { type known struct { key string cur delivery.Module + manifest catalogue.Manifest languages map[string]string bases []string contexts bool @@ -196,7 +197,7 @@ func (ix *Indexer) recomputeModules(ctx context.Context, overlay map[string]deli cur, stale[key] = again, true held[key] = cur } - all[key] = &known{key: key, cur: cur, languages: languagesOf(m), bases: basesOf(m), contexts: hasContext(m), held: before} + all[key] = &known{key: key, cur: cur, manifest: m, languages: languagesOf(m), bases: basesOf(m), contexts: hasContext(m), held: before} } tools := ix.agreed() published := ix.publishedAll(ctx) @@ -278,9 +279,11 @@ func (ix *Indexer) recomputeModules(ctx context.Context, overlay map[string]deli return r } finals := map[string]delivery.Module{} + edges := map[string]graphEdges{} // the module graph's nodes of this recompute (graph.go) for _, key := range slices.Sorted(maps.Keys(all)) { k := all[key] r := of(key, nil) + edges[key] = graphEdges{module: key, cur: k.cur, manifest: k.manifest, fp: r.fp, none: r.none, tcs: r.tcs, pkgs: r.pkgs} if _, merged := overlay[key]; merged { final := overlay[key] final.FP2, final.None2, final.Inputs2, final.Toolchains, final.Packages = r.fp, r.none, k.cur.Inputs2, r.tcs, r.pkgs @@ -312,6 +315,13 @@ func (ix *Indexer) recomputeModules(ctx context.Context, overlay map[string]deli if cur.FP2 == r.fp && cur.None2 == r.none && reflect.DeepEqual(cur.Toolchains, r.tcs) && reflect.DeepEqual(cur.Packages, r.pkgs) { return nil, false, nil } + if cur.FP2 != r.fp { + // Moved with no merge (a toolchain, a package, a base): no release carries this build, and the source revision + // moves only at a merge, so it is not known (0) and the move is asked of a person; never the revision of now, + // which may be the very release that last merged the module (ADR 0601 §1). The source stamp leaves it, since + // Src is set. + cur.FPSrc = 0 + } cur.FP2, cur.None2, cur.Toolchains, cur.Packages = r.fp, r.none, r.tcs, r.pkgs cur.Inputs2, cur.ArtifactPaths = inputs, paths data, err := delivery.EncodeModule(cur) @@ -325,7 +335,10 @@ func (ix *Indexer) recomputeModules(ctx context.Context, overlay map[string]deli w.wrote(key) } } - return ix.writeOverlay(ctx, overlay, finals, w) + if err := ix.writeOverlay(ctx, overlay, finals, w); err != nil { + return err + } + return ix.writeGraph(ctx, edges) } // writeOverlay writes the records of a merge, each once and only if it differs from what the bucket holds; finals has @@ -353,7 +366,14 @@ func (ix *Indexer) writeOverlay(ctx context.Context, overlay, finals map[string] if held && cur.Manifest == next.Manifest { // The judge's verdict stands while the digest does (ADR 0578 §3, ADR 0579 §1): a recompute never writes a // refused or outgrown module as accepted; only the judge changes it. - next.Verdict, next.Problems, next.Warnings = cur.Verdict, cur.Problems, cur.Warnings + next.Verdict, next.Problems, next.Unknown, next.Warnings = cur.Verdict, cur.Problems, cur.Unknown, cur.Warnings + } + // The source revision at which FP2 last changed (ADR 0601 §1): this merge's when it moved it (0 until the + // source revision naming the merge is heard, which stamps it), else the one held. + if held && cur.FP2 == next.FP2 { + next.FPSrc = cur.FPSrc + } else { + next.FPSrc = next.Src } if held && sameButWhere(cur, next) { return nil, false, nil @@ -379,8 +399,8 @@ func (ix *Indexer) writeOverlay(ctx context.Context, overlay, finals map[string] // which only the judge writes: the module is unchanged. func sameButWhere(cur, next delivery.Module) bool { next = withV(next) - next.Commit, next.Src = cur.Commit, cur.Src - next.Verdict, next.Problems, next.Warnings = cur.Verdict, cur.Problems, cur.Warnings + next.Commit, next.Src, next.FPSrc = cur.Commit, cur.Src, cur.FPSrc + next.Verdict, next.Problems, next.Unknown, next.Warnings = cur.Verdict, cur.Problems, cur.Unknown, cur.Warnings return reflect.DeepEqual(cur, next) } diff --git a/modules/mesh-delivery-indexer/internal/index/toolchains_test.go b/modules/mesh-delivery-indexer/internal/index/toolchains_test.go index 603241dd..2510e054 100644 --- a/modules/mesh-delivery-indexer/internal/index/toolchains_test.go +++ b/modules/mesh-delivery-indexer/internal/index/toolchains_test.go @@ -2,12 +2,17 @@ package index import ( "context" + "errors" + "fmt" "reflect" "strings" "testing" "time" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/process" + + "git.novox.be/novox/mesh-delivery/sdk/graph" "mesh-delivery-indexer/internal/fingerprint" ) @@ -60,6 +65,20 @@ type mod struct { // merged applies one merge of the catalogue carrying these modules (every one a Go bundle's go.sum, a TypeScript bundle's // package-lock.json) and returns the indexer. +// graphOf is the graph node the indexer holds of module. +func graphOf(t *testing.T, ix *Indexer, module string) graph.Node { + t.Helper() + v, ok := ix.Graph.(*memStore).vals[module] + if !ok { + t.Fatalf("graph.%s is not held", module) + } + g, err := graph.Decode(v) + if err != nil { + t.Fatal(err) + } + return g +} + func newFormIndexer(t *testing.T, mods ...mod) (*Indexer, *memStore) { t.Helper() ix, store, f := newFormIndexerUnmerged(t, mods...) @@ -98,12 +117,17 @@ func toolchainValue(t *testing.T, language, version, recipe string) []byte { } func reportToolchain(t *testing.T, ix *Indexer, module, node, language, version string) { + t.Helper() + reportToolchainRecipe(t, ix, module, node, language, version, "1") +} + +func reportToolchainRecipe(t *testing.T, ix *Indexer, module, node, language, version, recipe string) { t.Helper() key, err := delivery.ToolchainKey(node, language) if err != nil { t.Fatal(err) } - if err := ix.ApplyToolchain(context.Background(), module, key, toolchainValue(t, language, version, "1"), false); err != nil { + if err := ix.ApplyToolchain(context.Background(), module, key, toolchainValue(t, language, version, recipe), false); err != nil { t.Fatal(err) } } @@ -111,14 +135,75 @@ func reportToolchain(t *testing.T, ix *Indexer, module, node, language, version func TestAgreedToolchainChangesAGoModulesReasonWithNoMerge(t *testing.T) { // 1444/AC5 ix, mods := newFormIndexer(t, mod{"avahi", catalogueManifest(t, "avahi")}) if m, _ := moduleOf(t, mods, "avahi"); m.None2 != delivery.ToolchainNoReport { + t.Run("a recipe change builds a Go module at once and again once the agreement moves", aRecipeChangeBuildsAGoModuleAtOnceAndAgainOnceTheAgreementMoves) t.Fatalf("before: none2 = %q", m.None2) } + // The release that last merged avahi: its source revision names avahi's commit, so Src (and FPSrc) are 7. + merged, _ := moduleOf(t, mods, "avahi") + src := delivery.Source{Group: "g", Heads: map[string]string{merged.Repository: merged.Commit}} + if err := ix.ApplySource(context.Background(), 7, src); err != nil { + t.Fatal(err) + } + if m, _ := moduleOf(t, mods, "avahi"); m.Src != 7 || m.FPSrc != 7 { + t.Fatalf("before: src %d fp-src %d, want 7 and 7", m.Src, m.FPSrc) + } + if g := graphOf(t, ix, "avahi"); g.None != delivery.ToolchainNoReport || g.Toolchains != nil { + t.Errorf("before: graph.avahi = %+v, want no toolchain edge and the reason", g) + } reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.27.1") m, _ := moduleOf(t, mods, "avahi") + // L3 (ADR 0603 §2, §3): the agreement moving rewrites the module's toolchain edge, and again when it moves on. + if g := graphOf(t, ix, "avahi"); g.FP != m.FP2 || g.Toolchains["go"].Version != "go1.27.1" || g.Toolchains["go"].Module != "mesh-build-go" { + t.Errorf("after: graph.avahi = %+v, want the toolchain edge of go1.27.1", g) + } + reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.28.0") + reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.28.0") + if g := graphOf(t, ix, "avahi"); g.Toolchains["go"].Version != "go1.28.0" { + t.Errorf("moved on: graph.avahi = %+v, want the toolchain edge of go1.28.0", g) + } + reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") + reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.27.1") + m, _ = moduleOf(t, mods, "avahi") if m.None2 != "" || m.FP2 == "" { t.Errorf("none2 = %q fp2 = %q, want a fingerprint: no builder input is waited for (ADR 0530 §1)", m.None2, m.FP2) } + // ADR 0601 §1 (review round 2): a fingerprint moved with no merge has no release that carries it: fp-src 0, not known, + // though the source revision is still the release's at the module's own Src; a source revision heard again stamps nothing. + if m.FPSrc != 0 { + t.Errorf("a fingerprint moved by a recompute: fp-src %d, want 0", m.FPSrc) + } + if err := ix.ApplySource(context.Background(), 8, src); err != nil { + t.Fatal(err) + } + if again, _ := moduleOf(t, mods, "avahi"); again.FPSrc != 0 { + t.Errorf("a source revision stamped the recomputed fingerprint: fp-src %d", again.FPSrc) + } +} + +// ADR 0603 §3 (D9): a source revision that changes the Go build module's recipe and a Go module builds the module at once, with +// the recipe the nodes run, and again once the agreement moves to the new one: the module never waits and never loses its +// fingerprint between the two, and its toolchain edge names the recipe each was computed with. +func aRecipeChangeBuildsAGoModuleAtOnceAndAgainOnceTheAgreementMoves(t *testing.T) { + ix, mods := newFormIndexer(t, mod{"avahi", catalogueManifest(t, "avahi")}) + for _, node := range []string{"novox", "ace"} { + reportToolchain(t, ix, "mesh-build-go", node, "go", "go1.27.1") + } + first, _ := moduleOf(t, mods, "avahi") + if first.FP2 == "" || graphOf(t, ix, "avahi").Toolchains["go"].Recipe != "1" { + t.Fatalf("with the running recipe: fp2 %q, graph %+v", first.FP2, graphOf(t, ix, "avahi")) + } + // The release carrying the new recipe is merged, but the nodes still run the old one: one node reporting the new recipe is + // no strict majority, so the fingerprint stays, nothing waits. + reportToolchainRecipe(t, ix, "mesh-build-go", "novox", "go", "go1.27.1", "2") + if held, _ := moduleOf(t, mods, "avahi"); held.FP2 != first.FP2 || held.None2 != "" { + t.Errorf("one node on the new recipe: fp2 %q none2 %q, want the fingerprint of the running recipe, %q", held.FP2, held.None2, first.FP2) + } + reportToolchainRecipe(t, ix, "mesh-build-go", "ace", "go", "go1.27.1", "2") + again, _ := moduleOf(t, mods, "avahi") + if again.FP2 == "" || again.FP2 == first.FP2 || again.None2 != "" || graphOf(t, ix, "avahi").Toolchains["go"].Recipe != "2" { + t.Errorf("the agreement moved: fp2 %q (was %q) none2 %q graph %+v, want a second fingerprint at recipe 2", again.FP2, first.FP2, again.None2, graphOf(t, ix, "avahi")) + } } func TestNodesReportingDifferentVersionsLeaveTheReasonNamingBoth(t *testing.T) { // 1444/AC6 @@ -182,6 +267,59 @@ func TestBasesAreComputedFirstAndNoFingerprintTakesTheBuilder(t *testing.T) { // if m, _ := moduleOf(t, mods, "other"); m.FP2 != "" || !strings.Contains(m.None2, "ts-base") || !strings.Contains(m.None2, "base") { t.Errorf("other: fp2 = %q none2 = %q, want none2 naming the base ts-base", m.FP2, m.None2) } + // L3 (ADR 0603 §2): the module graph holds each module's edges as this recompute computed them. + goTool := map[string]graph.Toolchain{"go": {Module: "mesh-build-go", Version: "go1.27.1", Recipe: "1"}} + if g := graphOf(t, ix, "avahi"); g.FP != want["avahi"] || !reflect.DeepEqual(g.Bases, []string{"mesh-tools"}) || + !reflect.DeepEqual(g.Toolchains, goTool) || g.Repository != catalogueRepo || g.Path != "modules/avahi" { + t.Errorf("graph.avahi = %+v", g) + } + if g := graphOf(t, ix, "other"); g.FP != "" || !strings.Contains(g.None, "ts-base") || !reflect.DeepEqual(g.Bases, []string{"ts-base"}) { + t.Errorf("graph.other = %+v, want its base and its reason", g) + } + graph := ix.Graph.(*memStore) + written := graph.writes + if err := ix.Recompute(context.Background()); err != nil || graph.writes != written { + t.Errorf("a recompute that moves nothing wrote the graph %d times (%v)", graph.writes-written, err) + } + // A module the modules state no longer holds (a merge dropped it, issue 1900) leaves the graph at the next recompute. + delete(mods.vals, "other") + if err := ix.Recompute(context.Background()); err != nil { + t.Fatal(err) + } + if _, held := graph.vals["other"]; held { + t.Error("graph.other is still held after its module went") + } + if _, held := graph.vals["avahi"]; !held { + t.Error("graph.avahi went with another module") + } + // Review finding 3 (ADR 0603 §2): a graph state not on the bus yet (process.ErrNotYet), or one whose listing fails, never + // fails a recompute; the first writes nothing and says so, the second skips the deletion pass and says so. + graph.vals["ghost"], graph.revs["ghost"] = []byte("x"), 1 + var said []string + ix.Logf = func(format string, args ...any) { said = append(said, fmt.Sprintf(format, args...)) } + for _, row := range []struct { + name string + err error + say string + }{ + {"not on the bus yet", process.ErrNotYet, "not on the bus yet"}, + {"a listing that fails", errors.New("the bus is slow"), "are not deleted"}, + } { + said, graph.keysErr = nil, row.err + if err := ix.Recompute(context.Background()); err != nil { + t.Errorf("%s: the recompute failed: %v", row.name, err) + } + if _, held := graph.vals["ghost"]; !held || !strings.Contains(strings.Join(said, "\n"), row.say) { + t.Errorf("%s: ghost held %v, log %q, want it kept and %q said", row.name, held, said, row.say) + } + } + graph.keysErr = nil + if err := ix.Recompute(context.Background()); err != nil { + t.Fatal(err) + } + if _, held := graph.vals["ghost"]; held { + t.Error("graph.ghost is still held once the graph can be listed") + } a := agentBuild("build-1792587792086548572", agentSrcY, "2026-10-21T15:03:00Z") _ = ix.Builds.Put(a[1].([]byte)) if err := ix.Recompute(context.Background()); err != nil { diff --git a/modules/mesh-delivery-indexer/internal/index/verdicts.go b/modules/mesh-delivery-indexer/internal/index/verdicts.go index 64d5f825..6d79ec5a 100644 --- a/modules/mesh-delivery-indexer/internal/index/verdicts.go +++ b/modules/mesh-delivery-indexer/internal/index/verdicts.go @@ -7,6 +7,8 @@ import ( "fmt" "maps" "slices" + "strings" + "time" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -18,19 +20,23 @@ import ( // The indexer judges every manifest it holds, once, with its own judge (novox/hq ADR 0578 §1): it writes each verdict in // its state verdicts (delivery.VerdictsBucket), one key per manifest digest, only when the verdict differs from the one // held; a module's value in the modules state carries a refusal (Verdict, Problems), so a reader of modules takes it -// without a second read; a new refusal is said as the event manifest-refused. It judges at its start, so a judge that +// without a second read; a new refusal is said as the event manifest-refused; a manifest with a field the judge does not know waits instead (ownBuildIsNewest). It judges at its start, so a judge that // changed (a new indexer) judges every manifest again, and after every merge it takes in, since a rule between manifests // can turn on any of them. Nothing polls. -// VerdictsBucket is the indexer's state of verdicts. +// VerdictsBucket is the indexer's state of verdicts; VerdictsState is its local name, as the tools bundle reads it. var VerdictsBucket = delivery.VerdictsBucket +const VerdictsState = "verdicts" + // Judged is what one pass of the judge did: the manifests judged, the verdicts written, and the modules refused. type Judged struct { Judged, Written int Refused map[string][]string // Outgrown are the modules accepted before whose problems are now warnings (ADR 0579 §1). Outgrown []string + // Waiting are the modules whose manifest carries a field this judge does not know and waits. + Waiting []string } // JudgeAll judges every manifest the modules state names, as one catalogue with the predefined list the indexer keeps, @@ -43,6 +49,10 @@ type Judged struct { // watchman raises as manifest-outgrown. A manifest in fresh (the digests a merge just took in) with no verdict yet, and // one already refused, is judged in full. func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, error) { + // A merge, the start pass and indexer_judge may all judge at once: one pass at a time, so none writes a verdict from + // modules another is rewriting. + ix.judgeMu.Lock() + defer ix.judgeMu.Unlock() var out Judged if ix.Verdicts == nil || ix.Modules == nil || ix.Manifests == nil { return out, nil @@ -80,11 +90,15 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, if ix.OldSections != nil { counts = ix.heldOldSections(ctx) } - refused, unnamed := judge.ShelfCounting(raws, predefined, 0, counts) + refused, waits, unnamed := judge.ShelfWaiting(raws, predefined, 0, counts) for _, p := range unnamed { ix.logf("judge: a problem between the manifests that no one module's removal ends, so it refuses none: %s", p) } out.Refused = refused + // A manifest with a field this judge does not know waits for a judge that may know it (clause 3 of the record on a + // release's Approve; ADR 0578 §1): it is refused only when this program is the newest build of the indexer the trunk + // holds, so no judge that could know the field is still to come. + newest := ix.ownBuildIsNewest(held) id := ix.JudgeID if id == "" { id = judge.Identity() @@ -93,7 +107,21 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, for _, name := range slices.Sorted(maps.Keys(digests)) { out.Judged++ v := delivery.ManifestVerdict{Module: name, State: delivery.VerdictAccepted, Judge: id, At: ix.now()} - if problems := refused[name]; len(problems) > 0 { + if fields := waits[name]; len(fields) > 0 { + accepted := ix.acceptedBefore(ctx, digests[name], fresh) + if !newest && !accepted { + v.State, v.Unknown = delivery.VerdictManifestWaits, fields + out.Waiting = append(out.Waiting, name) + } else { + _, problems := judge.Manifest(raws[name], ix.predefinedList(ctx)) + if accepted { + v.Warnings = problems + out.Outgrown = append(out.Outgrown, name) + } else { + v.State, v.Problems = delivery.VerdictRefused, problems + } + } + } else if problems := refused[name]; len(problems) > 0 { if ix.acceptedBefore(ctx, digests[name], fresh) { v.Warnings = problems out.Outgrown = append(out.Outgrown, name) @@ -117,6 +145,9 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, } if wrote { out.Written++ + if v.Waits() { + ix.logf("judge: %s", v.Why()) + } if v.Refused() { ix.logf("judge: %s", v.Why()) if ix.SayRefused != nil { @@ -142,6 +173,7 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, // JudgeAtStart is JudgeAll of every manifest held, none fresh, as the indexer starts (a judge that changed judges every // one again); one that fails is judged again at the next message or source revision heard (kickJudge). func (ix *Indexer) JudgeAtStart(ctx context.Context) (Judged, error) { + ix.learnOwnBuild(ctx) j, err := ix.JudgeAll(ctx, nil) ix.judgePending.Store(err != nil) return j, err @@ -170,7 +202,7 @@ func (ix *Indexer) acceptedBefore(ctx context.Context, digest string, fresh map[ return !fresh[digest] } prev, err := delivery.DecodeManifestVerdict(value) - return err == nil && !prev.Refused() + return err == nil && prev.State == delivery.VerdictAccepted } // writeVerdict writes v under the digest's key unless the verdict held there says the same (ManifestVerdict.Same); wrote @@ -194,6 +226,36 @@ func (ix *Indexer) writeVerdict(ctx context.Context, digest string, v delivery.M return wrote, err } +// learnOwnBuild asks where the indexer's program is told which build it runs (OwnBuild: the node-engine's seat, never the +// trunk's value of the indexer, which names a build that may be merged and not delivered). Asked at the start pass; where +// it is not answered the build stays unknown and every unknown field waits, which is the safe side. +func (ix *Indexer) learnOwnBuild(ctx context.Context) { + if ix.OwnBuild == nil { + return + } + fp, err := ix.OwnBuild(ctx) + if err != nil || fp == "" { + ix.logf("judge: the build this program runs is not known (%v), so a manifest with a field it does not know waits and is not refused", err) + return + } + ix.ownMu.Lock() + ix.ownFP = fp + ix.ownMu.Unlock() +} + +// ownBuildIsNewest says whether the build of the indexer this program runs (ownFP, from the node-engine) is the newest the +// trunk holds: the indexer's own modules value names it. A newer build merged and not yet delivered ends it at the trunk's +// merge, as one that pins a changed mesh-sdk always is; false when either is unknown. +func (ix *Indexer) ownBuildIsNewest(held map[string]delivery.Module) bool { + fp := held[delivery.IndexerModule].FP2 + if fp == "" { + return false + } + ix.ownMu.Lock() + defer ix.ownMu.Unlock() + return ix.ownFP != "" && ix.ownFP == fp +} + // carryVerdict puts a refusal into the module's value, and takes one out once the manifest is accepted, while the value // still names the digest judged; an accepted module's value carries nothing, so judging writes no value of a module the // judge accepts (a reader takes a value without a verdict as accepted, delivery.TakeManifestVerdict). @@ -210,14 +272,18 @@ func (ix *Indexer) carryVerdict(ctx context.Context, name, digest string, v deli if err != nil || cur.Manifest != digest { return nil, false, nil // gone, unreadable, or moved to another manifest since: the next pass judges it } - state := "" - if v.Refused() { + // A wait is written as a refusal naming the fields, so a reader pinned before the verdict waits holds it as refused. + state, problems := "", v.Problems + switch { + case v.Waits(): + state, problems = delivery.VerdictRefused, []string{v.WaitsOn()} + case v.Refused(): state = delivery.VerdictRefused } - if cur.Verdict == state && slices.Equal(cur.Problems, v.Problems) && slices.Equal(cur.Warnings, v.Warnings) { + if cur.Verdict == state && slices.Equal(cur.Problems, problems) && slices.Equal(cur.Unknown, v.Unknown) && slices.Equal(cur.Warnings, v.Warnings) { return nil, false, nil } - cur.Verdict, cur.Problems, cur.Warnings = state, v.Problems, v.Warnings + cur.Verdict, cur.Problems, cur.Unknown, cur.Warnings = state, problems, v.Unknown, v.Warnings if state == "" { cur.Problems = nil } @@ -247,3 +313,111 @@ func (ix *Indexer) predefinedList(ctx context.Context) catalogue.PredefinedList } return list } + +// The tools that read and steer the verdicts (the instruction "tools are how a module is spoken to"): indexer_verdicts +// lists the manifests that wait or are refused, and indexer_judge judges one module again now. +const ( + VerdictsTool = "indexer_verdicts" + JudgeAgainTool = "indexer_judge" + judgeAgainPrefix = "judge." +) + +// VerdictRow is one verdict as indexer_verdicts lists it: the manifest's digest, the module, its state, the fields it waits +// on or the problems that refuse it, and the judge that made it. +type VerdictRow struct { + Module string `json:"module"` + Digest string `json:"digest"` + State string `json:"state"` + Words string `json:"words,omitempty"` + Unknown []string `json:"unknown,omitempty"` + Problems []string `json:"problems,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Judge string `json:"judge"` + At time.Time `json:"at"` +} + +// ListVerdicts reads the verdicts under keys with get and lists those of module (every module when empty) that wait or are +// refused, or every one with all, sorted by module and digest. A value that does not read is said in unread by its key. +func ListVerdicts(keys []string, get func(key string) ([]byte, error), module string, all bool) (rows []VerdictRow, unread map[string]string) { + unread = map[string]string{} + for _, key := range keys { + value, err := get(key) + if err != nil || value == nil { + if err != nil { + unread[key] = err.Error() + } + continue + } + v, err := delivery.DecodeManifestVerdict(value) + if err != nil { + unread[key] = err.Error() + continue + } + if (module != "" && v.Module != module) || (!all && !v.Held()) { + continue + } + rows = append(rows, VerdictRow{Module: v.Module, Digest: key, State: v.State, Words: v.Why(), Unknown: v.Unknown, + Problems: v.Problems, Warnings: v.Warnings, Judge: v.Judge, At: v.At}) + } + slices.SortFunc(rows, func(a, b VerdictRow) int { + if c := strings.Compare(a.Module, b.Module); c != 0 { + return c + } + return strings.Compare(a.Digest, b.Digest) + }) + return rows, unread +} + +// JudgeAgain judges the manifests again now, as at the start, and answers how the module's stands: the tool +// indexer_judge. A module the modules state does not hold, or whose manifest is not in the manifests state, is +// answered so. +func (ix *Indexer) JudgeAgain(ctx context.Context, module string) TakenIn { + out := TakenIn{Module: module} + held, err := ix.heldModules(ctx) + if err != nil { + out.Answer = "the modules state cannot be read: " + err.Error() + return out + } + m, ok := held[module] + if !ok || m.Manifest == "" { + out.Answer = "the indexer holds no manifest of " + module + return out + } + // The build this program runs is asked again, so a start pass that missed the engine's answer is recovered without a restart. + ix.learnOwnBuild(ctx) + if _, err := ix.JudgeAll(ctx, nil); err != nil { + out.Answer = "the manifests could not be judged: " + err.Error() + return out + } + value, _, found, err := ix.Verdicts.Get(ctx, m.Manifest) + if err != nil || !found { + out.Answer = "judged, but the verdict on " + module + " cannot be read" + return out + } + v, err := delivery.DecodeManifestVerdict(value) + if err != nil { + out.Answer = "judged, but the verdict on " + module + " cannot be read: " + err.Error() + return out + } + out.Stored = true + out.Answer = "judged again by " + v.Judge + ": " + v.State + if v.Held() { + out.Answer += " (" + v.Why() + ")" + } + return out +} + +// NewJudgeAgain is the value indexer_judge writes for a module: its key in the takeins state and the value. +func NewJudgeAgain(module, why, by string, at time.Time) (string, []byte, error) { + key, data, err := NewTakeIn(module, why, by, at) + if err != nil { + return "", nil, err + } + var a TakeInAsk + if err := json.Unmarshal(data, &a); err != nil { + return "", nil, err + } + a.Judge = true + data, err = json.Marshal(a) + return judgeAgainPrefix + key, data, err +} diff --git a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go index 2e84f954..051fe168 100644 --- a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go +++ b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go @@ -76,6 +76,11 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { if j, err := ix.JudgeAll(ctx, nil); err != nil || j.Written != 0 || len(said) != 1 { t.Fatalf("a judge that judges alike: %+v %v, said %d", j, err, len(said)) } + // ADR 0603 §4 (D9): a source revision that changes the indexer builds the new indexer, which at its start judges every + // manifest it holds again, none of them fresh: all of them judged, nothing written for a verdict that stands. + if j, err := ix.JudgeAtStart(ctx); err != nil || j.Judged != 2 || j.Written != 0 || len(said) != 1 { + t.Fatalf("the new indexer at its start: %+v %v, said %d, want both manifests judged and nothing written", j, err, len(said)) + } // The manifest mended: its new digest is accepted and the module's refusal goes. mended := hold("bad", `{"module":"bad","version":"2"}`) if j, err := ix.JudgeAll(ctx, map[string]bool{mended: true}); err != nil || j.Written != 1 || len(j.Refused) != 0 { @@ -196,6 +201,85 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { t.Fatalf("a count that could not be read: %+v, logged %q", v, logged) } ix.OldSections = held + // Clause 3 of the record on a release's Approve: a manifest with a field this judge does not know waits (no refusal, no + // event) while a newer build of the indexer is on the trunk, and is judged again at the next judge; once this program's + // build is the trunk's newest it is refused; a wait is written into the module's value as a refusal naming the fields. + ownFP := "src2:" + strings.Repeat("e", 64) + hold(delivery.IndexerModule, `{"module":"mesh-delivery-indexer","version":"1"}`) + ix.ownFP = ownFP // this program started when the trunk held another build of the indexer than the one it holds now + later := hold("later", `{"module":"later","version":"1","a-field-from-later":1,"another":{"x":1}}`) + said = nil + before := len(said) + j, err = ix.JudgeAll(ctx, map[string]bool{later: true}) + if err != nil || len(j.Waiting) != 1 || j.Waiting[0] != "later" || len(said) != before { + t.Fatalf("a manifest with fields from later: %+v %v, said %d", j, err, len(said)) + } + if v := verdict(later); !v.Waits() || v.Refused() || strings.Join(v.Unknown, ",") != "a-field-from-later,another" { + t.Fatalf("later: %+v", v) + } + if m := module("later"); m.Verdict != delivery.VerdictRefused || len(m.Unknown) != 2 || !m.VerdictOf("later").Waits() || + !strings.Contains(m.Problems[0], "does not know yet") { + t.Fatalf("modules.later carries %+v", m) + } + // The build this program runs is asked of the engine, and the trunk moving before the start pass changes nothing: the + // trunk's value of the indexer names a newer build than the one the engine says runs, so the field still waits. + ix.ownFP = "" + ix.OwnBuild = func(context.Context) (string, error) { return ownFP, nil } + if _, err := ix.JudgeAtStart(ctx); err != nil || !verdict(later).Waits() { + t.Fatalf("the trunk moved before the start pass: %v %+v", err, verdict(later)) + } + ix.OwnBuild = func(context.Context) (string, error) { return "", errors.New("the engine does not answer") } + ix.ownFP = "" + if _, err := ix.JudgeAtStart(ctx); err != nil || !verdict(later).Waits() { + t.Fatalf("an unknown build refused: %v %+v", err, verdict(later)) + } + ix.ownFP = ownFP + // A renamed field is a known mistake: refused at once. + renamed := hold("renamed", `{"module":"renamed","version":"1","needs":{"x":"y"}}`) + if j, err := ix.JudgeAll(ctx, map[string]bool{renamed: true}); err != nil || len(j.Waiting) != 1 || !verdict(renamed).Refused() { + t.Fatalf("a renamed field: %+v %v", j, err) + } + // The trunk's newest build of the indexer is this program's: the field is one no judge to come knows. + ix.ownFP = "src2:" + strings.Repeat("a", 64) + if j, err := ix.JudgeAll(ctx, nil); err != nil || len(j.Waiting) != 0 || !verdict(later).Refused() || !strings.Contains(verdict(later).Problems[0], "a-field-from-later") { + t.Fatalf("the newest build refuses: %+v %v %+v", j, err, verdict(later)) + } + if m := module("later"); m.Verdict != delivery.VerdictRefused || m.VerdictOf("later").Waits() || len(m.Unknown) != 0 { + t.Fatalf("modules.later after the refusal carries %+v", m) + } + // The tools: indexer_verdicts lists the verdicts that wait or are refused (and every one with all), indexer_judge + // judges now and says how the module's verdict stands. + keys, _ := ix.Verdicts.Keys(ctx) + get := func(key string) ([]byte, error) { v, _, _, err := ix.Verdicts.Get(ctx, key); return v, err } + if rows, unread := ListVerdicts(keys, get, "later", false); len(rows) != 1 || rows[0].State != delivery.VerdictRefused || len(unread) != 0 { + t.Fatalf("indexer_verdicts of later: %+v %v", rows, unread) + } + if rows, _ := ListVerdicts(keys, get, "", true); len(rows) <= len(keys)-3 || rows[0].Module > rows[len(rows)-1].Module { + t.Fatalf("indexer_verdicts, all: %d rows of %d verdicts", len(rows), len(keys)) + } + // indexer_judge asks the engine for the running build again: a start pass that missed it is recovered (the trunk's value of + // the indexer is the newer one, so the field waits; asked and answered with that same build it is refused). + ix.ownFP = "" + ix.OwnBuild = func(context.Context) (string, error) { return "src2:" + strings.Repeat("a", 64), nil } + if got := ix.JudgeAgain(ctx, "later"); !got.Stored || !strings.Contains(got.Answer, "refused") || ix.ownFP == "" { + t.Fatalf("indexer_judge did not learn the running build: %+v", got) + } + ix.ownFP = ownFP + ix.OwnBuild = nil + if got := ix.JudgeAgain(ctx, "later"); !got.Stored || !strings.Contains(got.Answer, "waits") || !strings.Contains(got.Answer, "a-field-from-later") { + t.Fatalf("indexer_judge: %+v", got) + } + if rows, _ := ListVerdicts(keys, get, "later", false); len(rows) != 1 { + t.Fatalf("indexer_verdicts of later after: %+v", rows) + } + if got := ix.JudgeAgain(ctx, "nobody"); got.Stored || !strings.Contains(got.Answer, "holds no manifest") { + t.Fatalf("indexer_judge of nobody: %+v", got) + } + if key, value, err := NewJudgeAgain("later", "why", "me", time.Now()); err != nil || key != "judge.later" { + t.Fatalf("the ask: %q %v", key, err) + } else if a, err := DecodeTakeIn(value); err != nil || !a.Judge || a.Module != "later" { + t.Fatalf("the ask's value: %+v %v", a, err) + } // Without the verdicts state the indexer judges nothing. ix.Verdicts = nil if j, err := ix.JudgeAll(ctx, nil); err != nil || j.Judged != 0 { diff --git a/modules/mesh-delivery-indexer/judge/judge.go b/modules/mesh-delivery-indexer/judge/judge.go index 32ae12ca..b41c6ba0 100644 --- a/modules/mesh-delivery-indexer/judge/judge.go +++ b/modules/mesh-delivery-indexer/judge/judge.go @@ -73,11 +73,29 @@ func Shelf(raws map[string][]byte, predefined catalogue.PredefinedList, longestM } // ShelfCounting is Shelf also refusing a manifest of a module outside the foundation that uses an old section whose count -// is 0 (OldSectionProblems; issue 1966 D4); nil counts refuse none. +// is 0 (OldSectionProblems; issue 1966 D4); nil counts refuse none. A field this judge does not know is refused, strictly, as +// the checker judges a change: the judge that runs there is the one built at the change's head, which knows the fields the +// change brings, so a field it does not know is a mistake. func ShelfCounting(raws map[string][]byte, predefined catalogue.PredefinedList, longestMachine int, counts OldSectionCounts) (refused map[string][]string, unnamed []string) { - refused = map[string][]string{} + refused, waits, unnamed := ShelfWaiting(raws, predefined, longestMachine, counts) + for name := range waits { + _, refused[name] = Manifest(raws[name], predefined) + } + return refused, unnamed +} + +// ShelfWaiting is ShelfCounting where a manifest that carries a field this judge does not know (UnknownFields) is not +// refused: it is in waits, by module, naming each field, and judged no further, because a newer judge may know the field +// (novox/hq ADR 0578 §1, amended). The indexer judges so; the checker, which runs the judge built at the head of the change, +// judges with Shelf. +func ShelfWaiting(raws map[string][]byte, predefined catalogue.PredefinedList, longestMachine int, counts OldSectionCounts) (refused, waits map[string][]string, unnamed []string) { + refused, waits = map[string][]string{}, map[string][]string{} shelf := catalogue.Shelf{} for name, raw := range raws { + if unknown := UnknownFields(raw); len(unknown) > 0 { + waits[name] = unknown + continue + } m, problems := Manifest(raw, predefined) if len(problems) > 0 { refused[name] = problems @@ -132,7 +150,7 @@ func ShelfCounting(raws map[string][]byte, predefined catalogue.PredefinedList, for name := range refused { sort.Strings(refused[name]) } - return refused, unnamed + return refused, waits, unnamed } // Between is every problem between the manifests of shelf, sorted: catalogue.CatalogueProblems, IdentityProblems over diff --git a/modules/mesh-delivery-indexer/judge/judge_test.go b/modules/mesh-delivery-indexer/judge/judge_test.go index 406be32e..27caf578 100644 --- a/modules/mesh-delivery-indexer/judge/judge_test.go +++ b/modules/mesh-delivery-indexer/judge/judge_test.go @@ -54,6 +54,21 @@ func TestTheJudge(t *testing.T) { } } } + // A field the judge does not know waits in the indexer's judge and stays a refusal in the checker's (clause 3 of the + // record on a release's Approve); a renamed field and any other problem are refused by both. + waitRaws := map[string][]byte{ + "later": []byte(`{"module":"later","version":"1","later-field":true,"other":1}`), + "renamed": []byte(`{"module":"renamed","version":"1","needs":{"x":"y"}}`), + "bad": []byte(`{"module":"bad","version":"1","provides":[{"name":"amqp"}]}`), + "ok": []byte(`{"module":"ok","version":"1"}`), + } + refused, waits, _ := ShelfWaiting(waitRaws, nil, 0, nil) + if !reflect.DeepEqual(waits, map[string][]string{"later": {"later-field", "other"}}) || len(refused) != 2 || refused["renamed"] == nil || refused["bad"] == nil { + t.Errorf("ShelfWaiting: waits %v, refused %v", waits, refused) + } + if strict, _ := Shelf(waitRaws, nil, 0); len(strict) != 3 || !strings.Contains(strings.Join(strict["later"], ""), "later-field") { + t.Errorf("Shelf refuses as before: %v", strict) + } // The private network's manifest, which the mesh provides itself, is accepted (moved from mesh-sdk overlay's test). if raw, err := json.Marshal(overlay.Manifest()); err != nil { t.Fatal(err) diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go index 13334227..734c5730 100644 --- a/modules/mesh-delivery-indexer/judge/rules.go +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -136,7 +136,7 @@ func spokenSystems() string { // and was simply absent — seven modules' tools went missing that way on 2026-10-03. Refused here, // naming the field that would deliver it. func undeliveredBundles(m catalogue.Manifest) []string { - if m.Build == nil || m.Module == catalogue.RuntimeModule { + if m.Build == nil || catalogue.IsToolRunner(m) { return nil } named := runByAResource(m) @@ -2388,6 +2388,44 @@ func ownSecretPromptProblems(m catalogue.Manifest, name, prompt string, family b return problems } +// unknownFieldText is how the lenient decoder words the first field its shape does not have, and readWithout the list of +// every field it read the manifest without (catalogue.Manifest.SetAside). +var ( + unknownFieldText = regexp.MustCompile(`json: unknown field "([^"]+)"`) + readWithout = regexp.MustCompile(`\(read without ([^)]*)\)`) +) + +// UnknownFields are the fields of a manifest the judge does not know, as the lenient decoder sets them aside (catalogue.Decode, +// each once, the path it names them with), or none: a manifest this judge reads whole has none, and a field that used to +// mean something (renamed) is a known mistake, not a field a later judge may know, so a manifest with one has none +// either. +func UnknownFields(raw []byte) []string { + m, err := catalogue.Decode(raw) + aside := m.SetAside() + if err != nil || aside == "" { + return nil + } + first := unknownFieldText.FindStringSubmatch(aside) + if first == nil { + return nil // set aside for another reason: the judge's refusal as it is + } + fields := []string{first[1]} + if list := readWithout.FindStringSubmatch(aside); list != nil { + fields = fields[:0] + for _, f := range strings.Split(list[1], ", ") { + if f = strings.TrimSpace(f); f != "" && !slices.Contains(fields, f) { + fields = append(fields, f) + } + } + } + for _, f := range fields { + if _, was := renamed[f[strings.LastIndex(f, ".")+1:]]; was { + return nil + } + } + return fields +} + func parseManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.Manifest, error) { var m catalogue.Manifest // Strictly. **An unknown key is refused**, which is the discipline the host's declaration @@ -3268,9 +3306,9 @@ func waitProblems(m catalogue.Manifest) []string { if len(m.Waits) == 0 { return nil } - if m.Module != catalogue.RuntimeModule { - return []string{fmt.Sprintf("%s says what a session waits on; only %s, the tool runner's module, "+ - "declares waits (novox/hq ADR 0367 §2)", m.Module, catalogue.RuntimeModule)} + 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)} } var problems []string seen := map[string]bool{} @@ -5382,12 +5420,29 @@ func StateProblems(m catalogue.Manifest) []string { return problems } -// PredefinedFormProblems refuses each stream a module declares that StreamsKeptUntilMoved does not name for it. +// **A module keeps its data in the bus's predefined forms** (the lead and the operator, 2026-10-29; the operator's rule of +// 2026-10-22, "data storage is done using our predefined nats bus options"): a state, a log or an object store, which the +// runtime reads and writes for its tools and other modules read by a declared `reads`. A stream of its own is outside +// them and refused, naming it. streamsKeptUntilMoved are the streams declared before this check, each allowed by name +// until its module moves it; the list only shrinks. The judge's own exception, kept here and no longer in mesh-sdk, +// which names no module (issue 774, the operator 2026-11-03). +var streamsKeptUntilMoved = map[string][]string{ + "brother": {"device-asks"}, + "mesh-build-go": {"toolchains-go"}, + "mesh-build-node": {"toolchains-node"}, + "mesh-mail": {"mail", "mail-sends"}, + "only-office": {"convert-asks"}, + "mesh-delivery-assigner": {"placement"}, + "mesh-delivery-checker": {"checks"}, + "mesh-delivery-forge-bridge": {"forge"}, +} + +// PredefinedFormProblems refuses each stream a module declares that streamsKeptUntilMoved does not name for it. func PredefinedFormProblems(m catalogue.Manifest) []string { var out []string for _, s := range m.Streams { // A stream that keeps its history is one of the bus's predefined forms (novox/hq ADR 0592 §6). - if !s.KeepsHistory() && !slices.Contains(catalogue.StreamsKeptUntilMoved[m.Module], s.Name) { + if !s.KeepsHistory() && !slices.Contains(streamsKeptUntilMoved[m.Module], s.Name) { out = append(out, fmt.Sprintf("%s keeps the stream %q, outside the bus's predefined forms: keep it as a state, "+ "a log or an object store, which the runtime reads for the module's tools and others read by `reads`", m.Module, s.Name)) } @@ -7407,7 +7462,8 @@ var defaultSeats = append([]catalogue.Seat{ // images and archives, by digest — which is why the provision is the artifact store and not an // image registry. {Name: "mesh-artifact-store", Scope: catalogue.ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"}, - {Name: "mesh-catalog", Scope: catalogue.ScopeMesh, Decision: "novox/hq ADR 0121"}, + // The catalog (novox/hq ADR 0604): the SDK's entry, its four verbs optional while the module that claimed it before stands. + {Name: "mesh-catalog", Scope: catalogue.ScopeMesh, Serves: catalogVerbs(), Decision: "novox/hq ADR 0604"}, // The delivery's owner (novox/hq ADR 0239): one commit's journey from its pull request's head to every // machine, and a group of them, recorded and ordered by its holder, which asks the controller for each act. // What it says it says as its module (`mesh-delivery.transition`), through the runtime that launches it. @@ -7826,6 +7882,26 @@ func configInput() map[string]any { return in } +// catalogVerbs are the catalog seat's four verbs, as the SDK's seat serves them (catalogue.CatalogSeat). +func catalogVerbs() []catalogue.Verb { + return []catalogue.Verb{ + {Name: "modules", Optional: true, Description: "Every module the trunk holds, each with its repository, its " + + "directory there, the merge commit it was last changed at and its fingerprint (or why it has none): the " + + "catalog of the mesh, read from its holder's state. Reads only.", + Input: schema(map[string]string{}, nil)}, + {Name: "module", Optional: true, Description: "One module whole: its repository, directory, commit and " + + "fingerprint, and its manifest as the trunk holds it. Older versions are in its repository's history. Reads only.", + Input: schema(map[string]string{"module": "the module's name"}, []string{"module"})}, + {Name: "provides", Optional: true, Description: "Which modules provide a provision (a module provides its own " + + "name too) and which require it, from the module graph's provision edges. Reads only.", + Input: schema(map[string]string{"provision": "the provision, such as postgres-database"}, []string{"provision"})}, + {Name: "dependents", Optional: true, Description: "The modules whose build edges reach a module, directly or " + + "through others (a base, a mesh package it publishes, the build module that builds them): what a change of it " + + "builds again, from the module graph. Reads only.", + Input: schema(map[string]string{"module": "the module's name"}, []string{"module"})}, + } +} + // deliveryVerbs are the delivery seat's tools: what reads the merges and checks, and the acts of a person and healer H2 // on a merge (ADR 0539 clause 17). func deliveryVerbs() []catalogue.Verb { diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index a036e926..5e3c4325 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -157,12 +157,18 @@ func TestABundleNothingDeliversIsRefused(t *testing.T) { run := base() run.Resources = []map[string]any{{"id": "daemon", "type": "process", "artifact": "tools", "run": []any{"node", "tools/index.js"}}} runtime := base() - runtime.Module = catalogue.RuntimeModule + runtime.Module, runtime.ToolRunner = "node-tools", true for name, m := range map[string]catalogue.Manifest{"loads": loads, "tools": tools, "a process": run, "the runtime": runtime} { if p := undeliveredBundles(m); len(p) != 0 { t.Errorf("a bundle delivered by %s was refused: %v", name, p) } } + // The tool runner is what says it is one (issue 774): its name alone exempts nothing. + named := base() + named.Module = "node-tools" + if p := undeliveredBundles(named); len(p) != 1 { + t.Errorf("a bundle of a module named as the tool runner, which does not say it is one: %v", p) + } } func TestABundlesWordsAreRefusedWhenTheyAreNotPathsOrConstants(t *testing.T) { @@ -3108,15 +3114,16 @@ func TestAVerbWithAnUnknownKeyIsRefused(t *testing.T) { // each refusal is said in the manifest's words, at parse, rather than at the composition of the bus's users. func TestAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner(t *testing.T) { for manifest, want := range map[string]string{ - `{"module":"shop","version":"1","waits":["mesh-knowledge.reviewed"]}`: "only node-tools", - `{"module":"node-tools","version":"1","waits":["mesh-knowledge.*"]}`: "never a wildcard", - `{"module":"node-tools","version":"1","waits":["**"]}`: "never a wildcard", - `{"module":"node-tools","version":"1","waits":["mesh-knowledge.>"]}`: "never a wildcard", - `{"module":"node-tools","version":"1","waits":["*.reviewed"]}`: "never a wildcard", - `{"module":"node-tools","version":"1","waits":["reviewed"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1","waits":["mesh-knowledge..reviewed"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1","waits":["Mesh-Knowledge.reviewed x"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1","waits":["a.b","a.b"]}`: "twice", + `{"module":"shop","version":"1","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module", + `{"module":"node-tools","version":"1","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module", // its name says nothing (issue 774) + `{"module":"node-tools","version":"1","tool-runner":true,"waits":["mesh-knowledge.*"]}`: "never a wildcard", + `{"module":"node-tools","version":"1","tool-runner":true,"waits":["**"]}`: "never a wildcard", + `{"module":"node-tools","version":"1","tool-runner":true,"waits":["mesh-knowledge.>"]}`: "never a wildcard", + `{"module":"node-tools","version":"1","tool-runner":true,"waits":["*.reviewed"]}`: "never a wildcard", + `{"module":"node-tools","version":"1","tool-runner":true,"waits":["reviewed"]}`: "does not name an emitter and an event", + `{"module":"node-tools","version":"1","tool-runner":true,"waits":["mesh-knowledge..reviewed"]}`: "does not name an emitter and an event", + `{"module":"node-tools","version":"1","tool-runner":true,"waits":["Mesh-Knowledge.reviewed x"]}`: "does not name an emitter and an event", + `{"module":"node-tools","version":"1","tool-runner":true,"waits":["a.b","a.b"]}`: "twice", } { _, err := ParseManifest([]byte(manifest)) if err == nil { @@ -4818,6 +4825,17 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { []string{"placement", "proposals", "propose", "withdraw", "again"}) { t.Errorf("the placement seat is %v, %v", ok, s) } + // The judge's copy of the catalog seat is the SDK's entry exactly (ADR 0604 §1): four optional verbs, so the claim of the + // module that held the seat before is not outgrown while both stand. + if sdk, ok := catalogue.SeatNamed(catalogue.CatalogSeat); !ok || len(sdk.Serves) != 4 { + t.Errorf("the SDK's catalog seat is %v, %+v", ok, sdk) + } else { + for _, s := range defaultSeats { + if s.Name == catalogue.CatalogSeat && !reflect.DeepEqual(s, sdk) { + t.Errorf("the judge's copy of the catalog seat is %+v, want the SDK's %+v", s, sdk) + } + } + } // The checker's seat serves the try, which replaces the shell's merge check (ADR 0547 §10, ADR 0245). var checkerVerbs []string for _, s := range catalogue.Seats() { @@ -5171,22 +5189,20 @@ func TestWhereAModulesUpgradePolicyComesFrom(t *testing.T) { catalogue.PolicyRoll, catalogue.FromDefault}, {"irreplaceable but says roll", catalogue.Manifest{Module: "photos", Upgrade: &catalogue.UpgradePolicy{Policy: catalogue.PolicyRoll}, Data: &catalogue.Data{Own: []catalogue.DataItem{{ID: "originals", Class: catalogue.ClassIrreplaceable}}}}, catalogue.PolicyRoll, catalogue.FromModule}, - {"the bus, whatever it says", catalogue.Manifest{Module: "nats", Provides: []catalogue.Offer{{Name: "mesh-bus"}}, - Upgrade: &catalogue.UpgradePolicy{Policy: catalogue.PolicyRoll}}, catalogue.PolicyRecord, catalogue.FromBus}, + // Issue 774 (the operator, 2026-11-03): the bus has the policy its manifest says, as any module. + {"the bus says record", catalogue.Manifest{Module: "nats", Provides: []catalogue.Offer{{Name: "mesh-bus"}}, + Upgrade: &catalogue.UpgradePolicy{Policy: catalogue.PolicyRecord, Why: "its streams"}}, catalogue.PolicyRecord, catalogue.FromModule}, } for _, c := range cases { policy, from, _ := catalogue.DerivedUpgrade(c.m) if policy != c.policy || from != c.from { t.Errorf("%s: %s from %s, want %s from %s", c.name, policy, from, c.policy, c.from) } - // ADR 0565 clause 7 (issue 1953 L5-S3): a person's choice wins over each source but the bus; none leaves the - // derivation as it was. + // ADR 0565 clause 7 (issue 1953 L5-S3): a person's choice wins over each source; none leaves the derivation as it + // was. The bus is no exception (ADR 0601 §1). person := &catalogue.PersonPolicy{V: catalogue.PersonPolicyVersion, Module: c.m.Module, Policy: catalogue.PolicyTogether, Why: "one zone", By: "person.jochen"} policy, from, _, by := catalogue.Upgrade(c.m, person) wantPolicy, wantFrom, wantBy := catalogue.PolicyTogether, catalogue.FromPerson, "person.jochen" - if c.from == catalogue.FromBus { - wantPolicy, wantFrom, wantBy = c.policy, catalogue.FromBus, "" - } if policy != wantPolicy || from != wantFrom || by != wantBy { t.Errorf("%s with a person's choice: %s from %s by %q", c.name, policy, from, by) } diff --git a/modules/mesh-delivery-indexer/manifest_test.go b/modules/mesh-delivery-indexer/manifest_test.go index c6bbbf90..7569b44c 100644 --- a/modules/mesh-delivery-indexer/manifest_test.go +++ b/modules/mesh-delivery-indexer/manifest_test.go @@ -13,6 +13,8 @@ import ( "testing" "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" ) // The controller's manifest reader is mesh-controller internal/catalogue (Manifest, manifest.go: Consumes :521, @@ -90,7 +92,8 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build t.Errorf("emits = %v, want manifest-refused and manifest-accepted", m.Emits) } // 1126 note 76: heard keeps the last 64 FORGE messages the indexer handled, for indexer_heard. - if want := []state{{Name: "candidates", History: 10}, {Name: "heard", History: 64}, {Name: "manifests", History: 1}, {Name: "modules", History: 20}, {Name: "predefined", History: 5}, {Name: "config-candidates", History: 5}, {Name: "taken", History: 1}, {Name: "takeins", History: 1}, {Name: "verdicts", History: 5}, {Name: "oldsections", History: 1}, {Name: delivery.PackagesState, History: delivery.PackagesHistory}}; !reflect.DeepEqual(m.State, want) { + if want := []state{{Name: "candidates", History: 10}, {Name: "heard", History: 64}, {Name: "manifests", History: 1}, {Name: "modules", History: 20}, {Name: "predefined", History: 5}, {Name: "config-candidates", History: 5}, {Name: "taken", History: 1}, {Name: "takeins", History: 1}, {Name: "verdicts", History: 5}, {Name: "oldsections", History: 1}, {Name: delivery.PackagesState, History: delivery.PackagesHistory}, + {Name: graph.State, History: graph.History}}; !reflect.DeepEqual(m.State, want) { t.Errorf("state = %+v, want %+v", m.State, want) } if want := []string{"mesh-build-go.toolchains", "mesh-build-node.toolchains", "mesh-delivery-assigner.placement", "mesh-delivery-builder.build-log", "mesh-delivery-checker.previews", "mesh-delivery-gate.source"}; !reflect.DeepEqual(sorted(m.Reads), want) { @@ -110,12 +113,13 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build // Three invokes: the file of a registered module's manifest and lock files (registered.go), the forge's trees (the named paths' objects, since the indexer is the one reader of a manifest's paths: // ADR 0499 clauses 2 and 3, ADR 0495 clause 1; and the module directories of the trunk, issue 1900) and whether a // commit is in another (issue 1900). - if want := []string{"gitea.gitea_commit_trees", "gitea.gitea_contains", "gitea.gitea_get_file", "gitea.gitea_open_pulls"}; !reflect.DeepEqual(m.Invokes, want) { + if want := []string{"gitea.gitea_commit_trees", "gitea.gitea_contains", "gitea.gitea_get_file", "gitea.gitea_open_pulls", "seat:node-engine.composition"}; !reflect.DeepEqual(m.Invokes, want) { t.Errorf("invokes = %v, want %v", m.Invokes, want) } - // 1945: indexer_take_in, served by its tools bundle through the takeins state; the process serves none. 1966/AC11: - // indexer_old_sections reads the count of the old sections (state oldsections). - if want := []string{"indexer_take_in", "indexer_old_sections"}; !reflect.DeepEqual(m.Tools, want) { + // 1945: indexer_take_in, served by its tools bundle through the takeins state; the process serves none. The same bundle + // reads the verdicts state (indexer_verdicts) and asks the indexer to judge again through the takeins state + // (indexer_judge; issue 774, clause 3). 1966/AC11: indexer_old_sections reads the count of the old sections (state oldsections). + if want := []string{"indexer_take_in", "indexer_old_sections", "indexer_verdicts", "indexer_judge"}; !reflect.DeepEqual(m.Tools, want) { t.Errorf("tools = %v, want %v", m.Tools, want) } // No streams, no bus, no seat (ADR 0437 §4); it says no event. diff --git a/modules/mesh-delivery-indexer/module.json b/modules/mesh-delivery-indexer/module.json index 313afc81..11fac677 100644 --- a/modules/mesh-delivery-indexer/module.json +++ b/modules/mesh-delivery-indexer/module.json @@ -4,7 +4,9 @@ "slug": "indexer", "tools": [ "indexer_take_in", - "indexer_old_sections" + "indexer_old_sections", + "indexer_verdicts", + "indexer_judge" ], "consumes": [ "mesh-build-go.toolchain.reported", @@ -23,7 +25,8 @@ { "name": "takeins", "history": 1 }, { "name": "verdicts", "history": 5 }, { "name": "oldsections", "history": 1 }, - { "name": "packages", "history": 5 } + { "name": "packages", "history": 5 }, + { "name": "graph", "history": 5 } ], "emits": [ "manifest-refused", @@ -41,7 +44,8 @@ "gitea.gitea_commit_trees", "gitea.gitea_contains", "gitea.gitea_get_file", - "gitea.gitea_open_pulls" + "gitea.gitea_open_pulls", + "seat:node-engine.composition" ], "own-secrets": { "broker": "${dir:state}/broker" diff --git a/modules/mesh-delivery-teller/go.mod b/modules/mesh-delivery-teller/go.mod index b27cca6b..28754187 100644 --- a/modules/mesh-delivery-teller/go.mod +++ b/modules/mesh-delivery-teller/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-teller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-teller/go.sum b/modules/mesh-delivery-teller/go.sum index 43cc4ec7..abad2969 100644 --- a/modules/mesh-delivery-teller/go.sum +++ b/modules/mesh-delivery-teller/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go index d795395b..d147a802 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go @@ -14,6 +14,9 @@ import ( "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/sdk/graph" + "git.novox.be/novox/mesh-sdk/go/provisions" + stdio "git.novox.be/novox/mesh-sdk/go" ) @@ -43,6 +46,16 @@ func Tools(r view.Reader, u view.UsageReader) []stdio.Tool { module, _ := args["module"].(string) return r.Reach(repository, module) }}, + {Name: view.TiersTool, + Description: "A source revision's tiers, worked out now and never stored (novox/hq ADR 0603 §6): the modules the revision moves " + + "(each whose fingerprint last changed at it), ordered by the module graph (a module after the bases and the providers " + + "of the provisions it awaits among them), and for each module what it waits on now and why: its build key, on each node " + + "that wants it its want (the assigner's rule and words when held back), its have, and each provision it awaits. Nodes " + + "the watchman says are offline are named away and left out. At most 500 modules, said when cut short; said when the " + + "module graph cannot be read. Give revision for an older one, none for the gate's newest. Read-only.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "revision": map[string]any{"type": "integer", "description": "the source revision; none for the gate's newest"}}}, + Run: func(args map[string]any) (any, error) { return r.TiersOf(args) }}, {Name: view.ModuleHistoryTool, Description: "The history of one module's value in the indexer's modules state (modules.), with times: each value it " + "still keeps (up to 20), newest first, with its revision, when it was written, put or del, the repository and commit " + @@ -307,7 +320,9 @@ func main() { PredefinedState: read(stdio.State(delivery.IndexerModule + "." + delivery.IndexerPredefinedState)), ConfigCandidatesState: read(stdio.State(delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState)), PackagesState: read(stdio.State(delivery.IndexerModule + "." + delivery.PackagesState)), - BuildLog: stdio.Log(delivery.BuildLogRead).Since} + GraphState: read(stdio.State(graph.Read)), SourceState: read(stdio.State(delivery.GateModule + ".source")), + ProvisionsState: read(stdio.State(provisions.ProvisionsRead)), + BuildLog: stdio.Log(delivery.BuildLogRead).Since} u := view.UsageReader{Haves: r.HavesState, Resolved: read(stdio.State("mesh-config.resolved")), Tries: read(stdio.State("mesh-delivery-checker.tries")), Steps: map[string]view.StateRead{}, Pauses: map[string]view.StateRead{}, Backlog: backlogOf(socket())} for _, lang := range delivery.AskLanguages { diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go index 4d54278c..144d9d30 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go @@ -55,6 +55,7 @@ const ( // 1562/AC1: indexer_module answers the key's value as stored: FP, FP2, None2, Inputs2, Path and Toolchains are all there. func TestModuleToolAnswersTheStoredValue(t *testing.T) { t.Run("indexer_packages answers the published modules", packagesToolAnswersThePublishedModules) + t.Run("view_tiers names each module's wait", tiersToolSaysEachModulesWait) r := fake{"a": withFP2, "b": withNone}.reader() for key, want := range map[string]string{"a": withFP2, "b": withNone} { out, err := run(t, r, view.ModuleReadTool, map[string]any{"module": key}) diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go new file mode 100644 index 00000000..6236315b --- /dev/null +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" + "git.novox.be/novox/mesh-sdk/go/provisions" + + "mesh-delivery-view/internal/view" +) + +// encoded is v as its contract's encoder writes it, failing the test when it refuses. +func encoded(t *testing.T, v []byte, err error) string { + t.Helper() + if err != nil { + t.Fatal(err) + } + return string(v) +} + +// tiersToolSaysEachModulesWait: view_tiers orders a source revision's modules by the module graph and names each one's +// wait (ADR 0603 §6): a consumer after its provider, held by the assigner's provision rule and its provision not provided; +// a module whose build waits for a package; a provider with no wait; a node offline named away; older modules left out. +func tiersToolSaysEachModulesWait(t *testing.T) { + fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) } + manifest := strings.Repeat("d", 64) + mod := func(f string, src uint64) string { + b, err := delivery.EncodeModule(delivery.Module{FP2: f, Manifest: manifest, Src: src, FPSrc: src, Repository: "novox/mesh-catalog", Commit: "c"}) + return encoded(t, b, err) + } + node := func(g graph.Node) string { + b, err := graph.Encode(g) + return encoded(t, b, err) + } + build := func(b delivery.BuilderBuild) string { + v, err := delivery.EncodeBuilderBuild(b) + return encoded(t, v, err) + } + want := func(w delivery.Want) string { + v, err := delivery.EncodeWant(w) + return encoded(t, v, err) + } + have := func(h delivery.Have) string { + v, err := delivery.EncodeHave(h) + return encoded(t, v, err) + } + key := func(k string, err error) string { + if err != nil { + t.Fatal(err) + } + return k + } + since := time.Date(2026, 11, 3, 16, 40, 0, 0, time.UTC) + r := fake{"db": mod(fp("a"), 9), "web": mod(fp("b"), 9), "lib": mod(fp("c"), 9), "old": mod(fp("e"), 5)}.reader() + r.GraphState = fake{ + "db": node(graph.Node{FP: fp("a"), Repository: "novox/mesh-catalog", Provides: []string{"database"}}), + "web": node(graph.Node{FP: fp("b"), Repository: "novox/mesh-catalog", Requires: []string{"database"}, Awaits: []string{"database"}}), + "lib": node(graph.Node{FP: fp("c"), Repository: "novox/mesh-catalog"}), + }.reader().State + r.Builds = fake{ + key(delivery.BuilderBuildKey("db", fp("a"))): build(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: manifest}), + key(delivery.BuilderBuildKey("web", fp("b"))): build(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: manifest}), + key(delivery.BuilderBuildKey("lib", fp("c"))): build(delivery.BuilderBuild{State: delivery.BuildQueued, Waits: "@novox/x@1.2.0"}), + }.reader().State + r.WantsState = fake{ + key(delivery.WantKey("ace", "db")): want(delivery.Want{FP: fp("a"), Self: true}), + key(delivery.WantKey("ace", "web")): want(delivery.Want{Self: true, FP: fp("f"), Newest: fp("b"), Rule: delivery.RuleProvisions, Words: "the provision ace.web.database of web on ace is asked, not provided"}), + key(delivery.WantKey("g14", "web")): want(delivery.Want{Self: true, FP: fp("f"), Newest: fp("b"), Rule: delivery.RuleCanary, Words: "the canary"}), + }.reader().State + r.WantsState.History = func(string) ([]view.Revision, error) { return []view.Revision{{At: since}}, nil } + r.HavesState = fake{key(delivery.HaveKey("ace", "db")): have(delivery.Have{FP: fp("a"), Phase: delivery.PhaseRunning, Health: delivery.HealthOK})}.reader().State + p, err := provisions.Encode(provisions.Provision{Provision: "database", Consumer: provisions.End{Module: "web", Node: "ace"}, + Provider: provisions.End{Module: "db", Node: "ace"}, State: provisions.Asked, Why: "the build db runs does not offer it", At: since}) + r.ProvisionsState = fake{key(provisions.Key("ace", "web", "database", "")): encoded(t, p, err)}.reader().State + off, err := delivery.EncodePresence(delivery.Presence{Online: false}) + r.PresenceState = fake{key(delivery.PresenceNodeKey("g14")): encoded(t, off, err)}.reader().State + + out, err := run(t, r, view.TiersTool, map[string]any{"revision": float64(9)}) + got, _ := out.(view.Tiers) + if err != nil || got.Revision != 9 || got.Modules != 3 || len(got.Tiers) != 2 || !reflect.DeepEqual(got.Away, []string{"g14"}) || got.Graph != "" { + t.Fatalf("view_tiers 9 = %+v, %v", out, err) + } + first := fmt.Sprint(got.Tiers[0]) + if len(got.Tiers[0]) != 2 || got.Tiers[0][0].Module != "db" || len(got.Tiers[0][0].Waits) != 0 || got.Tiers[0][1].Module != "lib" || + len(got.Tiers[0][1].Waits) != 1 || !strings.Contains(got.Tiers[0][1].Waits[0].Words, "waiting for the package @novox/x@1.2.0") { + t.Errorf("the first tier = %s, want db with no wait and lib's build waiting for its package", first) + } + web := got.Tiers[1][0] + if web.Module != "web" || !reflect.DeepEqual(web.After, []string{"db"}) || len(web.Waits) != 2 || + web.Waits[0].Edge != "want" || web.Waits[0].Node != "ace" || web.Waits[0].Rule != delivery.RuleProvisions || !web.Waits[0].Since.Equal(since) || + web.Waits[1].Edge != "provision database" || !strings.Contains(web.Waits[1].Words, "asked: the build db runs does not offer it") { + t.Errorf("web = %+v, want after db, held on ace by the provision rule and its provision asked (g14 is away)", web) + } + // No revision named: the gate's newest, its source key's revision. + r.SourceState = view.StateRead{GetEntry: func(string) (json.RawMessage, uint64, error) { return json.RawMessage(`{}`), 9, nil }} + if out, err := run(t, r, view.TiersTool, map[string]any{}); err != nil || out.(view.Tiers).Revision != 9 || out.(view.Tiers).Modules != 3 { + t.Errorf("view_tiers of the gate's newest = %+v, %v", out, err) + } + // The graph not readable: every module in the first tier, said; past 500 modules the answer says it is cut short. + r.GraphState = view.StateRead{Keys: func() ([]string, error) { return nil, errors.New("no bucket") }, Get: r.GraphState.Get} + if out, err := run(t, r, view.TiersTool, map[string]any{"revision": float64(9)}); err != nil || len(out.(view.Tiers).Tiers) != 1 || + !strings.Contains(out.(view.Tiers).Graph, "cannot be read") { + t.Errorf("view_tiers with no graph = %+v, %v", out, err) + } + many := fake{} + for i := 0; i < view.TiersMost+2; i++ { + many[fmt.Sprintf("m%03d", i)] = mod(fp("a"), 9) + } + r.State = many.reader().State + if out, err := run(t, r, view.TiersTool, map[string]any{"revision": "9"}); err != nil || out.(view.Tiers).Modules != view.TiersMost+2 || + !strings.Contains(out.(view.Tiers).Cut, "the first 500") { + t.Errorf("view_tiers past its bound = %+v, %v", out.(view.Tiers).Cut, err) + } +} diff --git a/modules/mesh-delivery-view/go.mod b/modules/mesh-delivery-view/go.mod index faf8b6ba..ff85b24b 100644 --- a/modules/mesh-delivery-view/go.mod +++ b/modules/mesh-delivery-view/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-view go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) @@ -20,3 +20,7 @@ require ( golang.org/x/sys v0.48.0 // indirect golang.org/x/time v0.15.0 // indirect ) + +require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk diff --git a/modules/mesh-delivery-view/go.sum b/modules/mesh-delivery-view/go.sum index 43cc4ec7..abad2969 100644 --- a/modules/mesh-delivery-view/go.sum +++ b/modules/mesh-delivery-view/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-view/internal/view/reach.go b/modules/mesh-delivery-view/internal/view/reach.go index 303fb5d8..1f68ce6e 100644 --- a/modules/mesh-delivery-view/internal/view/reach.go +++ b/modules/mesh-delivery-view/internal/view/reach.go @@ -202,6 +202,7 @@ func (m *Model) moduleReach(name string, mod delivery.Module, heads []headAt, wa mr.Changes = append(mr.Changes, c) } var waiting []string + away := m.awayNodes() floor, floorNode, unset := -1, "", "" for _, key := range wantKeys { node, _ := splitKey(key) @@ -209,6 +210,11 @@ func (m *Model) moduleReach(name string, mod delivery.Module, heads []headAt, wa if w.err != "" || w.value.Removed || w.value.FP == "" { continue } + if away[node] { + // A node away holds no head back (ADR 0603 §7); it catches up by its wants and counts once it runs them. + waiting = append(waiting, fmt.Sprintf("%s on %s is away", name, node)) + continue + } mr.Nodes++ h, ok := m.haves[key] switch { diff --git a/modules/mesh-delivery-view/internal/view/reach_test.go b/modules/mesh-delivery-view/internal/view/reach_test.go index 236a1697..69284aef 100644 --- a/modules/mesh-delivery-view/internal/view/reach_test.go +++ b/modules/mesh-delivery-view/internal/view/reach_test.go @@ -13,7 +13,7 @@ import ( func TestTheViewDerivesHowFarEachRepositoryReached(t *testing.T) { const web, sdk = "novox/web", "novox/sdk" commitA, commitB, commitC := strings.Repeat("a", 40), strings.Repeat("b", 40), strings.Repeat("c", 40) - commitS, commitX := strings.Repeat("5", 40), strings.Repeat("e", 40) + commitS, commitX, commitD := strings.Repeat("5", 40), strings.Repeat("e", 40), strings.Repeat("d", 40) const fpMail = "src2:3333333333333333333333333333333333333333333333333333333333333333" mod := func(module, fp, commit string, src uint64, history bool) Entry { v, err := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: strings.Repeat("a", 64), Repository: web, Commit: commit, Src: src, Path: module}) @@ -68,6 +68,11 @@ func TestTheViewDerivesHowFarEachRepositoryReached(t *testing.T) { {name: "every node runs the newest build", reached: commitC}, {name: "a node lagging stops the head before the change it lacks", reached: commitB, holds: "web on g14", then: []Entry{haveFP(t, "g14", "web", fpOld, delivery.HealthOK)}, waiting: "not yet the"}, + // ADR 0603 §7: a node away holds no head back, is named, and holds it again once it returns still behind. + {name: "a node away lagging holds no head back and is named", reached: commitC, waiting: "g14 is away", + then: []Entry{haveFP(t, "g14", "web", fpOld, delivery.HealthOK), presenceE(t, "g14", false, t0)}}, + {name: "a node returned lagging holds the head again", reached: commitB, holds: "web on g14", waiting: "not yet the", + then: []Entry{haveFP(t, "g14", "web", fpOld, delivery.HealthOK), presenceE(t, "g14", false, t0), presenceE(t, "g14", true, t0)}}, {name: "a want held back at an older build holds the head as a lag does", reached: commitB, holds: "web on g14 runs the build of", then: []Entry{wantE(t, "g14", "web", delivery.Want{FP: fpOld, Newest: fpNew, Rule: 2}), haveFP(t, "g14", "web", fpOld, delivery.HealthOK)}}, {name: "a failing health check is said and holds nothing", reached: commitC, waiting: "health check failed", @@ -84,6 +89,18 @@ func TestTheViewDerivesHowFarEachRepositoryReached(t *testing.T) { then: []Entry{taken(web, commitB)}}, {name: "a change whose source revision is not known yet holds no head the source named", reached: commitC, then: []Entry{mod("web", "src2:"+strings.Repeat("9", 64), commitX, 0, true), mod("web", "src2:"+strings.Repeat("9", 64), commitX, 0, false)}}, + // 1126 note 94: a merge that removes the repository's last module.json moves nothing; once the indexer dropped its + // modules and took the merge in, the repository reaches it, held by nothing, as the library with no module does. + {name: "a merge that removed the last module is reached at the merge the indexer took in", reached: commitD, + then: func() []Entry { + head := src(13, commitD) + current := head + current.History = false + gone := func(module string, history bool) Entry { + return Entry{Bucket: delivery.ModulesBucket, Key: module, Deleted: true, At: t0, History: history} + } + return []Entry{head, current, gone("web", true), gone("mail", true), gone("web", false), gone("mail", false), taken(web, commitD)} + }()}, // 1126: the view started while the bus did not answer its writes; its reach and its event are made at the next change it // takes in, though the derivation is the same, so the state reach never keeps an older have than node_haves says. {name: "a write and an event refused at the start are made at the next change", reached: commitC, refused: true, diff --git a/modules/mesh-delivery-view/internal/view/read.go b/modules/mesh-delivery-view/internal/view/read.go index d9af163e..5da2b0bb 100644 --- a/modules/mesh-delivery-view/internal/view/read.go +++ b/modules/mesh-delivery-view/internal/view/read.go @@ -55,6 +55,9 @@ type Reader struct { PredefinedState, ConfigCandidatesState StateRead // PackagesState is the indexer's published packages (delivery.PackagesBucket, ADR 0593 §2); unset reads none. PackagesState StateRead + // GraphState is the indexer's module graph (graph.Bucket, ADR 0603 §2), SourceState the gate's source and + // ProvisionsState the provisioner's keys: what view_tiers reads (tiers.go). Unset reads none, said in the answer. + GraphState, SourceState, ProvisionsState StateRead // BuildLog reads the builder's log from a time (stdio.Log(delivery.BuildLogRead).Since); unset reads none. BuildLog LogSince } diff --git a/modules/mesh-delivery-view/internal/view/tiers.go b/modules/mesh-delivery-view/internal/view/tiers.go new file mode 100644 index 00000000..8055ad38 --- /dev/null +++ b/modules/mesh-delivery-view/internal/view/tiers.go @@ -0,0 +1,385 @@ +package view + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + "strconv" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" + "git.novox.be/novox/mesh-sdk/go/provisions" +) + +// The tiers of a source revision are a view, worked out at each call and never stored (novox/hq ADR 0603 §1, §6): the +// modules the revision moves, ordered by the module graph's ordering edges among them (a base, a provision awaited from +// another of them), and for each module what it waits on now: its build key, each node's want and have, and each +// provision it awaits on each node, with the rule and the words of the part that holds it. Nothing in the mesh waits on +// a tier; the tiers say where the revision stands. A node the watchman says is offline is named away and left out of +// what the revision waits on (ADR 0603 §7). + +// TiersTool is the view's read tool of a source revision's tiers. +const TiersTool = "view_tiers" + +// TiersMost is the most modules view_tiers answers (ADR 0603 §2); past it the answer says how many it left out. +const TiersMost = 500 + +// Tiers is a source revision's tiers: Revision the source revision (the gate's source key's revision), Modules how many +// modules it moves (each whose fingerprint last changed at it, delivery.Module.FPSrc), Tiers those modules in order, the +// first tier waiting on none of the others. Cut says when the answer holds fewer than Modules, Graph why the module graph +// could not be read (then every module is in the first tier), Cycle a cycle among them (its modules form the last +// tier), and Away the nodes left out. +type Tiers struct { + Revision uint64 `json:"revision"` + Modules int `json:"modules"` + Tiers [][]TierModule `json:"tiers"` + Cut string `json:"cut,omitempty"` + Graph string `json:"graph,omitempty"` + Cycle []string `json:"cycle,omitempty"` + Away []string `json:"away,omitempty"` +} + +// TierModule is one module of a tier: its fingerprint, the modules of the same revision it waits for (After), and each +// wait of its own now; no wait is a module that reached every node present. +type TierModule struct { + Module string `json:"module"` + FP string `json:"fp,omitempty"` + None string `json:"none,omitempty"` + After []string `json:"after,omitempty"` + Waits []TierWait `json:"waits,omitempty"` +} + +// TierWait is one thing a module waits on: the edge (build, want, have, or provision ), the node it waits on (none +// for its build), the assigner's rule for a want held back, the part's words and since when, where the part says it. +type TierWait struct { + Edge string `json:"edge"` + Node string `json:"node,omitempty"` + Rule int `json:"rule,omitempty"` + Words string `json:"words"` + Since time.Time `json:"since,omitzero"` +} + +// Tiers works out the tiers of a source revision, the gate's newest when revision is 0. +func (r Reader) Tiers(revision uint64) (Tiers, error) { + if revision == 0 { + if r.SourceState.GetEntry == nil { + return Tiers{}, fmt.Errorf("the gate's source cannot be read here: name the source revision") + } + _, rev, err := r.SourceState.GetEntry(delivery.SourceMainKey) + if err != nil { + return Tiers{}, fmt.Errorf("the gate's source: %w", err) + } + if rev == 0 { + return Tiers{}, fmt.Errorf("the gate holds no source revision yet") + } + revision = rev + } + modules, err := values(r.State) + if err != nil { + return Tiers{}, fmt.Errorf("the indexer's modules: %w", err) + } + moved := map[string]delivery.Module{} + for key, raw := range modules { + if m, err := delivery.DecodeModule(raw); err == nil && m.FPSrc == revision { + moved[key] = m + } + } + out := Tiers{Revision: revision, Modules: len(moved)} + names := slices.Sorted(maps.Keys(moved)) + if len(names) > TiersMost { + out.Cut = fmt.Sprintf("the revision moves %d modules: the first %d by name are shown, %s and the %d after it are not", + len(names), TiersMost, names[TiersMost], len(names)-TiersMost-1) + names = names[:TiersMost] + } + gr := graph.Graph{} + if raws, err := values(r.GraphState); err != nil { + out.Graph = "the module graph cannot be read, so no module is ordered after another: " + err.Error() + } else { + for key, raw := range raws { + if n, err := graph.Decode(raw); err == nil { + gr[key] = n + } + } + } + away := r.away() + out.Away = slices.Sorted(maps.Keys(away)) + wants, _ := values(r.WantsState) + provided, perr := values(r.ProvisionsState) + + // The ordering among the revision's modules: each one's tier is one past the highest of the modules it waits for. + in := map[string]bool{} + for _, n := range names { + in[n] = true + } + after := map[string][]string{} + for _, n := range names { + for _, b := range gr.Before(n) { + if in[b] { + after[n] = append(after[n], b) + } + } + } + sub := graph.Graph{} + for _, n := range names { + if g, ok := gr[n]; ok { + sub[n] = g + } + } + out.Cycle = sub.Cycle() + tier := map[string]int{} + var level func(string, map[string]bool) int + level = func(n string, visiting map[string]bool) int { + if t, ok := tier[n]; ok { + return t + } + if visiting[n] { + return -1 + } + visiting[n] = true + t := 0 + for _, b := range after[n] { + lb := level(b, visiting) + if lb < 0 { + return -1 + } + t = max(t, lb+1) + } + delete(visiting, n) + tier[n] = t + return t + } + top := 0 + for _, n := range names { + if t := level(n, map[string]bool{}); t > top { + top = t + } + } + for _, n := range names { + if _, ok := tier[n]; !ok { + tier[n] = top + 1 // on a cycle: after every tier that is ordered + } + } + for _, n := range names { + t := tier[n] + for len(out.Tiers) <= t { + out.Tiers = append(out.Tiers, nil) + } + m := moved[n] + tm := TierModule{Module: n, FP: m.FP2, None: m.None2, After: after[n]} + tm.Waits = r.waitsOf(n, m, gr[n], wants, provided, perr, away) + out.Tiers[t] = append(out.Tiers[t], tm) + } + compact := out.Tiers[:0] + for _, t := range out.Tiers { + if len(t) > 0 { + compact = append(compact, t) + } + } + out.Tiers = compact + return out, nil +} + +// away are the nodes the watchman's presence says are offline. +func (r Reader) away() map[string]bool { + out := map[string]bool{} + presence, err := values(r.PresenceState) + if err != nil { + return out + } + for key, raw := range presence { + node, part, _, err := delivery.ParsePresenceKey(key) + if err != nil || part != "" { + continue + } + if p, err := delivery.DecodePresence(raw); err == nil && !p.Online { + out[node] = true + } + } + return out +} + +// waitsOf is what module m waits on now: its build at its fingerprint, then on each node present that wants it, its want, +// its have, and each provision its graph node awaits. +func (r Reader) waitsOf(module string, m delivery.Module, g graph.Node, wants, provided map[string]json.RawMessage, + perr error, away map[string]bool) []TierWait { + var out []TierWait + if m.FP2 == "" { + return []TierWait{{Edge: "build", Words: "it has no fingerprint, so nothing is built: " + m.None2}} + } + if w, ok := r.buildWait(module, m.FP2); ok { + out = append(out, w) + } + var nodes []string + for key := range wants { + node, mod, err := delivery.ParseWantKey(key) + if err == nil && mod == module { + nodes = append(nodes, node) + } + } + slices.Sort(nodes) + for _, node := range nodes { + if away[node] { + continue + } + w, err := delivery.DecodeWant(wants[mustWantKey(node, module)]) + if err != nil || w.Removed { + continue + } + if w.FP != m.FP2 { + words := w.Words + if words == "" { + words = "the want names " + short(w.FP) + ", not " + short(m.FP2) + } + out = append(out, TierWait{Edge: "want", Node: node, Rule: w.Rule, Words: words, Since: r.since(r.WantsState, mustWantKey(node, module))}) + } else if hw, ok := r.haveWait(node, module, m.FP2); ok { + out = append(out, hw) + } + for _, a := range g.Awaits { + out = append(out, provisionWaits(node, module, a, provided, perr)...) + } + } + return out +} + +func mustWantKey(node, module string) string { + k, _ := delivery.WantKey(node, module) + return k +} + +// buildWait is the wait of the build key ., none when it is done. +func (r Reader) buildWait(module, fp string) (TierWait, bool) { + key, err := delivery.BuilderBuildKey(module, fp) + if err != nil || r.Builds.Get == nil { + return TierWait{Edge: "build", Words: "the builder's builds cannot be read here"}, true + } + raw, err := r.Builds.Get(key) + switch { + case err != nil: + return TierWait{Edge: "build", Words: "the build key " + key + " cannot be read: " + err.Error()}, true + case raw == nil: + return TierWait{Edge: "build", Words: "the builder holds no build of " + short(fp) + " yet"}, true + } + b, err := delivery.DecodeBuilderBuild(raw) + if err != nil { + return TierWait{Edge: "build", Words: "the build key " + key + " does not read: " + err.Error()}, true + } + switch b.State { + case delivery.BuildDone: + return TierWait{}, false + case delivery.BuildFailed: + return TierWait{Edge: "build", Words: "the build of " + short(fp) + " failed: " + b.Error}, true + } + words := "the build of " + short(fp) + " is " + b.State + if b.Waits != "" { + words += ", waiting for the package " + b.Waits + } + if b.Step != "" { + words += ": " + b.Step + } + return TierWait{Edge: "build", Words: words, Since: r.since(r.Builds, key)}, true +} + +// haveWait is the wait of node's have of module at fp, none when it runs fp healthy. +func (r Reader) haveWait(node, module, fp string) (TierWait, bool) { + key, err := delivery.HaveKey(node, module) + if err != nil || r.HavesState.Get == nil { + return TierWait{}, false + } + raw, err := r.HavesState.Get(key) + if err != nil || raw == nil { + return TierWait{Edge: "have", Node: node, Words: node + " has not yet said what it runs of " + module}, true + } + h, err := delivery.DecodeHave(raw) + if err != nil { + return TierWait{Edge: "have", Node: node, Words: "its have does not read: " + err.Error()}, true + } + if h.FP == fp && h.Phase == delivery.PhaseRunning && h.Health == delivery.HealthOK { + return TierWait{}, false + } + words := fmt.Sprintf("%s runs %s, %s", node, short(h.FP), h.Phase) + if h.Health != "" { + words += ", health " + string(h.Health) + } + if h.Detail != "" { + words += ": " + h.Detail + } + return TierWait{Edge: "have", Node: node, Words: words, Since: r.since(r.HavesState, key)}, true +} + +// provisionWaits are the waits of the provision a that module awaits on node: none once every key of it is provided. +func provisionWaits(node, module, a string, provided map[string]json.RawMessage, perr error) []TierWait { + edge := "provision " + a + if perr != nil { + return []TierWait{{Edge: edge, Node: node, Words: "the provisioner's keys cannot be read: " + perr.Error()}} + } + prefix, _ := provisions.Key(node, module, a, "") + var out []TierWait + found := false + for _, key := range slices.Sorted(maps.Keys(provided)) { + if key != prefix && !strings.HasPrefix(key, prefix+".") { + continue + } + found = true + p, err := provisions.Decode(provided[key]) + if err != nil { + out = append(out, TierWait{Edge: edge, Node: node, Words: "the provision key " + key + " does not read: " + err.Error()}) + continue + } + if p.State == provisions.Provided { + continue + } + words := "the provision " + key + " is " + p.State + if p.Why != "" { + words += ": " + p.Why + } + out = append(out, TierWait{Edge: edge, Node: node, Words: words, Since: p.At}) + } + if !found { + out = append(out, TierWait{Edge: edge, Node: node, Words: "the provisioner holds no key of " + a + " for " + module + " on " + node + " yet"}) + } + return out +} + +// since is when key's value was written, from the state's history, or zero when it cannot say. +func (r Reader) since(s StateRead, key string) time.Time { + if s.History == nil { + return time.Time{} + } + revs, err := s.History(key) + if err != nil || len(revs) == 0 { + return time.Time{} + } + return revs[len(revs)-1].At +} + +// tiersRevision reads the tool's revision argument: a number, as JSON gives it or as text; 0 for the gate's newest. +func tiersRevision(v any) (uint64, error) { + switch x := v.(type) { + case nil: + return 0, nil + case float64: + if x < 0 || x != float64(uint64(x)) { + return 0, fmt.Errorf("the revision %v is no source revision", x) + } + return uint64(x), nil + case string: + if strings.TrimSpace(x) == "" { + return 0, nil + } + return strconv.ParseUint(strings.TrimSpace(x), 10, 64) + } + return 0, fmt.Errorf("the revision %v is no source revision", v) +} + +// TiersOf is view_tiers' answer for its arguments. +func (r Reader) TiersOf(args map[string]any) (any, error) { + revision, err := tiersRevision(args["revision"]) + if err != nil { + return nil, err + } + return r.Tiers(revision) +} diff --git a/modules/mesh-delivery-view/internal/view/view.go b/modules/mesh-delivery-view/internal/view/view.go index a447fe76..1b3cd75b 100644 --- a/modules/mesh-delivery-view/internal/view/view.go +++ b/modules/mesh-delivery-view/internal/view/view.go @@ -124,6 +124,9 @@ type SourceRow struct { Waiting []string `json:"waiting,omitempty"` // Wants is how many wants of modules at the indexer were looked at; a revision with none is delivered of nothing. Wants int `json:"wants"` + // Away are the nodes the watchman says are offline, left out of the comparison (ADR 0603 §7): the revision counts for them + // once they return and run their wants. + Away []string `json:"away,omitempty"` } // AskRow is an open ask (novox/hq ADR 0437 §10): the gate's ask with no answer under its key in the teller's answers. @@ -1061,7 +1064,8 @@ func (m *Model) wantRows() []WantRow { // sourceRow derives the state of the source revision the gate holds (ADR 0429 §1; recorded by no part): delivered when every want // equals its module's fingerprint at the indexer and every have equals its want, running and healthy, and every node a module is -// assigned to has a want. It is nil when the gate holds no readable source. +// assigned to has a want, compared only on the nodes present: a node the watchman says is offline is named away and left out +// (ADR 0603 §7, changing ADR 0429 §1). It is nil when the gate holds no readable source. func (m *Model) sourceRow() *SourceRow { if m.source == nil || m.source.err != "" { return nil @@ -1075,8 +1079,12 @@ func (m *Model) sourceRow() *SourceRow { return fp } looked := 0 + away := m.awayNodes() for key, w := range m.wants { node, module := splitKey(key) + if away[node] { + continue + } mod, ok := m.modules[module] if w.err != "" || w.value.Removed || !ok || mod.err != "" || mod.value.FP2 == "" { continue @@ -1096,7 +1104,7 @@ func (m *Model) sourceRow() *SourceRow { } for _, a := range m.placedPairs() { wk, werr := delivery.HaveKey(a.Node, a.Module) - if werr != nil { + if werr != nil || away[a.Node] { continue } if _, ok := m.wants[wk]; ok { @@ -1107,13 +1115,28 @@ func (m *Model) sourceRow() *SourceRow { } } sort.Strings(waiting) - row := &SourceRow{Revision: m.source.revision, State: SourceDelivered, Wants: looked} + row := &SourceRow{Revision: m.source.revision, State: SourceDelivered, Wants: looked, Away: slices.Sorted(maps.Keys(away))} if len(waiting) > 0 { row.State, row.Waiting = SourceNotDelivered, waiting } return row } +// awayNodes are the nodes whose presence the watchman says is offline (a node's own key, not a part instance's): a source +// revision is delivered over the nodes present (ADR 0603 §7). Called with the lock held. +func (m *Model) awayNodes() map[string]bool { + out := map[string]bool{} + for key, p := range m.presence { + if p.err != "" || p.value.Online { + continue + } + if node, part, _, err := delivery.ParsePresenceKey(key); err == nil && part == "" { + out[node] = true + } + } + return out +} + // splitKey is the node and the module (unescaped, issue 1150) of a model key, which is the have bucket's key form. func splitKey(key string) (node, module string) { node, module, isNode, err := delivery.ParseHaveKey(key) diff --git a/modules/mesh-delivery-view/internal/view/wants_test.go b/modules/mesh-delivery-view/internal/view/wants_test.go index 07ad6dcd..e545bfe2 100644 --- a/modules/mesh-delivery-view/internal/view/wants_test.go +++ b/modules/mesh-delivery-view/internal/view/wants_test.go @@ -124,6 +124,16 @@ func TestASourceRevisionIsDerivedFromWantsAndHaves(t *testing.T) { if s.State != SourceNotDelivered || !strings.Contains(strings.Join(s.Waiting, ";"), "novox") { t.Errorf("a node assigned the module with no want: %+v", s) } + // ADR 0603 §7: a node away holds nothing back and is named; it counts once it returns and runs its wants. + fill(m, presenceE(t, "novox", false, t0)) + s = m.Snapshot().Source + if s.State != SourceDelivered || len(s.Away) != 1 || s.Away[0] != "novox" { + t.Errorf("a node away: %+v", s) + } + fill(m, presenceE(t, "novox", true, t0)) + if s = m.Snapshot().Source; s.State != SourceNotDelivered || len(s.Away) != 0 { + t.Errorf("the node returned and runs nothing yet: %+v", s) + } } func TestNoSourceRevisionHeldMeansNoStateIsDerived(t *testing.T) { diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index eb5d1e2c..4e65b88f 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -87,7 +87,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 if m.Module != "mesh-delivery-view" { t.Errorf("module = %q", m.Module) } - want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-conditions.conditions", "mesh-config.resolved", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-assigner.placement", "mesh-delivery-assigner.wants", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.config-candidates", "mesh-delivery-indexer.heard", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.packages", "mesh-delivery-indexer.predefined", "mesh-delivery-indexer.taken", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-healers.attempts", "mesh-vault.signers", "node-engine.have"} // view_status reads the keeper's conditions and the healers' attempts (1952/V1); build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 + want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-conditions.conditions", "mesh-config.resolved", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-assigner.placement", "mesh-delivery-assigner.wants", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.config-candidates", "mesh-delivery-indexer.graph", "mesh-delivery-indexer.heard", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.packages", "mesh-delivery-indexer.predefined", "mesh-delivery-indexer.taken", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-healers.attempts", "mesh-provisioner.provisions", "mesh-vault.signers", "node-engine.have"} // view_tiers reads the module graph and the provisioner's keys (ADR 0603 §6); view_status reads the keeper's conditions and the healers' attempts (1952/V1); build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 got := slices.Clone(m.Reads) slices.Sort(got) if !reflect.DeepEqual(got, want) { @@ -119,7 +119,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 } // The two read tools of the indexer's modules (issue 1562), carried by a bundle the runtime launches; the indexer // itself serves no tool (ADR 0437 §4). Carrying them gives the tool runner read access to every bucket the view reads. - if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "indexer_packages", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard"}; !reflect.DeepEqual(m.Tools, want) { + if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "indexer_packages", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard", "view_tiers"}; !reflect.DeepEqual(m.Tools, want) { t.Errorf("tools = %v, want %v", m.Tools, want) } for _, a := range m.Build.Artifacts { diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index 8ba20327..55bbbfb6 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -23,7 +23,8 @@ "build_usage", "view_reach", "indexer_module_history", - "indexer_heard" + "indexer_heard", + "view_tiers" ], "emits": [ "delivered", @@ -54,6 +55,7 @@ "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.config-candidates", + "mesh-delivery-indexer.graph", "mesh-delivery-indexer.heard", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.packages", @@ -62,6 +64,7 @@ "mesh-delivery-teller.answers", "mesh-conditions.conditions", "mesh-healers.attempts", + "mesh-provisioner.provisions", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-vault.signers", diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go index 1ded4bd7..8ec5142d 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go @@ -246,6 +246,17 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro w.ReadWhole(delivery.AssignerWantsBucket) } } + // The builder's builds (ADR 0603 §8): a key queued behind a package is a wait, mirrored from the time it was written. + var buildSeen jetstream.KeyWatcher + if kv := process.OptionalBucket(ctx, js, delivery.BuilderBuildsBucket, "a build waiting for a package is not watched until the watchman starts again", logger.Printf); kv != nil { + buildEach := func(e jetstream.KeyValueEntry) { w.Build(e.Key(), e.Value(), opOf(e), e.Created()) } + if buildSeen, err = settle(ctx, kv, buildEach); err != nil { + process.NotWatched(logger.Printf, delivery.BuilderBuildsBucket, err, "a build waiting for a package is not watched until the watchman starts again") + buildSeen = nil + } else { + w.ReadWhole(delivery.BuilderBuildsBucket) + } + } // The build seats' steps: a step key `.` written is the ask taken (issue 1887 A8b). A seat whose build module // keeps no steps bucket (yet) is said and left; the asks of its stream are mirrored all the same. taken := map[string]jetstream.KeyWatcher{} @@ -304,7 +315,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // The seat mesh-provisioner's provisions (ADR 0570 §7): a provision in waits is needs-operator. Read before the findings are // reconciled when the bucket is there; bound once it appears when it is not (mesh-provisioner comes in its own delivery). provisionsWatch := func(kv jetstream.KeyValue) (jetstream.KeyWatcher, error) { - return settle(ctx, kv, func(e jetstream.KeyValueEntry) { w.Provision(e.Key(), e.Value(), opOf(e)) }) + pw, err := settle(ctx, kv, func(e jetstream.KeyValueEntry) { w.Provision(e.Key(), e.Value(), opOf(e)) }) + if err == nil { + w.ReadWhole(provisions.ProvisionsBucket) + } + return pw, err } var provWatch jetstream.KeyWatcher if kv, err := js.KeyValue(ctx, provisions.ProvisionsBucket); err == nil { @@ -395,6 +410,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro errs <- follow(ctx, wantSeen, func(e jetstream.KeyValueEntry) { w.Want(e.Key(), e.Value(), opOf(e), e.Created()) }) }() } + if buildSeen != nil { + go func() { + errs <- follow(ctx, buildSeen, func(e jetstream.KeyValueEntry) { w.Build(e.Key(), e.Value(), opOf(e), e.Created()) }) + }() + } for lang, sw := range taken { lang, sw := lang, sw go func() { errs <- follow(ctx, sw, func(e jetstream.KeyValueEntry) { stepTaken(w, lang, e) }) }() diff --git a/modules/mesh-delivery-watchman/go.mod b/modules/mesh-delivery-watchman/go.mod index 9eafb632..b7c67621 100644 --- a/modules/mesh-delivery-watchman/go.mod +++ b/modules/mesh-delivery-watchman/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-watchman go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-watchman/go.sum b/modules/mesh-delivery-watchman/go.sum index 1582a407..2f163f06 100644 --- a/modules/mesh-delivery-watchman/go.sum +++ b/modules/mesh-delivery-watchman/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-watchman/internal/watch/findings_test.go b/modules/mesh-delivery-watchman/internal/watch/findings_test.go index 573abe74..40e308b2 100644 --- a/modules/mesh-delivery-watchman/internal/watch/findings_test.go +++ b/modules/mesh-delivery-watchman/internal/watch/findings_test.go @@ -59,6 +59,41 @@ func TestTheWatchmansFindings(t *testing.T) { t.Fatalf("findings = %v", o.findings) } w.ModuleVerdict("node-tools", value(""), Put) + // Clause 3 of the record on a release's Approve: a manifest that waits for a judge that knows its fields is its own + // warning (a refusal in the value, naming the fields), not manifest-refused, and goes when it is accepted. + waiting, _ := delivery.EncodeModule(delivery.Module{FP2: "src2:" + strings.Repeat("a", 64), Manifest: strings.Repeat("b", 64), + Repository: "novox/mesh-catalog", Commit: "c", Verdict: delivery.VerdictRefused, Problems: []string{"waits"}, Unknown: []string{"core-credentials"}}) + w.ModuleVerdict("nats", waiting, Put) + if f := o.findings[FindingKey(ScopeModule, "nats", KindManifestWaits)]; f.Severity != Warning || !strings.Contains(f.Summary, "core-credentials") { + t.Fatalf("findings = %v", o.findings) + } + if _, refused := o.findings[FindingKey(ScopeModule, "nats", KindManifestRefused)]; refused { + t.Fatalf("a waiting manifest was found refused: %v", o.findings) + } + w.ModuleVerdict("nats", value(""), Put) + }, map[string]string{}}, + {"ADR 0601 §4: what a node keeps of a module as it runs, for a manifest that does not say it yet, is a warning until the have says none", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) { + have := func(kept ...string) []byte { + b, err := delivery.EncodeHave(delivery.Have{FP: "src2:" + strings.Repeat("a", 64), Phase: delivery.PhaseRunning, Health: delivery.HealthOK, Kept: kept}) + if err != nil { + t.Fatal(err) + } + return b + } + k := FindingKey(ScopeModule, "runner-x.ace", "kept") + w.Have("ace.runner-x", have("runner-x runs the tool runner's process here"), Put) + f := o.findings[k] + if f.Kind != KindKeptAsRuns || f.Severity != Warning || !strings.Contains(f.Summary, "the tool runner's process") { + t.Fatalf("findings = %v", o.findings) + } + c.t = c.t.Add(time.Minute) + w.Have("ace.runner-x", have("runner-x runs the tool runner's process here"), Put) + if !o.findings[k].Since.Equal(f.Since) { + t.Errorf("a have written again moved the finding's since: %v, was %v", o.findings[k].Since, f.Since) + } + w.Have("ace.prov-y", have("prov-y carries its own secrets sealed"), Put) + w.Have("ace.runner-x", have(), Put) // its manifest says tool-runner now + w.Have("ace.prov-y", nil, Delete) }, map[string]string{}}, {"WM4: a stuck key not moved is its finding, and its move deletes it", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) { w.Step("go", "n1.ask1", runningStep("ask1", t0), Put) @@ -422,6 +457,46 @@ func TestTheWatchmansFindings(t *testing.T) { w.DeadLettersHeld(map[string]int{"SEAT_X.y_worker": 1}, who) w.NoticesWaiting(true) }, map[string]string{"bus.SEAT_X.y_worker.max-deliveries": Warning, "bus.DEAD_LETTER_NOTICES.controller.notices-waiting": Warning}}, + {"ADR 0603 §8: a want held by a rule, a provision asked and a build queued behind a package each raise a finding naming the wait past their bound, and clear when it ends", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) { + w.SetStepBound(30 * time.Minute) + held, _ := delivery.EncodeWant(delivery.Want{FP: fpOld, Self: true, Newest: fpNew, Rule: delivery.RuleCanary, Words: "the canary ace does not run it yet"}) + released, _ := delivery.EncodeWant(delivery.Want{FP: fpNew, Self: true}) + asked, _ := provisions.Encode(provisions.Provision{Provision: "oidc", Consumer: provisions.End{Module: "photos", Node: "ace"}, + Provider: provisions.End{Module: "keycloak", Node: "novox"}, State: provisions.Asked, At: t0}) + provided, _ := provisions.Encode(provisions.Provision{Provision: "oidc", Consumer: provisions.End{Module: "photos", Node: "ace"}, + Provider: provisions.End{Module: "keycloak", Node: "novox"}, State: provisions.Provided, At: t0}) + bkey, _ := delivery.BuilderBuildKey("web", fpNew) + queued, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildQueued, Waits: "mesh-sdk@v0.1.14"}) + building, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildBuilding, By: "n1"}) + w.Have("ace.web", haveValue(t, fpOld), Put) // the node runs what the held want names: the hold is still the wait + w.Want("want.ace.web", held, Put, t0) + w.Provision("ace.photos.oidc", asked, Put) + w.Build(bkey, queued, Put, t0) + waits := map[string]string{delivery.AssignerWantsBucket: "want.ace.web", provisions.ProvisionsBucket: "ace.photos.oidc", delivery.BuilderBuildsBucket: bkey} + if len(o.pulses) != 3 || len(o.findings) != 0 { + t.Fatalf("pulses %v findings %v", o.pulses, o.findings) + } + // The keys are written again as their parts move on, with new words and a new time: the wait still began at t0. + c.t = c.t.Add(20 * time.Minute) + held2, _ := delivery.EncodeWant(delivery.Want{FP: fpOld, Self: true, Newest: fpNew, Rule: delivery.RuleCanary, Words: "the canary ace runs it, not healthy yet"}) + asked2, _ := provisions.Encode(provisions.Provision{Provision: "oidc", Consumer: provisions.End{Module: "photos", Node: "ace"}, + Provider: provisions.End{Module: "keycloak", Node: "novox"}, State: provisions.Asked, Why: "the provider is applying", At: c.t}) + w.Want("want.ace.web", held2, Put, c.t) + w.Provision("ace.photos.oidc", asked2, Put) + w.Build(bkey, queued, Put, c.t) + for bucket, key := range waits { + expire(w, c, pulseKeyOf(t, bucket, key), 31*time.Minute) + } + for bucket, key := range waits { + f := o.findings[FindingKey(ScopeDelivery, stuckKeyOf(t, bucket, key), KindStuck)] + if f.Kind != KindStuck || !f.Since.Equal(t0) || !strings.Contains(f.Summary, "bound 30m0s") { + t.Fatalf("%s: findings = %v", bucket, o.findings) + } + } + w.Want("want.ace.web", released, Put, c.t) + w.Provision("ace.photos.oidc", provided, Put) + w.Build(bkey, building, Put, c.t) + }, map[string]string{}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/modules/mesh-delivery-watchman/internal/watch/mirror.go b/modules/mesh-delivery-watchman/internal/watch/mirror.go index 5bf05e7f..76fd2cf5 100644 --- a/modules/mesh-delivery-watchman/internal/watch/mirror.go +++ b/modules/mesh-delivery-watchman/internal/watch/mirror.go @@ -78,6 +78,7 @@ func (w *Watchman) Want(key string, value []byte, op Op, at time.Time) { return case Delete: delete(w.wants, key) + delete(w.held, key) w.moved(delivery.AssignerWantsBucket, key) return } @@ -89,10 +90,30 @@ func (w *Watchman) Want(key string, value []byte, op Op, at time.Time) { } if want.Removed { delete(w.wants, key) + delete(w.held, key) w.moved(delivery.AssignerWantsBucket, key) return } w.wants[key] = want.FP + if want.Newest != "" || want.Rule != 0 { + // A want held by a rule (ADR 0603 §8) waits on the rule, not on the node: its wait is the hold's, from the time the + // assigner wrote it, and the node running what the want names does not end it. + if at.IsZero() { + at = w.now() + } + w.held[key] = true + w.mirrorKept(delivery.AssignerWantsBucket, key, + describe("the hold on %s on %s at %s (rule %d) to end, bound %s: %s", module, node, shortFP(want.FP), want.Rule, w.stepBound, want.Words), + "held|"+want.FP+"|"+want.Newest, at) + return + } + if w.held[key] { + delete(w.held, key) + if pk, err := delivery.PulseKey(delivery.AssignerWantsBucket, key); err == nil { + delete(w.starts, pk) + } + w.stuckMoved(delivery.AssignerWantsBucket, key) // the hold ended: its finding is cleared + } if w.have(node, module) == want.FP { w.moved(delivery.AssignerWantsBucket, key) return @@ -104,6 +125,44 @@ func (w *Watchman) Want(key string, value []byte, op Op, at time.Time) { describe("the node agent of %s to run %s at %s", node, module, shortFP(want.FP)), want.FP, at, 0) } +// A build key queued behind a package is a wait of the builder's (ADR 0603 §8, ADR 0500 clause 6): its Waits names the package +// version the registry does not yet hold, and the key is not claimed until it is published. Its pulse is the pulse of the +// builder's builds bucket under the key, from the time the key was written, bounded as a step is (StepBound); the key +// claimed, done, failed or deleted ends it. + +// Build is a change of a key of the builder's builds bucket, read from the watch (at the start the keys that stand, then +// each change), at the time the entry was written. +func (w *Watchman) Build(key string, value []byte, op Op, at time.Time) { + module, fp, err := delivery.ParseBuilderBuildKey(key) + if err != nil { + return + } + w.mu.Lock() + defer w.mu.Unlock() + if op == Expired { + return + } + if op == Delete { + w.moved(delivery.BuilderBuildsBucket, key) + return + } + w.stands(delivery.BuilderBuildsBucket, key) + b, err := delivery.DecodeBuilderBuild(value) + if err != nil { + w.say("the build %s cannot be read, and is left: %v", key, err) + return + } + if b.State != delivery.BuildQueued || b.Waits == "" { + w.moved(delivery.BuilderBuildsBucket, key) + return + } + if at.IsZero() { + at = w.now() + } + w.mirrorKept(delivery.BuilderBuildsBucket, key, + describe("the package %s to be published before the build of %s at %s, bound %s", b.Waits, module, shortFP(fp), w.stepBound), b.Waits, at) +} + // Have is a change of a key of the node-engine's have bucket: the fingerprint a node runs of a module. One that names the // fingerprint of the node's want for the module ends the want's pulse. A node's own key and a key deleted are left. func (w *Watchman) Have(key string, value []byte, op Op) { diff --git a/modules/mesh-delivery-watchman/internal/watch/signals.go b/modules/mesh-delivery-watchman/internal/watch/signals.go index e5bc2346..042a5321 100644 --- a/modules/mesh-delivery-watchman/internal/watch/signals.go +++ b/modules/mesh-delivery-watchman/internal/watch/signals.go @@ -199,6 +199,7 @@ const ( KindUnhealthy = "module-unhealthy" KindNeedsOperator = "needs-operator" KindDeadLetters = "max-deliveries" + KindKeptAsRuns = "kept-as-it-runs" ) func stamp(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 MST") } @@ -476,11 +477,13 @@ func (w *Watchman) haveFindings(key string, node, module string, h *delivery.Hav w.unsignal("unhealthy-confirm", module+"."+node) w.unsignal("unhealthy", module+"."+node) w.unfind(unhealthyKey) + w.keptFinding(key, node, module, nil) w.applyCheck(node) return } w.haveState[key] = *h now := w.now() + w.keptFinding(key, node, module, h.Kept) // S21: a have refused or applied in part says phase failed, with the node's words in its detail. if h.Phase == delivery.PhaseFailed { @@ -531,6 +534,33 @@ func (w *Watchman) haveFindings(key string, node, module string, h *delivery.Hav } } +// keptFinding raises, as a warning, what a node keeps of a module as it runs because its manifest does not yet say what shared +// code no longer infers (its have's Kept, novox/hq ADR 0601 §4: the tool runner, the core's credentials), naming each; and +// clears it once the have says none, or is gone. Its since is the first have that said it, so a have written again moves +// nothing. +func (w *Watchman) keptFinding(key, node, module string, kept []string) { + id := module + "." + node + fkey := FindingKey(ScopeModule, id, "kept") + if len(kept) == 0 { + delete(w.keptSince, key) + w.unfind(fkey) + return + } + if w.keptSince == nil { + w.keptSince = map[string]time.Time{} + } + since, ok := w.keptSince[key] + if !ok { + since = w.now() + w.keptSince[key] = since + } + words := strings.Join(kept, "; ") + w.find(Finding{Scope: ScopeModule, ID: id, Token: "kept", Kind: KindKeptAsRuns, Machine: node, Severity: Warning, + Source: "node-engine", Since: since, Summary: fmt.Sprintf("%s on %s is kept as it runs: %s", module, node, words), + Said: firstLine(words), Headline: fmt.Sprintf("%s kept as it runs on %s", module, node), + Needs: "a build of " + module + " whose manifest says it (tool-runner, core-credentials)"}) +} + // ControlNodeModule is the module whose have names a control node: the bus module, so the control node is the bus node, // the one stably reachable node every part depends on (novox/hq ADR 0590, issue 1126: the controller that named it ended). // The same name the bus module's events are heard under (SlowConsumerSubject). @@ -559,6 +589,7 @@ func (w *Watchman) Provision(key string, value []byte, op Op) { w.waiting[id][key] = fmt.Sprintf("%s from %s on %s: %s", p.Provision, p.Provider.Module, p.Provider.Node, p.Why) } } + w.askedWait(key, value, op) fkey := FindingKey(ScopeModule, id, KindNeedsOperator) if len(w.waiting[id]) == 0 { delete(w.waiting, id) @@ -574,6 +605,36 @@ func (w *Watchman) Provision(key string, value []byte, op Op) { Said: strings.Join(said, "; ")}) } +// askedWait is the wait of a provision its provider has not provided (ADR 0603 §8): a key asked is mirrored from the time the +// holder wrote it, bounded as a step is, and provided, failed, waiting for the operator (its own finding), retiring or deleted +// ends it. Called with the lock held. +func (w *Watchman) askedWait(key string, value []byte, op Op) { + bucket := provisions.ProvisionsBucket + if op == Expired { + return + } + w.stands(bucket, key) + if op == Delete { + w.moved(bucket, key) + return + } + p, err := provisions.Decode(value) + if err != nil || p.State != provisions.Asked { + w.moved(bucket, key) + return + } + since := p.At + if since.IsZero() { + since = w.now() + } + words := fmt.Sprintf("%s from %s on %s to be provided for %s on %s, bound %s", p.Provision, p.Provider.Module, p.Provider.Node, + p.Consumer.Module, p.Consumer.Node, w.stepBound) + if p.Why != "" { + words += ": " + p.Why + } + w.mirrorKept(bucket, key, words, "asked", since) +} + func sortedKeys(m map[string]string) []string { out := make([]string, 0, len(m)) for k := range m { diff --git a/modules/mesh-delivery-watchman/internal/watch/verdicts.go b/modules/mesh-delivery-watchman/internal/watch/verdicts.go index 1a80e65c..b31b648e 100644 --- a/modules/mesh-delivery-watchman/internal/watch/verdicts.go +++ b/modules/mesh-delivery-watchman/internal/watch/verdicts.go @@ -14,6 +14,11 @@ const KindManifestRefused = "manifest-refused" // (novox/hq ADR 0579 §1): it stays accepted and runs; the warning stands until the manifest is mended. const KindManifestOutgrown = delivery.ManifestOutgrown +// KindManifestWaits is a module whose manifest carries a field the indexer's judge does not know yet (novox/hq ADR 0578 +// §1, amended): every reader holds it as a refused one and keeps what runs, and the warning stands until a judge knows the +// field or the manifest is mended. +const KindManifestWaits = delivery.ManifestWaits + // ModuleVerdict is a change of the indexer's value of a module (delivery.ModulesBucket): a value carrying a refusal is a // finding, one that does not (accepted, or not judged yet) or a deleted key ends it. func (w *Watchman) ModuleVerdict(module string, value []byte, op Op) { @@ -21,9 +26,11 @@ func (w *Watchman) ModuleVerdict(module string, value []byte, op Op) { defer w.mu.Unlock() key := FindingKey(ScopeModule, module, KindManifestRefused) outgrown := FindingKey(ScopeModule, module, KindManifestOutgrown) + waits := FindingKey(ScopeModule, module, KindManifestWaits) if op != Put { w.unfind(key) w.unfind(outgrown) + w.unfind(waits) return } m, err := delivery.DecodeModule(value) @@ -32,6 +39,20 @@ func (w *Watchman) ModuleVerdict(module string, value []byte, op Op) { return } v := m.VerdictOf(module) + if v != nil && v.Waits() { + w.unfind(key) + w.unfind(outgrown) + w.find(Finding{Scope: ScopeModule, ID: module, Kind: KindManifestWaits, Severity: Warning, Source: delivery.ModulesBucket, + Summary: v.Why(), + Said: strings.Join(v.Unknown, "; "), + Headline: "The manifest of " + module + " waits for a judge that knows its fields", + Explanation: "The manifest carries " + strings.Join(v.Unknown, ", ") + ", which the indexer's judge does not know yet, so every " + + "reader holds " + module + " as a refused one and keeps what runs; an indexer that knows the fields judges it again " + + "at its start, and the newest build of the indexer refuses a field it does not know.", + Needs: "nothing while a newer indexer is on its way; else a pull request that mends the manifest of " + module}) + return + } + w.unfind(waits) if refused, _ := delivery.TakeManifestVerdict(v); !refused { w.unfind(key) if v == nil || len(v.Warnings) == 0 { diff --git a/modules/mesh-delivery-watchman/internal/watch/watch.go b/modules/mesh-delivery-watchman/internal/watch/watch.go index fa6d5b7d..0e8b7925 100644 --- a/modules/mesh-delivery-watchman/internal/watch/watch.go +++ b/modules/mesh-delivery-watchman/internal/watch/watch.go @@ -60,9 +60,14 @@ type Watchman struct { presence map[string]presence pulses map[string]*pulse // live pulses, by pulse key stuck map[string]delivery.Stuck - wants map[string]string // the fingerprint of each want that stands, by want key - haves map[string]string // the fingerprint each node runs of a module, by have key - bound time.Duration + // keptSince is, by have key, when its have first said what the node keeps of the module as it runs (keptFinding). + keptSince map[string]time.Time + wants map[string]string // the fingerprint of each want that stands, by want key + haves map[string]string // the fingerprint each node runs of a module, by have key + // starts is, by pulse key, the first start of a wait whose mark still stands (mirrorKept). + starts map[string]startMark + held map[string]bool // each want key held by a rule (ADR 0603 §8), mirrored as the hold's wait + bound time.Duration // findings is every finding written, by key, as written; seededFindings those read at the start and not yet observed // again. findings map[string][]byte @@ -101,7 +106,7 @@ type Watchman struct { // New is a watchman writing to out, reading the time from now. func New(out Out, now func() time.Time) *Watchman { return &Watchman{out: out, now: now, counts: map[string]int{}, presence: map[string]presence{}, - pulses: map[string]*pulse{}, stuck: map[string]delivery.Stuck{}, wants: map[string]string{}, haves: map[string]string{}, bound: delivery.PulseMoveBound, stepBound: StepBound, + pulses: map[string]*pulse{}, stuck: map[string]delivery.Stuck{}, wants: map[string]string{}, haves: map[string]string{}, held: map[string]bool{}, starts: map[string]startMark{}, bound: delivery.PulseMoveBound, stepBound: StepBound, findings: map[string][]byte{}, seededFindings: map[string]bool{}, signals: map[string]signalDue{}, away: map[string]bool{}, haveState: map[string]delivery.Have{}, unhealthy: map[string]int{}, advised: map[string]int{}, advisedSince: map[string]time.Time{}, control: map[string]bool{}, failedSince: map[string]time.Time{}, provFailed: map[string]map[string]ProvReport{}, closures: map[string]map[string]time.Time{}, waiting: map[string]map[string]string{}, @@ -159,6 +164,25 @@ func (w *Watchman) mirrorWithin(bound time.Duration, bucket, key, waited, head s w.pulses[pk] = &pulse{bucket: bucket, key: key, waited: waited, since: since, head: head, token: token} } +// startMark is when a wait began, for the mark that names it. +type startMark struct { + mark string + since time.Time +} + +// mirrorKept is mirrorWithin for a wait that is the key's value standing as it is (ADR 0603 §8): while the same mark stands +// (a provision asked, a want held at the same fingerprints, a build waiting for the same package), a rewrite of the key (new +// words, a new time) does not start the bound again; the earliest start is kept. A different mark is a new wait. +func (w *Watchman) mirrorKept(bucket, key, waited, mark string, since time.Time) { + if pk, err := delivery.PulseKey(bucket, key); err == nil { + if cur, ok := w.starts[pk]; ok && cur.mark == mark && cur.since.Before(since) { + since = cur.since + } + w.starts[pk] = startMark{mark: mark, since: since} + } + w.mirrorWithin(w.stepBound, bucket, key, waited, mark, since, 0) +} + func (w *Watchman) deletePulse(pk string) { delete(w.pulses, pk) if err := w.out.DeletePulse(pk); err != nil { @@ -173,6 +197,7 @@ func (w *Watchman) moved(bucket, key string) { if err != nil { return } + delete(w.starts, pk) if _, ok := w.pulses[pk]; ok { w.deletePulse(pk) } diff --git a/modules/mesh-delivery-watchman/manifest_test.go b/modules/mesh-delivery-watchman/manifest_test.go index 7f146ca1..a577361e 100644 --- a/modules/mesh-delivery-watchman/manifest_test.go +++ b/modules/mesh-delivery-watchman/manifest_test.go @@ -91,7 +91,7 @@ func TestManifestStateAndReads(t *testing.T) { // 1135/AC6 reads := slices.Clone(m.Reads) slices.Sort(reads) if !reflect.DeepEqual(reads, []string{"mesh-build-go.steps", "mesh-build-image.steps", "mesh-build-node.steps", - "mesh-delivery-assigner.wants", "mesh-delivery-indexer.modules", "mesh-host.health", "mesh-provisioner.provisions", "node-engine.have"}) { + "mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", "mesh-delivery-indexer.modules", "mesh-host.health", "mesh-provisioner.provisions", "node-engine.have"}) { t.Errorf("reads = %v", reads) } // 1952/WM1: the watchman holds the seat mesh-watchdogs at mesh scope, its first seat (novox/hq ADR 0564 §1, §4). diff --git a/modules/mesh-delivery-watchman/module.json b/modules/mesh-delivery-watchman/module.json index f405cf75..4c85f5d7 100644 --- a/modules/mesh-delivery-watchman/module.json +++ b/modules/mesh-delivery-watchman/module.json @@ -20,6 +20,7 @@ "mesh-build-image.steps", "mesh-build-node.steps", "mesh-delivery-assigner.wants", + "mesh-delivery-builder.builds", "mesh-delivery-indexer.modules", "mesh-host.health", "mesh-provisioner.provisions", diff --git a/modules/mesh-enrolment/go.mod b/modules/mesh-enrolment/go.mod index d6d91c02..733bf732 100644 --- a/modules/mesh-enrolment/go.mod +++ b/modules/mesh-enrolment/go.mod @@ -3,7 +3,7 @@ module mesh-enrolment go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-enrolment/go.sum b/modules/mesh-enrolment/go.sum index 43cc4ec7..abad2969 100644 --- a/modules/mesh-enrolment/go.sum +++ b/modules/mesh-enrolment/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-grants/go.mod b/modules/mesh-grants/go.mod index baf4cd76..a90cee61 100644 --- a/modules/mesh-grants/go.mod +++ b/modules/mesh-grants/go.mod @@ -3,7 +3,7 @@ module mesh-grants go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103142843-f8bd12e4b7b1 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-grants/go.sum b/modules/mesh-grants/go.sum index d2717419..abad2969 100644 --- a/modules/mesh-grants/go.sum +++ b/modules/mesh-grants/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103142843-f8bd12e4b7b1 h1:T66tg9T/1hHg6KN/bDScHPT41IHkxLAGGO8VJiAOlb8= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103142843-f8bd12e4b7b1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 6488719b..88deec86 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -11,6 +11,7 @@ import ( "maps" "os" "path/filepath" + "regexp" "slices" "strings" "sync" @@ -598,7 +599,7 @@ func TestGrants(t *testing.T) { b := fixture() b.manifestOf("mod-z", `{"module":"mod-z","version":"1","own-secrets":{"broker":"/var/lib/z/broker"}`+row.build+`}`) b.assign("a", "mod-z") - b.manifestOf("node-tools", `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/nt/broker"}}`) + b.manifestOf("node-tools", `{"module":"node-tools","version":"1","tool-runner":true,"own-secrets":{"broker":"/var/lib/nt/broker"}}`) b.assign("a", "node-tools") users := shadowOf(t, b) tools := users[slices.IndexFunc(users, func(u grants.User) bool { return u.User == "a.node-tools" })] @@ -680,6 +681,22 @@ func TestGrants(t *testing.T) { if slices.Contains(web.Publish, "$JS.API.DIRECT.GET.KV_mesh-knowledge_sections.>") { t.Error("the object bucket's read is granted as a state's") } + // ADR 0601 clause 3: the owner's trunk manifest now waits for a field; its running build's manifest still declares the + // bucket, so web keeps its object grant. + fp := "src2:" + strings.Repeat("cd", 32) + b.put(grants.ReadModules, "mesh-knowledge", delivery.Module{V: delivery.Version, FP2: "src2:" + strings.Repeat("ab", 32), + Manifest: delivery.ManifestDigest([]byte(`{"module":"mesh-knowledge","version":"2"}`)), Repository: "novox/x", Commit: "c", + Verdict: delivery.VerdictRefused, Problems: []string{"it waits"}, Unknown: []string{"core-credentials"}}) + b.put(grants.ReadHave, "a.mesh-knowledge", delivery.Have{V: delivery.Version, Phase: delivery.PhaseRunning, Health: delivery.HealthOK, FP: fp}) + heldKey, _ := delivery.BuilderBuildKey("mesh-knowledge", fp) + b.put(grants.ReadBuilds, heldKey, map[string]any{"v": delivery.Version, "state": "done", "manifest": delivery.ManifestDigest([]byte(knowledge))}) + if heldWeb := (func() grants.User { + us := shadowOf(t, b) + return us[slices.IndexFunc(us, func(u grants.User) bool { return u.User == "a.web" })] + })(); !slices.Contains(heldWeb.Publish, read) { + t.Errorf("a reader of a held module's object store lost its grant: %v", heldWeb.Publish) + } + b.manifestOf("mesh-knowledge", knowledge) // accepted again for the rest of the row // ADR 0592 §6, §25: the bucket past 75 percent of its cap is said, in one warning, and cleared once below; the history // stream at 10 percent is not. var said []asks.Condition @@ -739,6 +756,53 @@ func TestGrants(t *testing.T) { } }) + // ADR 0601 clause 3: a held module's seats, hearings and reads are composed from the manifest its running build was judged + // on, so a principal that claims a seat it defines keeps its grants while its trunk manifest is refused or waits. + t.Run("a held module's seat is composed from the manifest its running build was judged on", func(t *testing.T) { + const router = `{"module":"messenger","version":"1","runs-as":"messenger","resources":[{"id":"account","type":"user","name":"messenger","shell":"/usr/bin/nologin","home":"/var/lib/messenger"}],"own-secrets":{"broker":"/var/lib/m/broker"},"secrets-owner":"messenger",` + + `"seats":[{"name":"operator-channel","scope":"mesh","serves":["open"]},{"name":"channel","scope":"mesh","kinded":true,"accepts":["show"]}],` + + `"claims":[{"name":"operator-channel","scope":"mesh","serves":["open"]}]}` + const telegram = `{"module":"telegram","version":"1","runs-as":"telegram","resources":[{"id":"account","type":"user","name":"telegram","shell":"/usr/bin/nologin","home":"/var/lib/telegram"}],"own-secrets":{"broker":"/var/lib/t/broker"},"secrets-owner":"telegram",` + + `"claims":[{"name":"channel","scope":"mesh","kind":"telegram","capabilities":["deliver","verified-sender"]}]}` + setup := func(held, runs bool) *fakeBus { + b := fixture() + b.manifestOf("messenger", router) + b.manifestOf("telegram", telegram) + b.assign("a", "messenger") + b.assign("b", "telegram") + if held { + // The trunk's manifest waits for a field; the build that runs was judged on the manifest before. + b.put(grants.ReadModules, "messenger", delivery.Module{V: delivery.Version, FP2: "src2:" + strings.Repeat("ab", 32), + Manifest: delivery.ManifestDigest([]byte(`{"module":"messenger","version":"2"}`)), Repository: "novox/x", Commit: "c", + Verdict: delivery.VerdictRefused, Problems: []string{"it waits"}, Unknown: []string{"core-credentials"}}) + if runs { + fp := "src2:" + strings.Repeat("cd", 32) + digest := delivery.ManifestDigest([]byte(router)) + b.put(grants.ReadManifests, digest, delivery.Manifest{V: delivery.Version, Module: "messenger", JSON: router}) + b.put(grants.ReadHave, "a.messenger", delivery.Have{V: delivery.Version, Phase: delivery.PhaseRunning, Health: delivery.HealthOK, FP: fp}) + key, _ := delivery.BuilderBuildKey("messenger", fp) + b.put(grants.ReadBuilds, key, map[string]any{"v": delivery.Version, "state": "done", "manifest": digest}) + } + } + return b + } + grantsOf := func(b *fakeBus) string { + for _, u := range shadowOf(t, b) { + if u.User == "b.telegram" { + return strings.Join(u.Publish, ",") + "|" + strings.Join(u.Subscribe, ",") + } + } + return "" + } + accepted, held, unseen := grantsOf(setup(false, false)), grantsOf(setup(true, true)), grantsOf(setup(true, false)) + if accepted == "" || held != accepted { + t.Errorf("a caller of a held module's seat lost grants:\n%s\nwas\n%s", held, accepted) + } + if unseen == accepted { + t.Errorf("the row proves nothing: the grants are the same without the held module's running manifest") + } + }) + t.Run("root-free: each engine's word fills the composition, failing closed (issue 1945)", func(t *testing.T) { const router = `{"module":"messenger","version":"1","runs-as":"messenger","resources":[{"id":"account","type":"user","name":"messenger","shell":"/usr/bin/nologin","home":"/var/lib/messenger"}],"own-secrets":{"broker":"/var/lib/m/broker"},"secrets-owner":"messenger",` + `"seats":[{"name":"operator-channel","scope":"mesh","serves":["open"]},{"name":"channel","scope":"mesh","kinded":true,"accepts":["show"]}],` + @@ -1191,7 +1255,7 @@ func TestGrants(t *testing.T) { n.manifestOf("claude-licence-manager", string(licence)) n.assign("novox", "nats") n.assign("novox", "claude-licence-manager") - n.manifestOf("node-tools", `{"module":"node-tools","version":"1"}`) // the machine's tool runner, which carries the seat + n.manifestOf("node-tools", `{"module":"node-tools","version":"1","tool-runner":true}`) // the machine's tool runner, which carries the seat n.assign("novox", "node-tools") // The live placement names mesh-host; with its manifest's state the runner once carried it and the whole list was // refused as a second writer of the engine's health (issue 774, 2026-10-30): the shadow below now composes. @@ -1487,7 +1551,7 @@ func TestGrants(t *testing.T) { fp := partOn(b) fp.Objects = p.Objects fp.Say = func(c asks.Condition) error { - if c.Key != grants.GrantMissingCondition && c.Key != grants.RefusedCondition { // their own rows + if c.Key != grants.GrantMissingCondition && c.Key != grants.RefusedCondition && c.Key != grants.HeldModuleCondition { // their own rows fresh = append(fresh, c) } return nil @@ -2077,26 +2141,27 @@ func TestGrants(t *testing.T) { }) // ADR 0590 clause 5's test for this repository (issue 1126): no module.json here names mesh-controller, the lease - // read and the builder's consume of artifacts-gone included (mesh-sdk catalogue.ManifestRefusals). + // read and the builder's consume of artifacts-gone included. Read as text, as mesh-catalog's controllerdeps check reads + // its own: mesh-sdk names no module, so it holds no such check (issue 774, the operator 2026-11-03). t.Run("no module of this repository names the controller", func(t *testing.T) { paths, _ := filepath.Glob("../*/module.json") - manifests := map[string]catalogue.Manifest{} + if len(paths) < 8 { + t.Fatalf("read %d manifests, want every module of the repository", len(paths)) + } + names := regexp.MustCompile(`(^|[^A-Za-z0-9_-])(seat:)?mesh-controller([._"]|$)`) for _, path := range paths { raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } - m, err := catalogue.Decode(raw) - if err != nil { + if _, err := catalogue.Decode(raw); err != nil { t.Fatalf("%s: %v", path, err) } - manifests[m.Module] = m - } - if len(manifests) < 8 { - t.Fatalf("read %d manifests, want every module of the repository", len(manifests)) - } - for _, why := range catalogue.ManifestRefusals(manifests) { - t.Error(why) + for i, line := range strings.Split(string(raw), "\n") { + if names.MatchString(line) { + t.Errorf("%s:%d names the ended controller: %s", path, i+1, strings.TrimSpace(line)) + } + } } }) @@ -2391,7 +2456,7 @@ func TestGrants(t *testing.T) { var said, behind []asks.Condition p.Say = func(c asks.Condition) error { switch c.Key { - case grants.GrantMissingCondition, grants.RefusedCondition, grants.FullCondition: // their own rows + case grants.GrantMissingCondition, grants.RefusedCondition, grants.HeldModuleCondition, grants.FullCondition: // their own rows case grants.ConsumerBehindCondition: behind = append(behind, c) default: @@ -2690,6 +2755,115 @@ func TestGrants(t *testing.T) { t.Errorf("the refusal was not cleared once it composes: %v, said %+v", res.PrincipalsRefused, said) } }) + + // Clause 3 of the record on a release's Approve (issue 774): a module whose manifest waits for a judge that knows a field + // of it, or is refused, and whose want names no build, keeps its users of the list in force and is said in a condition; + // the rest of the list is written as ever, and the users go the day the manifest is accepted and the module unassigned. + t.Run("a module whose manifest waits or is refused keeps its users of the list in force", func(t *testing.T) { + b := fixture() + const ctl = `{"module":"mod-r","version":"1","own-secrets":{"broker":"/var/lib/r/broker","broker-mod-r-tools":"${dir:tools-secret}/broker-mod-r-tools"},` + + `"resources":[{"id":"tools-secret","type":"directory","path":"/var/lib/mesh/mod-r-tools","mode":"0711"}],"build":{"artifacts":[{"name":"tools",` + + `"kind":"bundle","language":"go","system":"arch","from":"cmd/mod-r-tools","binary":"mod-r-tools","loads":["mod-r-tools"],"bus":{}}]}}` + b.manifestOf("mod-r", ctl) + b.assign("b", "mod-r") + for _, u := range shadowOf(t, b) { + b.put(grants.ReadAccounts, u.User, map[string]any{"user": u.User, "hash": "$2a$10$first", "generation": 1, "fingerprint": "sha256:1"}) + } + b.put(grants.ReadAccounts, "b.mod-r.mod-r-tools", map[string]any{"user": "b.mod-r.mod-r-tools", "hash": "$2a$10$first", "generation": 1, "fingerprint": "sha256:1"}) + busKey, err := seal.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + b.put(grants.ReadHave, "a.nats", delivery.Have{V: delivery.Version, Phase: delivery.PhaseRunning, Health: delivery.HealthOK, + UserList: &delivery.UserListWritten{Source: "a.nats.src2", Revision: 7, Digest: "sha256:aa", Users: 5, Key: busKey.PublicKey}}) + // mod-r runs a build whose manifest is the accepted one: the held module's seats and streams are composed from it. + runFP := "src2:" + strings.Repeat("cd", 32) + b.put(grants.ReadHave, "b.mod-r", delivery.Have{V: delivery.Version, Phase: delivery.PhaseRunning, Health: delivery.HealthOK, FP: runFP}) + runKey, _ := delivery.BuilderBuildKey("mod-r", runFP) + b.put(grants.ReadBuilds, runKey, map[string]any{"v": delivery.Version, "state": "done", "manifest": delivery.ManifestDigest([]byte(ctl))}) + p := partOn(b) + p.Objects = &fakeObjects{} + var said []asks.Condition + p.Say = func(c asks.Condition) error { + if c.Key == grants.HeldModuleCondition { + said = append(said, c) + } + return nil + } + p.Compose() + inForce, _, _ := p.Sealed() + before, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(inForce.Digest, "b.mod-r.mod-r-tools")) + if !ok || len(said) != 1 || said[0].State != asks.StateCleared { + t.Fatalf("the list in force keeps no grants of b.mod-r.mod-r-tools (%v), or a held module was said before: %+v", ok, said) + } + for gen, held := range []struct{ name, unknown string }{{"waits", "core-credentials"}, {"refused", ""}} { + // Another user's hash changes in the same pass, so a list that is written shows the held module did not hold it. + b.put(grants.ReadAccounts, "a.mod-x", map[string]any{"user": "a.mod-x", "hash": fmt.Sprintf("$2a$10$gen%d", gen), "generation": gen + 2, "fingerprint": fmt.Sprintf("sha256:%d", gen+2)}) + was, _, _ := p.Sealed() + var unknown []string + if held.unknown != "" { + unknown = []string{held.unknown} + } + b.put(grants.ReadModules, "mod-r", delivery.Module{V: delivery.Version, FP2: "src2:" + strings.Repeat("ab", 32), + Manifest: delivery.ManifestDigest([]byte(ctl)), Repository: "novox/x", Commit: "c", Verdict: delivery.VerdictRefused, + Problems: []string{"it is held"}, Unknown: unknown}) + b.put(grants.ReadModules, "mod-q", delivery.Module{V: delivery.Version, FP2: "src2:" + strings.Repeat("ab", 32), + Manifest: delivery.ManifestDigest([]byte(ctl)), Repository: "novox/x", Commit: "c"}) + res := p.Compose() + now, _, _ := p.Sealed() + after, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(now.Digest, "b.mod-r.mod-r-tools")) + if now.Digest == was.Digest || slices.ContainsFunc(res.Errors, func(e string) bool { return strings.Contains(e, "composed whole") }) { + t.Errorf("%s: a held module held the whole list: sealed %+v", held.name, res.Sealed) + } + if !ok || string(after) != string(before) || len(res.PrincipalsRefused) != 0 { + t.Errorf("%s: b.mod-r.mod-r-tools did not keep its grants of the list in force: %s, was %s", held.name, after, before) + } + if last := said[len(said)-1]; last.State != asks.StateRaised || !strings.Contains(last.Summary, "b.mod-r.mod-r-tools (stays in the list") { + t.Errorf("%s: the kept user was not said: %+v", held.name, last) + } + } + // A held module that runs no build the part can read declares nothing to anyone: the list in force is kept, said. + delete(b.states[grants.ReadHave], "b.mod-r") + b.put(grants.ReadModules, "mod-r", delivery.Module{V: delivery.Version, FP2: "src2:" + strings.Repeat("ab", 32), + Manifest: delivery.ManifestDigest([]byte(ctl)), Repository: "novox/x", Commit: "c", Verdict: delivery.VerdictRefused, + Problems: []string{"it is held"}, Unknown: []string{"core-credentials"}}) + b.put(grants.ReadAccounts, "a.mod-x", map[string]any{"user": "a.mod-x", "hash": "$2a$10$gen9", "generation": 9, "fingerprint": "sha256:9"}) + wasList, _, _ := p.Sealed() + res := p.Compose() + nowList, _, _ := p.Sealed() + if nowList.Digest != wasList.Digest || res.Sealed == nil || !strings.Contains(res.Sealed.Why, "running manifest of mod-r") { + t.Errorf("a held module with no running manifest did not keep the list in force: %+v", res.Sealed) + } + // Accepted again: the users compose from the manifest and the condition clears. + b.manifestOf("mod-r", ctl) + if p.Compose(); said[len(said)-1].State != asks.StateCleared { + t.Errorf("the condition was not cleared once the manifest is accepted: %+v", said[len(said)-1]) + } + // ADR 0601 §4: a tool runner whose manifest does not say so yet (an older build's) keeps its user as the list in force + // holds it, known by what it runs (it says the runner is alive), and the held condition says so; never composed as a + // plain module. + const runner = `{"module":"node-tools","version":"1","tool-runner":true,"own-secrets":{"broker":"/var/lib/nt/broker"}}` + b.manifestOf("node-tools", runner) + b.assign("a", "node-tools") + b.put(grants.ReadAccounts, "a.node-tools", map[string]any{"user": "a.node-tools", "hash": "$2a$10$first", "generation": 1, "fingerprint": "sha256:1"}) + p.Compose() + inForce, _, _ = p.Sealed() + ran, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(inForce.Digest, "a.node-tools")) + if !ok || !strings.Contains(string(ran), "mesh.control.a.tools-alive") { + t.Fatalf("the list in force holds no tool runner on a: %s", ran) + } + b.manifestOf("node-tools", strings.Replace(runner, `"tool-runner":true,`, "", 1)) + b.put(grants.ReadAccounts, "a.mod-x", map[string]any{"user": "a.mod-x", "hash": "$2a$10$gen10", "generation": 10, "fingerprint": "sha256:10"}) + p.Compose() + now, _, _ := p.Sealed() + kept, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(now.Digest, "a.node-tools")) + if !ok || string(kept) != string(ran) { + t.Errorf("the tool runner of a was composed without what it runs: %s, was %s", kept, ran) + } + if last := said[len(said)-1]; last.State != asks.StateRaised || !strings.Contains(last.Summary, "a.node-tools (stays in the list") { + t.Errorf("the kept tool runner was not said: %+v", last) + } + }) } // fakeObjects is the part's object bucket of sealed lists. diff --git a/modules/mesh-grants/internal/buscompose/buscompose_test.go b/modules/mesh-grants/internal/buscompose/buscompose_test.go index 32953ed9..16a053d0 100644 --- a/modules/mesh-grants/internal/buscompose/buscompose_test.go +++ b/modules/mesh-grants/internal/buscompose/buscompose_test.go @@ -91,6 +91,32 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Fatal(err) } golden(t, "user-lists.json", append(raw, '\n')) + // ADR 0601 §4: the node-engine's witness pings the tool runner by the name its manifest gives it, and with none saying so + // any service's instance on its own node, never a name written here. + t.Run("the witness pings the tool runner by its manifest", func(t *testing.T) { + for runner, want := range map[string]string{"runner-x": "$SRV.PING.runner-x.a", "": "$SRV.PING.*.a"} { + placed := []Declared{{Module: "web"}} + if runner != "" { + placed = append(placed, Declared{Module: runner, ToolRunner: true}) + } + users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": placed}}) + if err != nil { + t.Fatal(err) + } + nodes := 0 + for _, u := range users { + if u.Kind == KindNode { + nodes++ + } + if u.Kind == KindNode && (u.Runner != runner || !slices.Contains(WitnessSubjects(u), want)) { + t.Errorf("runner %q: the node principal %+v witnesses %v, want %s", runner, u, WitnessSubjects(u), want) + } + } + if nodes == 0 { + t.Errorf("runner %q: no node principal composed", runner) + } + } + }) // An asker on a seat pulled by kind hears what that seat's workers gave up on, and no other stream's dead letters // (novox/hq issue 734: a check ask given up during a bus reload left its try running for ever). t.Run("an asker reads its seat's dead letters", func(t *testing.T) { @@ -129,7 +155,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}} users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{ "one": {{Module: "messenger", Holds: []Seat{router}, Invokes: []string{"seat:" + EngineSeat + ".root-free"}}}, - "two": {{Module: RuntimeModule}, {Module: "shop", Invokes: []string{"*"}}}}}) + "two": {{Module: "node-tools", ToolRunner: true}, {Module: "shop", Invokes: []string{"*"}}}}}) if err != nil { t.Fatal(err) } @@ -361,7 +387,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { {Module: "mesh-enrolment", RunsAs: "mesh-enrolment", Holds: []Seat{holder}, Invokes: calls, State: []Bucket{{Module: "mesh-enrolment", Name: "enrolments", KeyTTL: true}}}, {Module: "shop", Invokes: calls[:2]}, - {Module: RuntimeModule}, {Module: "agent-tools", Invokes: []string{"*"}}}}}) + {Module: "node-tools", ToolRunner: true}, {Module: "agent-tools", Invokes: []string{"*"}}}}}) if err != nil { t.Fatal(err) } @@ -782,7 +808,7 @@ func healthChecks(t *testing.T) { } } // Nor the runtime carrying the node's modules, which reads each one's membership from the state. - if perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Module: RuntimeModule, + if perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Module: "node-tools", Carries: []Declared{{Module: "restic"}}}); err != nil || MaySubscribe(perms, "mesh.assignment.a.restic") || !MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic") { t.Errorf("the runtime hears mesh.assignment or reads no membership: %v", err) @@ -926,7 +952,7 @@ func everyNodesCompositions(t *testing.T) { t.Error("the read is granted as one bucket mesh-host_composed") } // A bundle reading them (the provisioner, carried by the machine's runtime) reads them on the runtime's connection. - carried := Principal{Kind: KindNodeTools, Node: "one", Module: RuntimeModule, Nodes: []string{"one", "two"}, + carried := Principal{Kind: KindNodeTools, Node: "one", Module: "node-tools", Nodes: []string{"one", "two"}, Carries: []Declared{{Module: "mesh-provisioner", Reads: []string{delivery.ComposedRead}}}} runner, err := PermissionsFor(carried) if err != nil { @@ -952,7 +978,7 @@ func aLedgerIsWrittenByItsLedgerAccountAlone(t *testing.T) { r := Records{Nodes: nodes, Assigned: map[string][]Declared{}} for _, n := range nodes { r.Assigned[n] = []Declared{ - {Module: RuntimeModule}, + {Module: "node-tools", ToolRunner: true}, {Module: "m", Ledgers: []string{"records", "audit"}, Processes: []ProcessPrincipal{{Process: "w"}}}, {Module: "plain"}, } diff --git a/modules/mesh-grants/internal/buscompose/creates_streams_test.go b/modules/mesh-grants/internal/buscompose/creates_streams_test.go index 35937977..7c99a208 100644 --- a/modules/mesh-grants/internal/buscompose/creates_streams_test.go +++ b/modules/mesh-grants/internal/buscompose/creates_streams_test.go @@ -21,7 +21,7 @@ func theGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) { Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds"}}, Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "BUILDASKS", Events: []string{"ask"}}}}, {Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "assertions"}}, Reads: []string{VaultBusAccounts}}, - {Module: RuntimeModule}, + {Module: "node-tools", ToolRunner: true}, // The node-engine's manifest as a placement names it (issue 774, met twice on 2026-10-30): no account of its // own, so the runner would carry it, and its state is the node's to write alone. {Module: EngineModule, NoAccount: true, Consumes: []string{"restic.backed-up"}, diff --git a/modules/mesh-grants/internal/buscompose/health_grants.go b/modules/mesh-grants/internal/buscompose/health_grants.go index 29036e94..7b06c49a 100644 --- a/modules/mesh-grants/internal/buscompose/health_grants.go +++ b/modules/mesh-grants/internal/buscompose/health_grants.go @@ -45,6 +45,9 @@ func ControllerLeftoverConsumersOn(nodes []string) []string { return out } +// endedController is the name the ended controller had as a module: its buckets are named for it (ADR 0590 §6). +const endedController = "mesh-controller" + // ControllerLeftoverBuckets are the ended controller's buckets, each by its local name (`KV_mesh-controller_`). var ControllerLeftoverBuckets = []string{"asked", "assignments", "builds", "calls", "checks-running", "composed", "condition-history", "conditions", "declared-config", "declared-packages", "delivery-merges", "grants", "hand-acts", @@ -55,7 +58,7 @@ var ControllerLeftoverBuckets = []string{"asked", "assignments", "builds", "call func ControllerLeftoverStreams(nodes []string) []string { out := []string{"CONTROL", AssignmentsStream} for _, b := range ControllerLeftoverBuckets { - out = append(out, "KV_"+BucketName(catalogue.EndedControllerName, b)) + out = append(out, "KV_"+BucketName(endedController, b)) } for _, n := range nodes { if safeSubject.MatchString(n) { diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index 93482882..7f85009a 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -58,12 +58,13 @@ const ( KindLedger Kind = "ledger" ) -// RuntimeModule is the module that IS the node's tool runtime (novox/hq ADR 0175). Where it is -// assigned, the mesh composes one runtime principal for the machine in place of that module's own, -// and the per-module containers that served tools until then stop being the way tools reach a node. -// Mirrored in the catalogue package, which the agreement test holds to the same string; one -// constant, so a rename is one edit and the two packages cannot drift. -const RuntimeModule = "node-tools" +// The node's tool runner (novox/hq ADR 0175) is the module whose manifest says it is (`tool-runner`, Declared.ToolRunner; +// ADR 0601 §4), never a name here. Where it is assigned, the mesh composes one runtime principal for the machine in place of +// that module's own, and the per-module containers that served tools until then stop being the way tools reach a node. + +// ToolsAliveSubject is what a node's tool runner says it is alive on: granted to the runner alone, so a user of the list in +// force that may publish it is that node's tool runner (ADR 0601 §4's keeping of what runs). +func ToolsAliveSubject(node string) string { return "mesh.control." + node + ".tools-alive" } // Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it // emits (novox/hq ADR 0118, design 29 §5). @@ -207,6 +208,9 @@ type Principal struct { // `.` (novox/hq ADR 0240, to-be 48 §3): asked of the instance on its own machine and // nowhere else. Checks []string + // Runner is, for a node, the module placed there whose manifest says it is the tool runner (`tool-runner`): the + // service the node-engine's witness pings (WitnessSubjects). Empty when none says so. + Runner string // MovesIntoTheVault is a machine principal whose node-engine may ask the vault's `move-in` in its own name (design // 57's V3, issue 676): set while the vault's holder serves it, so the engine can hand in the values sealed to its // machine, which nothing else can open. @@ -999,7 +1003,7 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, readGrants(GrantsMembershipsBucket, p.Node+".*")...) // And that it is there (novox/hq to-be 45 §3, S11): its own heartbeat, under its machine's // name and no other's, on core NATS like the host's. - pub = append(pub, "mesh.control."+p.Node+".tools-alive") + pub = append(pub, ToolsAliveSubject(p.Node)) // And every tool on the mesh (ADR 0175, decision 5): any node may call any tool on any // node, as the console already could — the runtime is the console's serving mode. invoked, err := invokedSubjects([]string{"*"}) @@ -1016,7 +1020,7 @@ func PermissionsFor(p Principal) (Permissions, error) { // discovery for each module and seat it carries, and the console it is asks the bus. // One service per runtime process, named for the runtime: the bus lets a principal answer each // request once, so the runtime announces everything it carries under its own name. - sub = append(sub, announcing(append([]string{RuntimeModule}, serves...)...)...) + sub = append(sub, announcing(append([]string{p.Module}, serves...)...)...) pub = append(pub, discovering()...) // **And it consumes for the modules it carries** (novox/hq ADR 0198, which changes ADR 0175's // "it consumes nothing"): a module's long-running code is a bundle this runtime launches, and @@ -1602,7 +1606,7 @@ func discovering() []string { // WitnessSubjects are the subjects a machine's node-engine publishes to witness the core builds it // places (novox/hq ADR 0236): its own node tools' PING. The answers come to the machine's own inbox. func WitnessSubjects(p Principal) []string { - return append([]string{pingSubject(p.Node)}, CheckSubjects(p)...) + return append([]string{pingSubject(p.Runner, p.Node)}, CheckSubjects(p)...) } // CheckSubjects are the tools a machine's node-engine asks as declared health checks (novox/hq ADR 0240, @@ -1626,10 +1630,15 @@ func CheckSubjects(p Principal) []string { return out } -// pingSubject is a node-engine's witness's ping of its own node's tools (novox/hq ADR 0236). -func pingSubject(node string) string { return "$SRV.PING." + nodeToolsService + "." + node } - -const nodeToolsService = "node-tools" +// pingSubject is a node-engine's witness's ping of its own node's tool runner (novox/hq ADR 0236), named as the runner's +// manifest says it is (ADR 0601 §4), never by a name here. With no manifest on the node saying so yet (an older build's, +// the runner kept as it runs), any service's instance on this node: the ping of its own node alone, whatever it is called. +func pingSubject(runner, node string) string { + if runner == "" { + runner = "*" + } + return "$SRV.PING." + runner + "." + node +} // VaultOwnCall is the subject a machine principal's node-engine asks the vault's `own` on, in its own name. func VaultOwnCall(p Principal) string { diff --git a/modules/mesh-grants/internal/buscompose/testdata/composed.conf b/modules/mesh-grants/internal/buscompose/testdata/composed.conf index 70ee7b5e..f65a1ba8 100644 --- a/modules/mesh-grants/internal/buscompose/testdata/composed.conf +++ b/modules/mesh-grants/internal/buscompose/testdata/composed.conf @@ -33,7 +33,7 @@ accounts { subscribe: { allow: ["_INBOX.enrol.one.>"] } } } { user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: { - publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.CREATE.KV_composed-one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_candidates.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_verdicts.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.one", "$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.one", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.one.>", "$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.one.>", "$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*", "$JS.API.CONSUMER.DELETE.KV_composed-one.>", "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_candidates.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_verdicts.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_data.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_health.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.>", "$JS.API.CONSUMER.DELETE.KV_node-engine_have.>", "$JS.API.CONSUMER.DELETE.OBJ_declaration-files-one.*", "$JS.API.CONSUMER.INFO.NODES.one", "$JS.API.DIRECT.GET.KV_composed-one.>", "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.one.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_candidates.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_verdicts.>", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.one.>", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.one.>", "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.one", "$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.one", "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.one.>", "$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.one.>", "$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*", "$JS.API.STREAM.INFO.KV_composed-one", "$JS.API.STREAM.INFO.KV_mesh-config_resolved", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement", "$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_data", "$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-one.>", "$JS.FC.KV_mesh-config_resolved.>", "$JS.FC.KV_mesh-delivery-assigner_candidates.>", "$JS.FC.KV_mesh-delivery-assigner_placement.>", "$JS.FC.KV_mesh-delivery-assigner_wants.>", "$JS.FC.KV_mesh-delivery-builder_builds.>", "$JS.FC.KV_mesh-delivery-indexer_candidates.>", "$JS.FC.KV_mesh-delivery-indexer_manifests.>", "$JS.FC.KV_mesh-delivery-indexer_modules.>", "$JS.FC.KV_mesh-delivery-indexer_verdicts.>", "$JS.FC.KV_mesh-host_acts.>", "$JS.FC.KV_mesh-host_data.>", "$JS.FC.KV_mesh-host_health.>", "$JS.FC.KV_mesh-host_packages.>", "$JS.FC.KV_mesh-provisioner_provisions.>", "$JS.FC.KV_node-engine_have.>", "$JS.FC.OBJ_declaration-files-one.>", "$KV.composed-one.>", "$KV.mesh-host_acts.one.>", "$KV.mesh-host_data.one.>", "$KV.mesh-host_health.one", "$KV.mesh-host_packages.one", "$KV.node-engine_have.one.>", "$SRV.INFO", "$SRV.INFO.>", "$SRV.PING", "$SRV.PING.>", "$SRV.PING.node-tools.one", "mesh.control.one.>", "mesh.seat.node-backup.call.backed-up.one.node~one", "mesh.seat.node-backup.call.now.one.node~one", "mesh.seat.node-backup.tool.backed-up.one", "mesh.seat.node-backup.tool.now.one", "mesh.seat.node-engine.event.*.one"] } + publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.CREATE.KV_composed-one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_candidates.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_verdicts.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.one", "$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.one", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.one.>", "$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.one.>", "$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*", "$JS.API.CONSUMER.DELETE.KV_composed-one.>", "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_candidates.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_verdicts.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_data.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_health.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.>", "$JS.API.CONSUMER.DELETE.KV_node-engine_have.>", "$JS.API.CONSUMER.DELETE.OBJ_declaration-files-one.*", "$JS.API.CONSUMER.INFO.NODES.one", "$JS.API.DIRECT.GET.KV_composed-one.>", "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.one.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_candidates.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_verdicts.>", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.one.>", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.one.>", "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.one", "$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.one", "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.one.>", "$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.one.>", "$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*", "$JS.API.STREAM.INFO.KV_composed-one", "$JS.API.STREAM.INFO.KV_mesh-config_resolved", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement", "$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_data", "$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-one.>", "$JS.FC.KV_mesh-config_resolved.>", "$JS.FC.KV_mesh-delivery-assigner_candidates.>", "$JS.FC.KV_mesh-delivery-assigner_placement.>", "$JS.FC.KV_mesh-delivery-assigner_wants.>", "$JS.FC.KV_mesh-delivery-builder_builds.>", "$JS.FC.KV_mesh-delivery-indexer_candidates.>", "$JS.FC.KV_mesh-delivery-indexer_manifests.>", "$JS.FC.KV_mesh-delivery-indexer_modules.>", "$JS.FC.KV_mesh-delivery-indexer_verdicts.>", "$JS.FC.KV_mesh-host_acts.>", "$JS.FC.KV_mesh-host_data.>", "$JS.FC.KV_mesh-host_health.>", "$JS.FC.KV_mesh-host_packages.>", "$JS.FC.KV_mesh-provisioner_provisions.>", "$JS.FC.KV_node-engine_have.>", "$JS.FC.OBJ_declaration-files-one.>", "$KV.composed-one.>", "$KV.mesh-host_acts.one.>", "$KV.mesh-host_data.one.>", "$KV.mesh-host_health.one", "$KV.mesh-host_packages.one", "$KV.node-engine_have.one.>", "$SRV.INFO", "$SRV.INFO.>", "$SRV.PING", "$SRV.PING.*.one", "$SRV.PING.>", "mesh.control.one.>", "mesh.seat.node-backup.call.backed-up.one.node~one", "mesh.seat.node-backup.call.now.one.node~one", "mesh.seat.node-backup.tool.backed-up.one", "mesh.seat.node-backup.tool.now.one", "mesh.seat.node-engine.event.*.one"] } subscribe: { allow: ["$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates", "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement", "$SRV.INFO", "$SRV.INFO.node-engine", "$SRV.INFO.node-engine.>", "$SRV.PING", "$SRV.PING.node-engine", "$SRV.PING.node-engine.>", "$SRV.STATS", "$SRV.STATS.node-engine", "$SRV.STATS.node-engine.>", "_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.mod.restic.event.backed-up", "mesh.node.one.ask.access-act", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.move-secret", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare", "mesh.seat.node-engine.call.*.one.*", "mesh.seat.node-engine.tool.*.one"] } allow_responses: { max: 1, ttl: "1m" } } } diff --git a/modules/mesh-grants/internal/buscompose/testdata/user-lists.json b/modules/mesh-grants/internal/buscompose/testdata/user-lists.json index a66303c9..842a9753 100644 --- a/modules/mesh-grants/internal/buscompose/testdata/user-lists.json +++ b/modules/mesh-grants/internal/buscompose/testdata/user-lists.json @@ -8,7 +8,7 @@ }, { "Name": "the controller's someRecords", - "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: \"node.one\", password: \"$2a$11$node.onenode.onenode.ofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.\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.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.one\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.one\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.one.\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.one.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\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_data.\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-one.*\", \"$JS.API.CONSUMER.INFO.NODES.one\", \"$JS.API.DIRECT.GET.KV_composed-one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.\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.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.one\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.one\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.one.\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.one.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*\", \"$JS.API.STREAM.INFO.KV_composed-one\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$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_data\", \"$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-one.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\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_data.\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-one.\u003e\", \"$KV.composed-one.\u003e\", \"$KV.mesh-host_acts.one.\u003e\", \"$KV.mesh-host_data.one.\u003e\", \"$KV.mesh-host_health.one\", \"$KV.mesh-host_packages.one\", \"$KV.node-engine_have.one.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"$SRV.PING.node-tools.one\", \"mesh.control.one.\u003e\", \"mesh.seat.node-backup.call.backed-up.one.node~one\", \"mesh.seat.node-backup.call.now.one.node~one\", \"mesh.seat.node-backup.tool.backed-up.one\", \"mesh.seat.node-backup.tool.now.one\", \"mesh.seat.node-engine.event.*.one\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$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.one\", \"_DELIVER.one.\u003e\", \"_INBOX.node.one.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.one.ask.access-act\", \"mesh.node.one.ask.hand-over\", \"mesh.node.one.ask.judge-directories\", \"mesh.node.one.ask.move-secret\", \"mesh.node.one.ask.report\", \"mesh.node.one.ask.setuid-search\", \"mesh.node.one.declare\", \"mesh.seat.node-engine.call.*.one.*\", \"mesh.seat.node-engine.tool.*.one\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.two\", password: \"$2a$11$node.twonode.twonode.tfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.two.\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.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.two\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.two\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.two.\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.two.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-two.*.$O.declaration-files-two.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\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_data.\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-two.*\", \"$JS.API.CONSUMER.INFO.NODES.two\", \"$JS.API.DIRECT.GET.KV_composed-two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.two.\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.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.two\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.two\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.two.\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.two.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-two.$O.declaration-files-two.M.*\", \"$JS.API.STREAM.INFO.KV_composed-two\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$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_data\", \"$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-two.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\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_data.\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-two.\u003e\", \"$KV.composed-two.\u003e\", \"$KV.mesh-host_acts.two.\u003e\", \"$KV.mesh-host_data.two.\u003e\", \"$KV.mesh-host_health.two\", \"$KV.mesh-host_packages.two\", \"$KV.node-engine_have.two.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"$SRV.PING.node-tools.two\", \"mesh.control.two.\u003e\", \"mesh.seat.node-backup.call.backed-up.two.node~two\", \"mesh.seat.node-backup.call.now.two.node~two\", \"mesh.seat.node-backup.tool.backed-up.two\", \"mesh.seat.node-backup.tool.now.two\", \"mesh.seat.node-engine.event.*.two\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$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.two\", \"_DELIVER.two.\u003e\", \"_INBOX.node.two.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.two.ask.access-act\", \"mesh.node.two.ask.hand-over\", \"mesh.node.two.ask.judge-directories\", \"mesh.node.two.ask.move-secret\", \"mesh.node.two.ask.report\", \"mesh.node.two.ask.setuid-search\", \"mesh.node.two.declare\", \"mesh.seat.node-engine.call.*.two.*\", \"mesh.seat.node-engine.tool.*.two\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"one.telegram\", password: \"$2a$11$one.telegramone.telegrfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.one_telegram.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.telegram\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.one_telegram\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.telegram\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.telegram\", \"$SRV.INFO.telegram.\u003e\", \"$SRV.PING\", \"$SRV.PING.telegram\", \"$SRV.PING.telegram.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.telegram\", \"$SRV.STATS.telegram.\u003e\", \"_INBOX.one.telegram.\u003e\", \"mesh.mod.telegram.call.\u003e\", \"mesh.mod.telegram.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"person.ada\", password: \"$2a$11$person.adaperson.adapefixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"mesh.mod.mesh-catalog.call.catalog_tools.*.person~ada\", \"mesh.mod.mesh-catalog.call.catalog_tools.person~ada\", \"mesh.mod.mesh-catalog.tool.catalog_tools\", \"mesh.mod.mesh-catalog.tool.catalog_tools.*\"] }\n subscribe: { allow: [\"_INBOX.person.ada.\u003e\"] }\n } }\n { user: \"two.shop\", password: \"$2a$11$two.shoptwo.shoptwo.shfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.two_shop.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.two.shop\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.two_shop\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.two.shop\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"mesh.mod.shop.event.order.placed\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.shop\", \"$SRV.INFO.shop.\u003e\", \"$SRV.PING\", \"$SRV.PING.shop\", \"$SRV.PING.shop.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.shop\", \"$SRV.STATS.shop.\u003e\", \"_INBOX.two.shop.\u003e\", \"mesh.mod.shop.call.\u003e\", \"mesh.mod.shop.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: \"node.one\", password: \"$2a$11$node.onenode.onenode.ofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.\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.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.one\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.one\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.one.\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.one.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\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_data.\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-one.*\", \"$JS.API.CONSUMER.INFO.NODES.one\", \"$JS.API.DIRECT.GET.KV_composed-one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.\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.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.one\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.one\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.one.\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.one.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*\", \"$JS.API.STREAM.INFO.KV_composed-one\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$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_data\", \"$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-one.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\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_data.\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-one.\u003e\", \"$KV.composed-one.\u003e\", \"$KV.mesh-host_acts.one.\u003e\", \"$KV.mesh-host_data.one.\u003e\", \"$KV.mesh-host_health.one\", \"$KV.mesh-host_packages.one\", \"$KV.node-engine_have.one.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.*.one\", \"$SRV.PING.\u003e\", \"mesh.control.one.\u003e\", \"mesh.seat.node-backup.call.backed-up.one.node~one\", \"mesh.seat.node-backup.call.now.one.node~one\", \"mesh.seat.node-backup.tool.backed-up.one\", \"mesh.seat.node-backup.tool.now.one\", \"mesh.seat.node-engine.event.*.one\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$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.one\", \"_DELIVER.one.\u003e\", \"_INBOX.node.one.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.one.ask.access-act\", \"mesh.node.one.ask.hand-over\", \"mesh.node.one.ask.judge-directories\", \"mesh.node.one.ask.move-secret\", \"mesh.node.one.ask.report\", \"mesh.node.one.ask.setuid-search\", \"mesh.node.one.declare\", \"mesh.seat.node-engine.call.*.one.*\", \"mesh.seat.node-engine.tool.*.one\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.two\", password: \"$2a$11$node.twonode.twonode.tfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.two.\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.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.two\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.two\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.two.\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.two.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-two.*.$O.declaration-files-two.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\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_data.\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-two.*\", \"$JS.API.CONSUMER.INFO.NODES.two\", \"$JS.API.DIRECT.GET.KV_composed-two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.two.\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.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.two\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.two\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.two.\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.two.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-two.$O.declaration-files-two.M.*\", \"$JS.API.STREAM.INFO.KV_composed-two\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$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_data\", \"$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-two.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\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_data.\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-two.\u003e\", \"$KV.composed-two.\u003e\", \"$KV.mesh-host_acts.two.\u003e\", \"$KV.mesh-host_data.two.\u003e\", \"$KV.mesh-host_health.two\", \"$KV.mesh-host_packages.two\", \"$KV.node-engine_have.two.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.*.two\", \"$SRV.PING.\u003e\", \"mesh.control.two.\u003e\", \"mesh.seat.node-backup.call.backed-up.two.node~two\", \"mesh.seat.node-backup.call.now.two.node~two\", \"mesh.seat.node-backup.tool.backed-up.two\", \"mesh.seat.node-backup.tool.now.two\", \"mesh.seat.node-engine.event.*.two\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$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.two\", \"_DELIVER.two.\u003e\", \"_INBOX.node.two.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.two.ask.access-act\", \"mesh.node.two.ask.hand-over\", \"mesh.node.two.ask.judge-directories\", \"mesh.node.two.ask.move-secret\", \"mesh.node.two.ask.report\", \"mesh.node.two.ask.setuid-search\", \"mesh.node.two.declare\", \"mesh.seat.node-engine.call.*.two.*\", \"mesh.seat.node-engine.tool.*.two\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"one.telegram\", password: \"$2a$11$one.telegramone.telegrfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.one_telegram.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.telegram\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.one_telegram\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.telegram\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.telegram\", \"$SRV.INFO.telegram.\u003e\", \"$SRV.PING\", \"$SRV.PING.telegram\", \"$SRV.PING.telegram.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.telegram\", \"$SRV.STATS.telegram.\u003e\", \"_INBOX.one.telegram.\u003e\", \"mesh.mod.telegram.call.\u003e\", \"mesh.mod.telegram.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"person.ada\", password: \"$2a$11$person.adaperson.adapefixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"mesh.mod.mesh-catalog.call.catalog_tools.*.person~ada\", \"mesh.mod.mesh-catalog.call.catalog_tools.person~ada\", \"mesh.mod.mesh-catalog.tool.catalog_tools\", \"mesh.mod.mesh-catalog.tool.catalog_tools.*\"] }\n subscribe: { allow: [\"_INBOX.person.ada.\u003e\"] }\n } }\n { user: \"two.shop\", password: \"$2a$11$two.shoptwo.shoptwo.shfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.two_shop.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.two.shop\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.two_shop\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.two.shop\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"mesh.mod.shop.event.order.placed\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.shop\", \"$SRV.INFO.shop.\u003e\", \"$SRV.PING\", \"$SRV.PING.shop\", \"$SRV.PING.shop.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.shop\", \"$SRV.STATS.shop.\u003e\", \"_INBOX.two.shop.\u003e\", \"mesh.mod.shop.call.\u003e\", \"mesh.mod.shop.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": { @@ -46,7 +46,7 @@ }, { "Name": "seats, consumers, people and a hashless user", - "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: \"node.one\", password: \"$2a$11$node.onenode.onenode.ofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.\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-grants_user-list.*.$KV.mesh-grants_user-list.sealed\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.one\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.one\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.one.\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.one.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*\", \"$JS.API.CONSUMER.CREATE.OBJ_mesh-grants-user-list.*.$O.mesh-grants-user-list.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\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-grants_user-list.*\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\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-one.*\", \"$JS.API.CONSUMER.DELETE.OBJ_mesh-grants-user-list.*\", \"$JS.API.CONSUMER.INFO.NODES.one\", \"$JS.API.DIRECT.GET.KV_composed-one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.\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-grants_user-list.$KV.mesh-grants_user-list.sealed\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.one\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.one\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.one.\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.one.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*\", \"$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.*\", \"$JS.API.STREAM.INFO.KV_composed-one\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$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-grants_user-list\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$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-one.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\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-grants_user-list.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_data.\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-one.\u003e\", \"$JS.FC.OBJ_mesh-grants-user-list.\u003e\", \"$KV.composed-one.\u003e\", \"$KV.mesh-host_acts.one.\u003e\", \"$KV.mesh-host_data.one.\u003e\", \"$KV.mesh-host_health.one\", \"$KV.mesh-host_packages.one\", \"$KV.node-engine_have.one.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"$SRV.PING.node-tools.one\", \"mesh.control.one.\u003e\", \"mesh.seat.node-backup.call.backed-up.one.node~one\", \"mesh.seat.node-backup.call.now.one.node~one\", \"mesh.seat.node-backup.tool.backed-up.one\", \"mesh.seat.node-backup.tool.now.one\", \"mesh.seat.node-engine.event.*.one\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$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.one\", \"_DELIVER.one.\u003e\", \"_INBOX.node.one.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.one.ask.access-act\", \"mesh.node.one.ask.hand-over\", \"mesh.node.one.ask.judge-directories\", \"mesh.node.one.ask.move-secret\", \"mesh.node.one.ask.report\", \"mesh.node.one.ask.setuid-search\", \"mesh.node.one.declare\", \"mesh.seat.node-engine.call.*.one.*\", \"mesh.seat.node-engine.tool.*.one\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.two\", password: \"$2a$11$node.twonode.twonode.tfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.two.\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.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.two\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.two\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.two.\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.two.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-two.*.$O.declaration-files-two.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\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_data.\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-two.*\", \"$JS.API.CONSUMER.INFO.NODES.two\", \"$JS.API.DIRECT.GET.KV_composed-two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.two.\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.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.two\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.two\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.two.\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.two.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-two.$O.declaration-files-two.M.*\", \"$JS.API.STREAM.INFO.KV_composed-two\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$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_data\", \"$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-two.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\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_data.\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-two.\u003e\", \"$KV.composed-two.\u003e\", \"$KV.mesh-host_acts.two.\u003e\", \"$KV.mesh-host_data.two.\u003e\", \"$KV.mesh-host_health.two\", \"$KV.mesh-host_packages.two\", \"$KV.node-engine_have.two.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"$SRV.PING.node-tools.two\", \"mesh.control.two.\u003e\", \"mesh.seat.node-backup.call.backed-up.two.node~two\", \"mesh.seat.node-backup.call.now.two.node~two\", \"mesh.seat.node-backup.tool.backed-up.two\", \"mesh.seat.node-backup.tool.now.two\", \"mesh.seat.node-engine.event.*.two\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$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.two\", \"_DELIVER.two.\u003e\", \"_INBOX.node.two.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.two.ask.access-act\", \"mesh.node.two.ask.hand-over\", \"mesh.node.two.ask.judge-directories\", \"mesh.node.two.ask.move-secret\", \"mesh.node.two.ask.report\", \"mesh.node.two.ask.setuid-search\", \"mesh.node.two.declare\", \"mesh.seat.node-engine.call.*.two.*\", \"mesh.seat.node-engine.tool.*.two\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"one.mesh-grants\", password: \"$2a$11$one.mesh-grantsone.mesfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.one_mesh-grants.\u003e\", \"$JS.API.CONSUMER.CREATE.EVENTS.two_audit\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.mesh-grants\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.one_mesh-grants\", \"$JS.API.CONSUMER.INFO.EVENTS.two_audit\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-grants\", \"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.*.*\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.mesh-grants\", \"$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.\u003e\", \"$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"$O.mesh-grants-user-list.\u003e\", \"mesh.assignment.*.*\", \"mesh.mod.mesh-grants.event.membership-changed\"] }\n subscribe: { allow: [\"$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.one.mesh-grants.\u003e\", \"mesh.mod.mesh-grants.call.\u003e\", \"mesh.mod.mesh-grants.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"one.mesh-watchman\", password: \"$2a$11$one.mesh-watchmanone.mfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.one_mesh-watchman.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.mesh-watchman\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.one_mesh-watchman\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-watchman\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.mesh-watchman\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.mesh-watchman\", \"$SRV.INFO.mesh-watchman.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-watchman\", \"$SRV.PING.mesh-watchman.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-watchman\", \"$SRV.STATS.mesh-watchman.\u003e\", \"_INBOX.one.mesh-watchman.\u003e\", \"mesh.mod.mesh-watchman.call.\u003e\", \"mesh.mod.mesh-watchman.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"one.nats\", password: \"$2a$11$one.natsone.natsone.nafixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.API.STREAM.INFO.*\", \"$JS.API.STREAM.NAMES\", \"$JS.API.STREAM.SNAPSHOT.*\", \"$JS.SNAPSHOT.ACK.\u003e\"] }\n subscribe: { allow: [\"_INBOX.one.nats.\u003e\"] }\n } }\n { user: \"one.telegram\", password: \"$2a$11$one.telegramone.telegrfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.one_telegram.\u003e\", \"$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.telegram\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.one_telegram\", \"$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram\", \"$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.telegram\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"mesh.seat.telegram-sender.event.delivered\", \"mesh.seat.telegram-sender.event.failed\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.telegram\", \"$SRV.INFO.telegram.\u003e\", \"$SRV.PING\", \"$SRV.PING.telegram\", \"$SRV.PING.telegram.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.telegram\", \"$SRV.STATS.telegram.\u003e\", \"_INBOX.one.telegram.\u003e\", \"mesh.mod.telegram.call.\u003e\", \"mesh.mod.telegram.tool.\u003e\", \"mesh.seat.telegram-sender.accept.send\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"person.ada\", password: \"$2a$11$person.adaperson.adapefixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"mesh.mod.*.call.*.*.person~ada\", \"mesh.mod.*.call.*.person~ada\", \"mesh.mod.*.tool.\u003e\", \"mesh.seat.*.call.*.*.person~ada\", \"mesh.seat.*.call.*.person~ada\", \"mesh.seat.*.tool.\u003e\"], deny: [\"mesh.seat.node-launcher.tool.secret\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.mod.*.tool.node-launcher.secret\", \"mesh.mod.*.tool.node-launcher.secret.*\", \"mesh.seat.node-launcher.call.secret.\u003e\", \"mesh.mod.*.call.node-launcher.secret.\u003e\"] }\n subscribe: { allow: [\"_INBOX.person.ada.\u003e\"] }\n } }\n { user: \"person.bob\", password: \"$2a$11$person.bobperson.bobpefixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"mesh.mod.shop.call.status.*.person~bob\", \"mesh.mod.shop.call.status.person~bob\", \"mesh.mod.shop.tool.status\", \"mesh.mod.shop.tool.status.*\"] }\n subscribe: { allow: [\"_INBOX.person.bob.\u003e\"] }\n } }\n { user: \"two.shop\", password: \"$2a$11$two.shoptwo.shoptwo.shfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.two_shop.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.two.shop\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.two_shop\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.two.shop\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"mesh.mod.shop.event.order.placed\", \"mesh.seat.telegram-sender.accept.send\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.shop\", \"$SRV.INFO.shop.\u003e\", \"$SRV.PING\", \"$SRV.PING.shop\", \"$SRV.PING.shop.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.shop\", \"$SRV.STATS.shop.\u003e\", \"_INBOX.two.shop.\u003e\", \"mesh.mod.shop.call.\u003e\", \"mesh.mod.shop.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: \"node.one\", password: \"$2a$11$node.onenode.onenode.ofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.\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-grants_user-list.*.$KV.mesh-grants_user-list.sealed\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.one.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.one\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.one\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.one.\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.one.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*\", \"$JS.API.CONSUMER.CREATE.OBJ_mesh-grants-user-list.*.$O.mesh-grants-user-list.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-one.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\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-grants_user-list.*\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\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-one.*\", \"$JS.API.CONSUMER.DELETE.OBJ_mesh-grants-user-list.*\", \"$JS.API.CONSUMER.INFO.NODES.one\", \"$JS.API.DIRECT.GET.KV_composed-one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.\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-grants_user-list.$KV.mesh-grants_user-list.sealed\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.one.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.one\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.one\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.one.\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.one.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*\", \"$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.*\", \"$JS.API.STREAM.INFO.KV_composed-one\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$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-grants_user-list\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$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-one.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\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-grants_user-list.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_data.\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-one.\u003e\", \"$JS.FC.OBJ_mesh-grants-user-list.\u003e\", \"$KV.composed-one.\u003e\", \"$KV.mesh-host_acts.one.\u003e\", \"$KV.mesh-host_data.one.\u003e\", \"$KV.mesh-host_health.one\", \"$KV.mesh-host_packages.one\", \"$KV.node-engine_have.one.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.*.one\", \"$SRV.PING.\u003e\", \"mesh.control.one.\u003e\", \"mesh.seat.node-backup.call.backed-up.one.node~one\", \"mesh.seat.node-backup.call.now.one.node~one\", \"mesh.seat.node-backup.tool.backed-up.one\", \"mesh.seat.node-backup.tool.now.one\", \"mesh.seat.node-engine.event.*.one\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$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.one\", \"_DELIVER.one.\u003e\", \"_INBOX.node.one.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.one.ask.access-act\", \"mesh.node.one.ask.hand-over\", \"mesh.node.one.ask.judge-directories\", \"mesh.node.one.ask.move-secret\", \"mesh.node.one.ask.report\", \"mesh.node.one.ask.setuid-search\", \"mesh.node.one.declare\", \"mesh.seat.node-engine.call.*.one.*\", \"mesh.seat.node-engine.tool.*.one\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.two\", password: \"$2a$11$node.twonode.twonode.tfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.two.\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.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.two.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.two\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.two\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.two.\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.two.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-two.*.$O.declaration-files-two.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-two.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\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_data.\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-two.*\", \"$JS.API.CONSUMER.INFO.NODES.two\", \"$JS.API.DIRECT.GET.KV_composed-two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.two.\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.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.two.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.two\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.two\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.two.\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.two.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-two.$O.declaration-files-two.M.*\", \"$JS.API.STREAM.INFO.KV_composed-two\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$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_data\", \"$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-two.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\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_data.\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-two.\u003e\", \"$KV.composed-two.\u003e\", \"$KV.mesh-host_acts.two.\u003e\", \"$KV.mesh-host_data.two.\u003e\", \"$KV.mesh-host_health.two\", \"$KV.mesh-host_packages.two\", \"$KV.node-engine_have.two.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.*.two\", \"$SRV.PING.\u003e\", \"mesh.control.two.\u003e\", \"mesh.seat.node-backup.call.backed-up.two.node~two\", \"mesh.seat.node-backup.call.now.two.node~two\", \"mesh.seat.node-backup.tool.backed-up.two\", \"mesh.seat.node-backup.tool.now.two\", \"mesh.seat.node-engine.event.*.two\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$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.two\", \"_DELIVER.two.\u003e\", \"_INBOX.node.two.\u003e\", \"mesh.mod.restic.event.backed-up\", \"mesh.node.two.ask.access-act\", \"mesh.node.two.ask.hand-over\", \"mesh.node.two.ask.judge-directories\", \"mesh.node.two.ask.move-secret\", \"mesh.node.two.ask.report\", \"mesh.node.two.ask.setuid-search\", \"mesh.node.two.declare\", \"mesh.seat.node-engine.call.*.two.*\", \"mesh.seat.node-engine.tool.*.two\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"one.mesh-grants\", password: \"$2a$11$one.mesh-grantsone.mesfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.one_mesh-grants.\u003e\", \"$JS.API.CONSUMER.CREATE.EVENTS.two_audit\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.mesh-grants\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.one_mesh-grants\", \"$JS.API.CONSUMER.INFO.EVENTS.two_audit\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-grants\", \"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.*.*\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.mesh-grants\", \"$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.\u003e\", \"$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"$O.mesh-grants-user-list.\u003e\", \"mesh.assignment.*.*\", \"mesh.mod.mesh-grants.event.membership-changed\"] }\n subscribe: { allow: [\"$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.one.mesh-grants.\u003e\", \"mesh.mod.mesh-grants.call.\u003e\", \"mesh.mod.mesh-grants.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"one.mesh-watchman\", password: \"$2a$11$one.mesh-watchmanone.mfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.one_mesh-watchman.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.mesh-watchman\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.one_mesh-watchman\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-watchman\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.mesh-watchman\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.mesh-watchman\", \"$SRV.INFO.mesh-watchman.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-watchman\", \"$SRV.PING.mesh-watchman.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-watchman\", \"$SRV.STATS.mesh-watchman.\u003e\", \"_INBOX.one.mesh-watchman.\u003e\", \"mesh.mod.mesh-watchman.call.\u003e\", \"mesh.mod.mesh-watchman.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"one.nats\", password: \"$2a$11$one.natsone.natsone.nafixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.API.STREAM.INFO.*\", \"$JS.API.STREAM.NAMES\", \"$JS.API.STREAM.SNAPSHOT.*\", \"$JS.SNAPSHOT.ACK.\u003e\"] }\n subscribe: { allow: [\"_INBOX.one.nats.\u003e\"] }\n } }\n { user: \"one.telegram\", password: \"$2a$11$one.telegramone.telegrfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.one_telegram.\u003e\", \"$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.telegram\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.one_telegram\", \"$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram\", \"$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.telegram\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"mesh.seat.telegram-sender.event.delivered\", \"mesh.seat.telegram-sender.event.failed\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.telegram\", \"$SRV.INFO.telegram.\u003e\", \"$SRV.PING\", \"$SRV.PING.telegram\", \"$SRV.PING.telegram.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.telegram\", \"$SRV.STATS.telegram.\u003e\", \"_INBOX.one.telegram.\u003e\", \"mesh.mod.telegram.call.\u003e\", \"mesh.mod.telegram.tool.\u003e\", \"mesh.seat.telegram-sender.accept.send\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"person.ada\", password: \"$2a$11$person.adaperson.adapefixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"mesh.mod.*.call.*.*.person~ada\", \"mesh.mod.*.call.*.person~ada\", \"mesh.mod.*.tool.\u003e\", \"mesh.seat.*.call.*.*.person~ada\", \"mesh.seat.*.call.*.person~ada\", \"mesh.seat.*.tool.\u003e\"], deny: [\"mesh.seat.node-launcher.tool.secret\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.mod.*.tool.node-launcher.secret\", \"mesh.mod.*.tool.node-launcher.secret.*\", \"mesh.seat.node-launcher.call.secret.\u003e\", \"mesh.mod.*.call.node-launcher.secret.\u003e\"] }\n subscribe: { allow: [\"_INBOX.person.ada.\u003e\"] }\n } }\n { user: \"person.bob\", password: \"$2a$11$person.bobperson.bobpefixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.\u003e\", \"mesh.mod.shop.call.status.*.person~bob\", \"mesh.mod.shop.call.status.person~bob\", \"mesh.mod.shop.tool.status\", \"mesh.mod.shop.tool.status.*\"] }\n subscribe: { allow: [\"_INBOX.person.bob.\u003e\"] }\n } }\n { user: \"two.shop\", password: \"$2a$11$two.shoptwo.shoptwo.shfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.two_shop.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.two.shop\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.two_shop\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.two.shop\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"mesh.mod.shop.event.order.placed\", \"mesh.seat.telegram-sender.accept.send\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.shop\", \"$SRV.INFO.shop.\u003e\", \"$SRV.PING\", \"$SRV.PING.shop\", \"$SRV.PING.shop.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.shop\", \"$SRV.STATS.shop.\u003e\", \"_INBOX.two.shop.\u003e\", \"mesh.mod.shop.call.\u003e\", \"mesh.mod.shop.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": [ "two.audit" ], @@ -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_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\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_data.*.$KV.mesh-host_data.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_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\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_data.\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_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\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_data.$KV.mesh-host_data.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_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$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_data\", \"$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_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\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_data.\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_data.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.\u003e\", \"$SRV.PING.node-tools.ace\", \"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_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$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.mod.restic.event.backed-up\", \"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_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\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_data.*.$KV.mesh-host_data.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_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\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_data.\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_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\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_data.$KV.mesh-host_data.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_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$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_data\", \"$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_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\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_data.\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_data.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.\u003e\", \"$SRV.PING.node-tools.novox\", \"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_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$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.mod.restic.event.backed-up\", \"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_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_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.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.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_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\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_data.*.$KV.mesh-host_data.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_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\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_data.\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_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\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_data.$KV.mesh-host_data.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_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$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_data\", \"$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_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\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_data.\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_data.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_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$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.mod.restic.event.backed-up\", \"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_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.\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_data.*.$KV.mesh-host_data.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_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.\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_data.\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_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.\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_data.$KV.mesh-host_data.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_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement\", \"$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_data\", \"$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_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_placement.\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_data.\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_data.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_candidates\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement\", \"$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.mod.restic.event.backed-up\", \"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_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_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.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.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 cbf0a027..9df3e1dd 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -26,6 +26,8 @@ import ( // Declared is one module on one node, as composing its authority needs it. type Declared struct { Module string + // ToolRunner says the module is the node's tool runner, as its manifest says (`tool-runner`, ADR 0601 §4). + ToolRunner bool // Objects are its own object buckets, and ReadsObjects the bus names of the object buckets of others its reads name // and their owners' grants let it read (novox/hq ADR 0592 §5, §9). Objects []ObjectBucket @@ -173,17 +175,20 @@ func Users(r Records) ([]Principal, error) { grants := -1 for _, node := range sortedCopy(r.Nodes) { var checks []string - bus := false + bus, runner := false, "" for _, d := range r.Assigned[node] { if d.SnapshotsTheBus { bus = true } + if d.ToolRunner { + runner = d.Module // the node-engine's witness pings it by the name its manifest gives (ADR 0601 §4) + } checks = append(checks, d.Checks...) } // **One user per login the node has** (novox/hq ADR 0583 §1): the one in use, and while it rotates the other too, // each with the node's permissions. for _, login := range nodeLoginsOf(r, node) { - out = append(out, Principal{Kind: KindNode, Node: node, Login: login, Checks: checks, + out = append(out, Principal{Kind: KindNode, Node: node, Login: login, Checks: checks, Runner: runner, MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r), FollowsGrantsUserList: bus, ServedReads: servedOn(served, node), RotatesItsLogin: servesVerb(r, VaultRotateNode)}) @@ -202,10 +207,10 @@ func Users(r Records) ([]Principal, error) { // node: its serving grants are the union of theirs. Every other module keeps its own // principal — a module still serving tools from its own container holds its own // credential until it moves, and the two serve side by side in the meantime. - runtimeHere := false + runtimeHere, runner := false, "" for _, d := range r.Assigned[node] { - if d.Module == RuntimeModule { - runtimeHere = true + if d.ToolRunner { + runtimeHere, runner = true, d.Module } } for _, d := range r.Assigned[node] { @@ -237,7 +242,7 @@ func Users(r Records) ([]Principal, error) { if len(d.Ledgers) > 0 { out = append(out, Principal{Kind: KindLedger, Node: node, Module: d.Module, Ledgers: sortedCopy(d.Ledgers)}) } - if runtimeHere && d.Module == RuntimeModule { + if runtimeHere && d.ToolRunner { continue } // **A module with nowhere to read an account is no user** (novox/hq issue 195). `module @@ -284,7 +289,7 @@ func Users(r Records) ([]Principal, error) { carried = append(carried, d) } } - out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: RuntimeModule, Carries: carried}) + out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: runner, Carries: carried}) } } // A node enrols through the seat mesh-enrolment alone (ADR 0587 §6, ADR 0590): while no holder serves redeem, an @@ -525,7 +530,7 @@ func CarriedByRunner(d Declared) bool { return Composed(d) && d.RunsAs == "" && // 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.Module == RuntimeModule || 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.Waits) > 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 index c92300ed..1fb8616b 100644 --- a/modules/mesh-grants/internal/buscompose/waits.go +++ b/modules/mesh-grants/internal/buscompose/waits.go @@ -62,9 +62,9 @@ func waitGrants(carries []Declared) ([]string, error) { if len(d.Waits) == 0 { continue } - if d.Module != RuntimeModule { - return nil, fmt.Errorf("%s declares what a session waits on; only %s, the tool runner's module, "+ - "does (novox/hq ADR 0367 §2)", d.Module, RuntimeModule) + 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) @@ -79,7 +79,7 @@ func waitGrants(carries []Declared) ([]string, error) { // waitsIssuedFor is the families a membership names: the tool runner's module's, and nobody else's. func waitsIssuedFor(d Declared) []WaitIssued { - if d.Module != RuntimeModule { + if !d.ToolRunner { return nil } var out []WaitIssued diff --git a/modules/mesh-grants/internal/grants/held.go b/modules/mesh-grants/internal/grants/held.go new file mode 100644 index 00000000..1a283593 --- /dev/null +++ b/modules/mesh-grants/internal/grants/held.go @@ -0,0 +1,145 @@ +package grants + +import ( + "fmt" + "slices" + "sort" + "strings" + + "mesh-grants/internal/buscompose" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// **A module whose manifest waits or is refused keeps its bus users** (clause 3 of the record on a release's Approve; issue +// 774). The indexer holds a manifest it refuses, or that waits for a judge that knows a field of it, out of the manifests +// the part composes from (inputs.go, Refused), so a module with no want naming a build had no manifest to derive its users +// from and its users left the list: the bus module's accounts among them, which locked its tools out. Now such a module's +// users, the ones the list in force carries, stay in the list as it holds them, the same way a refused principal's do +// (refused.go), and the part says so as the warning mesh-grants.module-held until the manifest is accepted. One module's +// manifest never holds the list: every other principal is composed as ever. A module whose want names a build is composed +// from the manifest of that build (Inputs.Running), not kept. + +// HeldModuleCondition is the key of the warning raised while a module's manifest is held and its users are kept. +const HeldModuleCondition = "mesh-grants.module-held" + +// heldModuleUsers are, by user, why it is kept: the users of candidates (the ones the list in force and the bus carry) that +// belong to a module placed on a node whose manifest the indexer holds (in.Refused) and whose want names no build. A user of +// a module on a node is `.` or `..` (buscompose.Principal.Username). Pure. +func heldModuleUsers(in Inputs, candidates []string) map[string]string { + held := map[string]string{} + for _, g := range in.Refused { + if module, ok := strings.CutPrefix(g.Input, "manifest of "); ok { + held[module] = g.Why + } + } + out := map[string]string{} + if len(held) == 0 { + return out + } + for node, modules := range in.Assigned { + for _, module := range modules { + why, ok := held[module] + if !ok { + continue + } + if _, runs := in.Running[node+"."+module]; runs { + continue + } + prefix := node + "." + module + for _, user := range candidates { + if user == prefix || strings.HasPrefix(user, prefix+".") { + out[user] = fmt.Sprintf("the manifest of %s is held: %s", module, why) + } + } + } + } + return out +} + +// runnerUsersKept are, by user, why it is kept: on a node where no placed module's manifest says it is the tool runner +// (`tool-runner`), the user of the list in force that is that node's tool runner, known by what runs (it may say the runner +// is alive, buscompose.ToolsAliveSubject, which the runner alone is granted), never by a module's name (ADR 0601 §4). A +// manifest that does not yet say what shared code no longer infers keeps the runner as it runs, said in the held module's +// condition, and is never composed without it. Pure. +func runnerUsersKept(recs buscompose.Records, inForce map[string]User) map[string]string { + out := map[string]string{} + for node, placed := range recs.Assigned { + if slices.ContainsFunc(placed, func(d buscompose.Declared) bool { return d.ToolRunner }) { + continue + } + alive := buscompose.ToolsAliveSubject(node) + for user, u := range inForce { + if strings.HasPrefix(user, node+".") && slices.Contains(u.Publish, alive) { + out[user] = fmt.Sprintf("no manifest placed on %s says it is the tool runner (tool-runner), so the tool runner keeps "+ + "what it runs until its manifest says so", node) + } + } + } + return out +} + +// inForceGrants are the grants of each user of the list in force as the part sealed them, or nil where it kept none. +func (p *Part) inForceGrants() map[string]User { + if g, _, had := p.Sealed(); had && g.Digest != "" && p.Store != nil { + inForce, _ := p.sealedGrantsOf(g.Digest) + return inForce + } + return nil +} + +// mergedRefusals is refused and held in one map, for the parts that keep a user's grants of the list in force. +func mergedRefusals(refused, held map[string]string) map[string]string { + if len(held) == 0 { + return refused + } + out := make(map[string]string, len(refused)+len(held)) + for u, why := range held { + out[u] = why + } + for u, why := range refused { + out[u] = why + } + return out +} + +// sayHeldModules raises HeldModuleCondition while a held module's users are kept, naming each user with what became of it, +// and clears it once none is; said once per set, again when it changes, and at the first pass after a start. +func (p *Part) sayHeldModules(held map[string]string, kept map[string]User) { + if p.Say == nil { + return + } + var lines []string + users := make([]string, 0, len(held)) + for u := range held { + users = append(users, u) + } + sort.Strings(users) + for _, user := range users { + became := "not in the list in force and left out" + if _, ok := kept[user]; ok { + became = "stays in the list as the list in force holds it" + } + lines = append(lines, user+" ("+became+"): "+held[user]) + } + why := strings.Join(lines, "; ") + p.holdMu.Lock() + defer p.holdMu.Unlock() + if p.heldModulesKnown && why == p.heldModulesSaid { + return + } + c := asks.Condition{Key: HeldModuleCondition, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}} + if why == "" { + c.State = asks.StateCleared + } else { + c.Severity, c.State = asks.SeverityWarning, asks.StateRaised + c.Summary = fmt.Sprintf("%d bus user(s) of a module whose manifest is held are kept in the list: %s", len(users), why) + c.Headline, c.Needs = "A module's manifest is held, its bus users are kept", "a manifest the indexer accepts, or a judge that knows its fields" + c.Explanation = asks.NeedsYou + "a manifest the indexer accepts (a refused one mended, or a waiting one judged by an indexer that knows its fields). " + + "The rest of the user list is written as ever; a held module's users stay as the list in force holds them: " + why + c.Resolved = "every module's manifest is accepted" + } + if p.Say(c) == nil { + p.heldModulesSaid, p.heldModulesKnown = why, true + } +} diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 035ffb21..82b6a39c 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -131,7 +131,11 @@ type Inputs struct { // Refused is every manifest the indexer holds that the part cannot read or mesh-sdk's reader refuses, assigned or // not, each with why: its module counts as placed nowhere, so it is said loudly, never passed over (issue 1945). Refused []Gap - Gaps []Gap + // Held is, by module, the manifest a held module's running build was judged on (its have's fingerprint, the builder's + // build of it): what the seats it defines, the streams others hear from it and its reads are composed from while its + // trunk manifest is refused or waits, so no other principal loses a grant to it (ADR 0601 clause 3). + Held map[string]catalogue.Manifest + Gaps []Gap } // HeldHash is one key of the vault's bus-accounts: the hash, and what it is of. The hash is never said by the part, and @@ -353,8 +357,8 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { } var m catalogue.Manifest if err == nil { - // The indexer's refusal, carried in its value (ADR 0578 §3): held and named as a refused manifest. - if verdict := v.VerdictOf(module); verdict != nil && verdict.Refused() { + // The indexer's refusal, or its wait for a judge that knows a field, carried in its value (ADR 0578 §3): held and named as a refused manifest. + if verdict := v.VerdictOf(module); verdict != nil && verdict.Held() { err = errors.New(verdict.Why()) } } @@ -440,6 +444,23 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { sort.Strings(fromTrunk) in.Gaps = append(in.Gaps, Gap{Input: "wanted manifests", Why: "no build record names the manifest at the wanted fingerprint, so the trunk's is used: " + strings.Join(fromTrunk, ", ")}) } + in.Held = heldManifests(r, in, cache) + // A held module placed on a node whose running manifest is not found declares no seat, stream or object bucket to + // anyone: composing past it would drop the grants of those who use them, so the list in force is kept (ADR 0601 + // clause 3), and the gap says which module and why. + for _, g := range in.Refused { + module, ok := strings.CutPrefix(g.Input, "manifest of ") + if _, found := in.Held[module]; !ok || found { + continue + } + for _, assigned := range in.Assigned { + if slices.Contains(assigned, module) { + in.Gaps = append(in.Gaps, Gap{Input: "running manifest of " + module, Failed: true, + Why: "its manifest is held and no running build's manifest is found, so the seats and streams it declares cannot be composed: the list in force is kept"}) + break + } + } + } in.Hashes = readHashes(r, &in.Gaps) // The seat mesh-enrolment's keys are every token since the controller's end (issue 1126). in.Enrolling, _ = readEnrolling(r, &in.Gaps) @@ -551,16 +572,81 @@ func sortGaps(g []Gap) { sort.SliceStable(g, func(i, j int) bool { return strings.Compare(g[i].Input, g[j].Input) < 0 }) } -// ReadFailures names every read this pass could not make and every manifest refused: what a list composed now may lack. -func (in Inputs) ReadFailures() []string { +// ReadFailed names every read this pass could not make: what a list composed now may lack. A manifest the indexer holds +// (refused, or waiting for a judge that knows a field of it) is not one: it holds its module's users in the list as the +// list in force has them (held.go), and never holds the list, so one manifest never freezes the bus's users (issue 774). +func (in Inputs) ReadFailed() []string { var out []string for _, g := range in.Gaps { if g.Failed { out = append(out, g.Input+": "+g.Why) } } + return out +} + +// ReadFailures is ReadFailed and every manifest refused or waiting: what the part retires nothing on (the accounts it +// keeps, the hearings it withholds). +func (in Inputs) ReadFailures() []string { + out := in.ReadFailed() for _, g := range in.Refused { out = append(out, g.Input+": "+g.Why) } return out } + +// heldManifests are the manifests the running builds of the held modules (in.Refused) were judged on, by module: the have +// of the module on a node names the fingerprint that runs, and the builder's build of it the manifest. A module that runs +// nowhere, or whose build or manifest cannot be read, has none, and that is said as a gap. +func heldManifests(r Reader, in Inputs, cache *Manifests) map[string]catalogue.Manifest { + out := map[string]catalogue.Manifest{} + for _, g := range in.Refused { + module, ok := strings.CutPrefix(g.Input, "manifest of ") + if !ok { + continue + } + for _, node := range in.Nodes { + raw, found, err := r.Get(ReadHave, node+"."+module) + if err != nil || !found { + continue + } + h, err := delivery.DecodeHave(raw) + if err != nil || h.FP == "" { + continue + } + bk, err := delivery.BuilderBuildKey(module, h.FP) + if err != nil { + continue + } + braw, found, err := r.Get(ReadBuilds, bk) + if err != nil || !found { + continue + } + b, err := delivery.DecodeBuilderBuild(braw) + if err != nil || b.Manifest == "" { + continue + } + if m, err := cache.get(r, b.Manifest); err == nil { + out[module] = m + break + } + } + } + return out +} + +// declared are the manifests the seats, hearings and reads are composed from: the trunk's, and for a held module the +// manifest its running build was judged on (Held). +func (in Inputs) declared() map[string]catalogue.Manifest { + if len(in.Held) == 0 { + return in.Manifests + } + out := make(map[string]catalogue.Manifest, len(in.Manifests)+len(in.Held)) + for m, man := range in.Held { + out[m] = man + } + for m, man := range in.Manifests { + out[m] = man + } + return out +} diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 29b8ffe4..ed644c8d 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -189,6 +189,9 @@ type Part struct { // refusedSaid is the refused principals last said as a condition, once refusedKnown (refused.go). refusedSaid string refusedKnown bool + // heldModulesSaid is the held modules' kept users last said as a condition, once heldModulesKnown (held.go). + heldModulesSaid string + heldModulesKnown bool // missingSaid is the modules without a grant last said as a condition, once missingKnown (grants.go). missingSaid string missingKnown bool @@ -259,14 +262,27 @@ func (p *Part) Compose() Result { res.Errors = append(res.Errors, "the shadow list could not be composed: "+err.Error()) } // A refused principal keeps its grants of the list in force, said once per change (refused.go). - kept, _ := p.keptOf(refused) + // And a module whose manifest is held (refused, or waiting for a judge that knows a field of it) and whose want names no + // build keeps its users of the list in force (held.go). + loaded, loadErr := LoadedUsers(p.Call) + inForce := p.inForceGrants() + candidates := names(loaded) + for user := range inForce { + candidates = append(candidates, user) + } + heldUsers := heldModuleUsers(in, candidates) + for user, why := range runnerUsersKept(listRecs, inForce) { + heldUsers[user] = why + } + allRefused := mergedRefusals(refused, heldUsers) + kept, _ := p.keptOf(allRefused) for _, user := range sortedKeys(refused) { res.PrincipalsRefused = append(res.PrincipalsRefused, user+": "+refused[user]) } p.sayPrincipalsRefused(refused, kept) + p.sayHeldModules(heldUsers, kept) users = WithHashes(withKept(users, kept), in.Hashes) st.Digest, st.Users = Digest(users), len(users) - loaded, loadErr := LoadedUsers(p.Call) st.Compare = Compare(users, loaded, Placed(in)).Excusing(ExcusedBy(MultiHeld(listRecs))) if loadErr != nil { st.Compare.Unread = loadErr.Error() @@ -298,7 +314,7 @@ func (p *Part) Compose() Result { if p.Store != nil { p.writeWithheld(&res, in, recs) } - list, n, leftOut, dropped, err := ComposeList(listRecs, in.Hashes, refused, kept) + list, n, leftOut, dropped, err := ComposeList(listRecs, in.Hashes, allRefused, kept) // The grants the assigner reads (ADR 0547 §4, §5): one that adds a permission only once the bus loaded a list that // carries it (issue 774). if p.Store != nil { diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index bb74d475..c93bf116 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -27,7 +27,8 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { var gaps []Gap seats := map[string]catalogue.SeatDeclaration{} declarers := map[string]string{} - for _, m := range in.Manifests { + declared := in.declared() + for _, m := range declared { for _, s := range m.DefinesSeats { seats[s.Name] = s declarers[s.Name] = m.Module @@ -79,8 +80,8 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { for _, sr := range catalogue.ReadsGranted(m, here, node) { d.KeyedReads = append(d.KeyedReads, buscompose.KeyedRead{Read: sr.Read, Key: sr.Key}) } - d.Hears, d.HearsAStream, d.Consumes = hearsOf(m, in.Manifests, node, values, d.Consumes) - d.Reads, d.ReadsObjects, d.ReadsRefused = readsOfObjects(m.Module, node, d.Reads, d.ReadsRefused, in.Manifests, values) + d.Hears, d.HearsAStream, d.Consumes = hearsOf(m, declared, node, values, d.Consumes) + d.Reads, d.ReadsObjects, d.ReadsRefused = readsOfObjects(m.Module, node, d.Reads, d.ReadsRefused, declared, values) return d } d := declare(m) @@ -101,7 +102,7 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { } // The node-engine's own manifest, placed on every node (ADR 0563): its state, logs and streams alone, created by this // part; it adds no principal, since each node's engine speaks as the node's account. - if m, ok := in.Manifests[buscompose.EngineModule]; ok { + if m, ok := declared[buscompose.EngineModule]; ok { out.Engine = &buscompose.Declared{Module: m.Module, State: bucketsFor(m, in.Nodes), Logs: logsOf(m), Streams: streamsOf(m)} } return out, gaps @@ -131,7 +132,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio fromModules = append(fromModules, c) } d := buscompose.Declared{ - Module: m.Module, Emits: m.EmitsAll(), Consumes: fromModules, Watches: watches, Serves: m.Tools, + 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), Streams: streamsOf(m), Queues: queueOwnersOf(m), Processes: processesOf(m), Checks: catalogue.HealthChecks(m), RunsAs: m.RunsAs, Issued: issuedTools(m), diff --git a/modules/mesh-grants/internal/grants/sealed.go b/modules/mesh-grants/internal/grants/sealed.go index 9b83190f..376900e1 100644 --- a/modules/mesh-grants/internal/grants/sealed.go +++ b/modules/mesh-grants/internal/grants/sealed.go @@ -299,7 +299,7 @@ func listUsers(list []byte) []string { // they could not be read). Empty when it may. func (p *Part) holdsBack(in Inputs, leftOut, dropped, bus, lost []string, digest string) string { p.hashHold = nil - if f := in.ReadFailures(); len(f) > 0 { + if f := in.ReadFailed(); len(f) > 0 { return "held: the list in force is kept while a read of this pass failed (" + strings.Join(f, "; ") + ")" } if bus == nil { diff --git a/sdk/go.mod b/sdk/go.mod new file mode 100644 index 00000000..1ae3ea03 --- /dev/null +++ b/sdk/go.mod @@ -0,0 +1,5 @@ +module git.novox.be/novox/mesh-delivery/sdk + +go 1.26.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 diff --git a/sdk/go.sum b/sdk/go.sum new file mode 100644 index 00000000..30b01cbe --- /dev/null +++ b/sdk/go.sum @@ -0,0 +1,14 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/sdk/graph/graph.go b/sdk/graph/graph.go new file mode 100644 index 00000000..b0c7951c --- /dev/null +++ b/sdk/graph/graph.go @@ -0,0 +1,411 @@ +package graph + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "reflect" + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Version is the version of a node's value this package writes and the only one it reads. +const Version = 1 + +// ErrUnknownVersion is what Decode returns for a value whose version is missing or not Version. +var ErrUnknownVersion = errors.New("graph: unknown value version") + +func decode(data []byte, into *Node) error { + var probe struct { + V *int `json:"v"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return fmt.Errorf("graph: value does not fit its shape: %w", err) + } + if probe.V == nil || *probe.V != Version { + return fmt.Errorf("%w: this reader reads %d", ErrUnknownVersion, Version) + } + if err := json.Unmarshal(data, into); err != nil { + return fmt.Errorf("graph: value does not fit its shape: %w", err) + } + return nil +} + +// checkName refuses a module name a key cannot hold (delivery.ModulesKey's rule). +func checkName(kind, name string) error { + if _, err := delivery.ModulesKey(name); err != nil { + return fmt.Errorf("graph: the %s name %q: %w", kind, name, err) + } + return nil +} + +// checkRepository refuses a repository that is not /. +func checkRepository(repository string) error { + owner, name, ok := strings.Cut(repository, "/") + if !ok || owner == "" || name == "" || strings.Contains(name, "/") { + return fmt.Errorf("graph: the repository %q is not /", repository) + } + return nil +} + +// The module graph (novox/hq ADR 0603 §2, ADR 0604 §1): the indexer keeps, as its state `graph`, one key per module it +// holds the inputs of, written at every recompute and deleted with the module. A key holds the module's build edges +// (its bases, the mesh packages its fingerprint takes, and per language the build module, toolchain version and recipe +// version its fingerprint names, ADR 0530 §1) and its provision edges (what its manifest provides, requires, and of what +// it requires what a want of it waits for). Nothing stores an order: a reader works it out from the edges (ADR 0603 +// §1, §6). The indexer writes it and works the edges out (EdgesOf), the delivery view, the checker and the indexer's +// answers of the seat mesh-catalog read it; all three are in this repository, which uses this package by path (the +// operator, 2026-11-03: no graph-related code in mesh-sdk, only contracts). It names no module. + +const ( + // State is the name of the indexer's state of the module graph, as its manifest declares it, and Bucket its + // bucket: one key per module (Key). + State = "graph" + Bucket = delivery.IndexerModule + "_" + State + // Read is the line a reader's manifest names in `reads` to read the graph. + Read = delivery.IndexerModule + "." + State + // History is the revisions the graph state keeps per key (ADR 0603 §2). + History = 5 +) + +// Key is the key, in Bucket, of a module: ``, as ModulesKey. +func Key(module string) (string, error) { return delivery.ModulesKey(module) } + +// ParseKey is the inverse of Key. +func ParseKey(key string) (string, error) { + module, err := Key(key) + if err != nil { + return "", fmt.Errorf("graph: %q is not a graph key: %w", key, err) + } + return module, nil +} + +// Node is the value of a graph key: one module's edges as its last recompute computed them. FP is its new-form +// fingerprint then, or None why it has none (exactly one is set); Repository and Path say where it is (as Module), so a +// package edge is resolved to the module that publishes it (GraphPublisher). Bases are the modules it is built on; +// Packages the mesh packages (Go module paths) its fingerprint took (Module.Packages); Toolchains, by language, the +// build module and the toolchain it was computed with. Provides are the provisions its manifest offers (its own name is +// provided too and is not listed), Requires what it requires and does not require optionally, and Awaits those of +// Requires a want of it waits for (catalogue.Awaited, ADR 0570 §4): the provision edges that order a source revision. +// Every list is sorted and each entry once; an encode sorts them. +type Node struct { + V int `json:"v"` + FP string `json:"fp,omitempty"` + None string `json:"none,omitempty"` + Repository string `json:"repository"` + Path string `json:"path,omitempty"` + Bases []string `json:"bases,omitempty"` + Packages []string `json:"packages,omitempty"` + Toolchains map[string]Toolchain `json:"toolchains,omitempty"` + Provides []string `json:"provides,omitempty"` + Requires []string `json:"requires,omitempty"` + Awaits []string `json:"awaits,omitempty"` +} + +// Toolchain is a build edge of one language: the build module that builds it (Module) and the toolchain version +// and recipe version its fingerprint names. +type Toolchain struct { + Module string `json:"module"` + Version string `json:"version"` + Recipe string `json:"recipe"` +} + +func (g Node) check() error { + switch { + case (g.FP == "") == (g.None == ""): + return fmt.Errorf("graph: a graph node has either a fingerprint or a reason for none, not both or neither") + case g.FP != "" && !strings.HasPrefix(g.FP, delivery.SourcePrefix2): + return fmt.Errorf("graph: the graph node's fingerprint %q does not start with %s", g.FP, delivery.SourcePrefix2) + } + if err := checkRepository(g.Repository); err != nil { + return err + } + for _, b := range g.Bases { + if err := checkName("base", b); err != nil { + return err + } + } + for _, p := range g.Packages { + if _, _, ok := delivery.PackageRepository(p); !ok { + return fmt.Errorf("graph: the graph node's package %q is no mesh package's path", p) + } + } + for language, t := range g.Toolchains { + if err := checkName("build module", t.Module); err != nil { + return fmt.Errorf("graph: the graph node's toolchain of %q: %w", language, err) + } + if language == "" || t.Version == "" || t.Recipe == "" { + return fmt.Errorf("graph: the graph node's toolchain of %q needs its language, version and recipe version", language) + } + } + for _, list := range [][]string{g.Provides, g.Requires, g.Awaits} { + if slices.Contains(list, "") { + return fmt.Errorf("graph: a graph node names an empty provision") + } + } + for _, a := range g.Awaits { + if !slices.Contains(g.Requires, a) { + return fmt.Errorf("graph: the graph node awaits %q, which it does not require", a) + } + } + return nil +} + +func sortedOnce(in []string) []string { + if len(in) == 0 { + return nil + } + out := slices.Clone(in) + slices.Sort(out) + return slices.Compact(out) +} + +func (g Node) normal() Node { + g.Bases, g.Packages = sortedOnce(g.Bases), sortedOnce(g.Packages) + g.Provides, g.Requires, g.Awaits = sortedOnce(g.Provides), sortedOnce(g.Requires), sortedOnce(g.Awaits) + if len(g.Toolchains) == 0 { + g.Toolchains = nil + } + return g +} + +// Encode is g as JSON at Version, its lists sorted; it refuses what Node's contract does not hold. +func Encode(g Node) ([]byte, error) { + g = g.normal() + if err := g.check(); err != nil { + return nil, err + } + g.V = Version + return json.Marshal(g) +} + +// Decode reads a graph node; it refuses an unknown version (ErrUnknownVersion) and what Encode refuses. +func Decode(data []byte) (Node, error) { + var g Node + if err := decode(data, &g); err != nil { + return Node{}, err + } + if err := g.check(); err != nil { + return Node{}, err + } + return g.normal(), nil +} + +// Same says whether two graph nodes are the same value once sorted, so a writer writes only when they differ. +func Same(a, b Node) bool { + a, b = a.normal(), b.normal() + a.V, b.V = 0, 0 + return reflect.DeepEqual(a, b) +} + +// Graph is the module graph as a reader holds it: every graph node, by module. +type Graph map[string]Node + +// Providers are the modules that provide provision: each whose Provides names it, and the module of that name (a module +// provides its own name), sorted. +func (g Graph) Providers(provision string) []string { + var out []string + for name, n := range g { + if name == provision || slices.Contains(n.Provides, provision) { + out = append(out, name) + } + } + slices.Sort(out) + return out +} + +// Requirers are the modules that require provision, not optionally, sorted. +func (g Graph) Requirers(provision string) []string { + var out []string + for name, n := range g { + if slices.Contains(n.Requires, provision) { + out = append(out, name) + } + } + slices.Sort(out) + return out +} + +// Publisher is the module of the graph whose repository and directory publish the mesh package pkg (PackageRepository): +// the module whose directory is the package's directory or the nearest one above it (the longest match, a package +// published from inside a module's directory belonging to that module), or "" when the graph holds none. +func (g Graph) Publisher(pkg string) string { + repository, dir, ok := delivery.PackageRepository(pkg) + if !ok { + return "" + } + if dir == "" { + dir = delivery.RootPath + } + best, bestLen := "", -1 + for _, name := range slices.Sorted(maps.Keys(g)) { + n := g[name] + if n.Repository != repository { + continue + } + switch { + case n.Path == dir: + return name + case n.Path == delivery.RootPath: + if bestLen < 0 { + best, bestLen = name, 0 + } + case strings.HasPrefix(dir, n.Path+"/") && len(n.Path) > bestLen: + best, bestLen = name, len(n.Path) + } + } + return best +} + +// buildEdges are the modules n's build edges reach: its bases, the publisher of each of its packages, and the build +// module of each language. +func (g Graph) buildEdges(n Node) []string { + out := slices.Clone(n.Bases) + for _, p := range n.Packages { + if m := g.Publisher(p); m != "" { + out = append(out, m) + } + } + for _, t := range n.Toolchains { + out = append(out, t.Module) + } + return sortedOnce(out) +} + +// Dependents are the modules whose build edges reach module, directly or through other modules' (a base of a base, a +// module built by a build module that stands on module), sorted and without module itself (ADR 0604 §1): what a +// change of module builds again. +func (g Graph) Dependents(module string) []string { + reverse := map[string][]string{} + for _, name := range slices.Sorted(maps.Keys(g)) { + for _, to := range g.buildEdges(g[name]) { + reverse[to] = append(reverse[to], name) + } + } + seen := map[string]bool{module: true} + queue := []string{module} + var out []string + for len(queue) > 0 { + at := queue[0] + queue = queue[1:] + for _, from := range reverse[at] { + if !seen[from] { + seen[from] = true + out = append(out, from) + queue = append(queue, from) + } + } + } + slices.Sort(out) + return out +} + +// Before are the modules name waits for, by the graph's ordering edges, sorted: its bases and, for each provision it +// awaits, every other module that provides it (ADR 0603 §1). A toolchain or package edge orders nothing (§3). +func (g Graph) Before(name string) []string { + n := g[name] + out := slices.Clone(n.Bases) + for _, a := range n.Awaits { + for _, p := range g.Providers(a) { + if p != name { + out = append(out, p) + } + } + } + return sortedOnce(out) +} + +// Cycle is the first cycle of the graph's ordering edges, as the modules it passes through with the first named again +// at its end (a -> b -> a), or nil for none (ADR 0603 §2: the check refuses it and names it). The ordering edges are a +// module's bases and, for each provision it awaits, every other module that provides it: modules ordered on each other +// would each wait for the other for good. A toolchain edge is no ordering edge (ADR 0603 §3: nothing waits on a build +// module), and neither is a package edge. The modules are visited in sorted order, so the same graph names the same +// cycle. +func (g Graph) Cycle() []string { + edges := g.Before + const ( + unseen = iota + onPath + done + ) + state := map[string]int{} + var path []string + var visit func(string) []string + visit = func(name string) []string { + state[name] = onPath + path = append(path, name) + for _, to := range edges(name) { + if _, ours := g[to]; !ours { + continue + } + switch state[to] { + case onPath: + i := slices.Index(path, to) + return append(slices.Clone(path[i:]), to) + case unseen: + if c := visit(to); c != nil { + return c + } + } + } + path = path[:len(path)-1] + state[name] = done + return nil + } + for _, name := range slices.Sorted(maps.Keys(g)) { + if state[name] == unseen { + if c := visit(name); c != nil { + return c + } + } + } + return nil +} + +// EdgesOf is the graph node of manifest's own edges: its bases (each module its build stands on), what it provides, +// what it requires and does not require optionally, and of those what a want of it waits for (catalogue.Awaited, as the +// assigner judges it), providers answering the manifests that provide a provision. The fingerprint, place, packages and +// toolchains are the indexer's to add; this is the part a reader of manifests alone (the checker) works out the same way. +func EdgesOf(manifest catalogue.Manifest, providers func(provision string) []catalogue.Manifest) Node { + var n Node + if manifest.Build != nil { + for _, on := range manifest.Build.On { + if on.Module != "" { + n.Bases = append(n.Bases, on.Module) + } + } + } + for _, o := range manifest.Provides { + n.Provides = append(n.Provides, o.Name) + } + for _, r := range manifest.Requires { + if !manifest.OptionalRequires[r] { + n.Requires = append(n.Requires, r) + } + } + n.Awaits = catalogue.Awaited(manifest, providers) + return n.normal() +} + +// OfManifests is the graph of these manifests' own edges (EdgesOf), by module, each provision's providers being +// the manifests among them that offer it: what the check works out for a change to find a cycle (ADR 0603 §2). +func OfManifests(manifests map[string]catalogue.Manifest) Graph { + providers := func(provision string) []catalogue.Manifest { + var out []catalogue.Manifest + for _, name := range slices.Sorted(maps.Keys(manifests)) { + if slices.Contains(manifests[name].Offers(), provision) { + out = append(out, manifests[name]) + } + } + return out + } + g := Graph{} + for name, m := range manifests { + g[name] = EdgesOf(m, providers) + } + return g +} diff --git a/sdk/graph/graph_test.go b/sdk/graph/graph_test.go new file mode 100644 index 00000000..2848f19c --- /dev/null +++ b/sdk/graph/graph_test.go @@ -0,0 +1,188 @@ +package graph + +import ( + "errors" + "reflect" + "slices" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// TestGraphEdges: the module graph's cycle, dependents and providers, each from the edges alone (ADR 0603 §2, ADR 0604 §1). +func TestGraphEdges(t *testing.T) { + t.Run("a node's value", nodeValues) + fp := "src2:" + strings.Repeat("b", 64) + node := func(repository, path string, edit func(*Node)) Node { + n := Node{FP: fp, Repository: repository, Path: path} + if edit != nil { + edit(&n) + } + return n + } + gotool := map[string]Toolchain{"go": {Module: "build-go", Version: "go1", Recipe: "1"}} + // A base chain, a package and a build module: app stands on base, imports lib's package, is built by build-go. + g := Graph{ + "build-go": node("novox/mesh-catalog", "modules/build-go", func(n *Node) { n.Toolchains = gotool }), + "lib": node("novox/lib", delivery.RootPath, nil), + "base": node("novox/mesh-catalog", "modules/base", nil), + "app": node("novox/mesh-catalog", "modules/app", func(n *Node) { + n.Bases, n.Packages, n.Toolchains = []string{"base"}, []string{"git.novox.be/novox/lib"}, gotool + }), + "top": node("novox/mesh-catalog", "modules/top", func(n *Node) { n.Bases = []string{"app"} }), + // A package published from inside a module's directory belongs to that module (ADR 0604 §1): users imports build-go's. + "users": node("novox/other", "modules/users", func(n *Node) { + n.Packages = []string{"git.novox.be/novox/mesh-catalog/modules/build-go/buildrun"} + }), + "db": node("novox/mesh-catalog", "modules/db", func(n *Node) { n.Provides = []string{"database"} }), + "web": node("novox/mesh-catalog", "modules/web", func(n *Node) { + n.Requires, n.Awaits = []string{"database", "db"}, []string{"database"} + }), + } + for module, want := range map[string][]string{ + "base": {"app", "top"}, + "lib": {"app", "top"}, + "build-go": {"app", "top", "users"}, // build-go's own toolchain edge reaches itself, which is left out + "top": nil, + "db": nil, // a provision edge is no build edge + } { + if got := g.Dependents(module); !reflect.DeepEqual(got, want) { + t.Errorf("dependents of %s = %v, want %v", module, got, want) + } + } + if got := g.Providers("database"); !reflect.DeepEqual(got, []string{"db"}) { + t.Errorf("providers of database = %v", got) + } + if got := g.Providers("db"); !reflect.DeepEqual(got, []string{"db"}) { // a module provides its own name + t.Errorf("providers of db = %v", got) + } + if got := g.Before("web"); !reflect.DeepEqual(got, []string{"db"}) { // web awaits database, which db provides + t.Errorf("web waits for %v", got) + } + if got := g.Before("top"); !reflect.DeepEqual(got, []string{"app"}) { + t.Errorf("top waits for %v", got) + } + if got := g.Requirers("db"); !reflect.DeepEqual(got, []string{"web"}) { + t.Errorf("requirers of db = %v", got) + } + for pkg, want := range map[string]string{ + "git.novox.be/novox/mesh-catalog/modules/build-go/buildrun": "build-go", // inside its directory + "git.novox.be/novox/mesh-catalog/modules/build-go": "build-go", // its directory + "git.novox.be/novox/mesh-catalog/modules/build-gone": "", // beside it, not inside + "git.novox.be/novox/lib/sub": "lib", // a root module publishes what is below it + } { + if got := g.Publisher(pkg); got != want { + t.Errorf("publisher of %s = %q, want %q", pkg, got, want) + } + } + if g.Publisher("git.novox.be/novox/lib") != "lib" || g.Publisher("git.novox.be/novox/mesh-sdk/go") != "" { + t.Error("a package's publisher is not the module at its repository and directory") + } + // From manifests alone (the check's graph): a requirement answered by a helper at mesh scope is awaited, an optional + // one is not, and two modules awaiting each other's provision are a cycle. + offer := func(name string) []catalogue.Offer { + return []catalogue.Offer{{Name: name, Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}} + } + ms := map[string]catalogue.Manifest{ + "db": {Module: "db", Provides: offer("database"), Requires: []string{"site", "logs"}, OptionalRequires: map[string]bool{"logs": true}}, + "web": {Module: "web", Provides: offer("site"), Requires: []string{"database"}, Build: &catalogue.Build{On: []catalogue.BuildsOn{{Module: "base"}}}}, + } + mg := OfManifests(ms) + if db := mg["db"]; !reflect.DeepEqual(db.Requires, []string{"site"}) || !reflect.DeepEqual(db.Awaits, []string{"site"}) || + !reflect.DeepEqual(mg["web"].Bases, []string{"base"}) || !reflect.DeepEqual(db.Provides, []string{"database"}) { + t.Errorf("the graph of manifests = %+v", mg) + } + if got := mg.Cycle(); !reflect.DeepEqual(got, []string{"db", "web", "db"}) { + t.Errorf("the cycle of manifests = %v", got) + } + for name, c := range map[string]struct { + edit func(Graph) + want []string + }{ + "none: a toolchain on itself and a requirement not awaited order nothing": {func(Graph) {}, nil}, + "bases": {func(g Graph) { + b := g["base"] + b.Bases = []string{"top"} + g["base"] = b + }, []string{"app", "base", "top", "app"}}, + "provisions a -> b -> a": {func(g Graph) { + d := g["db"] + d.Requires, d.Awaits = []string{"site"}, []string{"site"} + g["db"] = d + w := g["web"] + w.Provides = []string{"site"} + g["web"] = w + }, []string{"db", "web", "db"}}, + "a requirement not awaited is no edge": {func(g Graph) { + d := g["db"] + d.Requires = []string{"site"} + g["db"] = d + w := g["web"] + w.Provides = []string{"site"} + g["web"] = w + }, nil}, + } { + h := Graph{} + for k, v := range g { + h[k] = v + } + c.edit(h) + if got := h.Cycle(); !reflect.DeepEqual(got, c.want) { + t.Errorf("%s: cycle = %v, want %v", name, got, c.want) + } + } +} + +// sampleNode is a graph node with every kind of edge, its lists unsorted. +func sampleNode() Node { + return Node{FP: "src2:" + strings.Repeat("a", 64), Repository: "novox/mesh-catalog", Path: "modules/gitea", + Bases: []string{"z-base", "a-base"}, Packages: []string{"git.novox.be/novox/mesh-sdk/go"}, + Toolchains: map[string]Toolchain{"go": {Module: "build-go", Version: "go1.27.1", Recipe: "3"}}, + Provides: []string{"forge"}, Requires: []string{"postgres-database", "dns"}, Awaits: []string{"postgres-database"}} +} + +// nodeValues: a node round-trips with its lists sorted, an unknown version is refused, a node that does not hold is +// refused naming why, and the state is the indexer's graph (ADR 0603 §2). +func nodeValues(t *testing.T) { + if Bucket != "mesh-delivery-indexer_graph" || Read != "mesh-delivery-indexer.graph" || History != 5 || State != "graph" { + t.Error("the graph's state differs from the decided layout") + } + g := sampleNode() + data, err := Encode(g) + if err != nil || !strings.Contains(string(data), `"v":1`) { + t.Fatalf("encode: %s %v", data, err) + } + if got, err := Decode(data); err != nil || !Same(got, g) || !slices.IsSorted(got.Bases) { + t.Errorf("round trip: %+v %v", got, err) + } + if Same(g, Node{None: "x", Repository: "a/b"}) { + t.Error("different graph nodes are the same") + } + for _, in := range []string{`{"none":"x","repository":"a/b"}`, `{"v":2,"none":"x","repository":"a/b"}`} { + if _, err := Decode([]byte(in)); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("decode of %s = %v, want ErrUnknownVersion", in, err) + } + } + for name, c := range map[string]struct { + change func(*Node) + word string + }{ + "both": {func(g *Node) { g.None = "why" }, "both"}, + "neither": {func(g *Node) { g.FP = "" }, "neither"}, + "an old form": {func(g *Node) { g.FP = "src1:x" }, "src2:"}, + "a dotted base": {func(g *Node) { g.Bases = []string{"a.b"} }, "a.b"}, + "a foreign package": {func(g *Node) { g.Packages = []string{"example.com/x"} }, "example.com/x"}, + "a toolchain of no module": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Version: "go1", Recipe: "1"}} }, "build module"}, + "a toolchain of no recipe": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Module: "b", Version: "go1"}} }, "recipe"}, + "awaiting what it does not require": {func(g *Node) { g.Awaits = []string{"other"} }, "other"}, + "no repository": {func(g *Node) { g.Repository = "" }, "repositor"}, + } { + g := sampleNode() + c.change(&g) + if _, err := Encode(g); err == nil || !strings.Contains(err.Error(), c.word) { + t.Errorf("a node with %s: %v, want it refused naming %q", name, err, c.word) + } + } +}