From 7b8f5e7805ec59464c873a7049501e5b648a3c20 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 09:38:21 +0100 Subject: [PATCH 01/20] =?UTF-8?q?The=20sandbox=20composes=20a=20node=20as?= =?UTF-8?q?=20its=20node-engine=20does,=20from=20an=20input=20that=20says?= =?UTF-8?q?=20how,=20and=20names=20a=20node=20it=20cannot=20judge=20from?= =?UTF-8?q?=20an=20older=20engine's=20input=20(issue=20774,=20ADR=200593?= =?UTF-8?q?=20=C2=A79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/compose/frombus/frombus.go | 36 +++++++++++++++++++++ internal/compose/input.go | 20 ++++++++++++ internal/composer/composer.go | 44 ++++++++++++++++++++++++++ internal/composer/composer_test.go | 36 +++++++++++++++++++++ internal/selfcompose/candidate.go | 10 +++--- internal/selfcompose/candidate_test.go | 10 ++++++ 6 files changed, 152 insertions(+), 4 deletions(-) diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index f063f49..164baf2 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -571,6 +571,42 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s return out, skipped, held, err } +// ComposingInput is the input the scope's node composes itself from at the builds fps, as Compose reads it (the same read, +// the refusals it holds), with how Compose composes from it (compose.Input.ComposedHow): what a node-engine sends a +// check's sandbox, so the sandbox composes the node through ComposeSent, the path Compose takes (issue 774). Read no +// credential: the sandbox composes no pair value. +func ComposingInput(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (compose.Input, error) { + held := map[string]string{} + in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, fps) + if err != nil { + return compose.Input{}, err + } + c := &compose.ComposedHow{Node: s.Node, Builds: maps.Clone(fps), Held: held} + for _, run := range s.Running { + if _, isHeld := held[run.Module]; isHeld && run.Module != "" { + c.Running = append(c.Running, run) + } + } + in.ComposedHow = c + return in, nil +} + +// ComposeSent composes node from an input ComposingInput read, through the path Compose takes (composeEach, the read's holds +// first): what a check's sandbox runs. False when the input says no way of composing node (an input from a node-engine +// before ComposedHow): the caller cannot compose it as the node does. +func ComposeSent(in compose.Input, node string) (map[string]delivery.Composed, bool, error) { + c := in.ComposedHow + if c == nil || c.Node != node { + return nil, false, nil + } + held := maps.Clone(c.Held) + if held == nil { + held = map[string]string{} + } + out, _, _, err := composeEach(in, node, c.Builds, false, c.Running, held, nil) + return out, true, err +} + // Plans is the provisioning plan of each module handed to the provisioner on the scope's node, composed from the bus // alone (novox/hq ADR 0570 clause 2): the controller's handedPlans over the same input (mesh-control // cmd/mesh-controller/compose_sdk.go), its rendering in the Foreseeing mode with the mesh's generators. A plan names diff --git a/internal/compose/input.go b/internal/compose/input.go index 72aecf7..4471e5e 100644 --- a/internal/compose/input.go +++ b/internal/compose/input.go @@ -73,6 +73,13 @@ type Input struct { // issue 1126, C6). Used only while OperatorExport is nil. KeptRuns map[string]map[string]map[string]any + // ComposedHow is how the node-engine that read this input composes its node from it (frombus.Compose): the builds + // it composes each module at, the modules it holds and what runs of each held one. A node-engine answers it with the + // input it sends a check's sandbox (composer.SentInput), so the sandbox composes the node through the same path, a + // module folded in the node's own composition kept as it runs rather than resolved from its manifest (issue 774, the + // check of the bus want: novox's nats). Nil from every other reader and from a node-engine before it. + ComposedHow *ComposedHow + // Issued is the credentials made for the consumers of each provider (inv.SecretsFrom): one row for each pair credential // that exists, by the provider node it is for. The grants a provider must create are read from these, so a credential not // made yet has no grant until the send that makes it. An assigner fills it from the facts snapshot's Grants of each @@ -231,6 +238,19 @@ type Node struct { RunsTools map[string][]catalogue.Bundle } +// ComposedHow is how a node-engine composes its node from an Input (Input.ComposedHow). +type ComposedHow struct { + // Node is the node composed. + Node string + // Builds is the build each module is composed at, by module: a module the node runs folded in its own composition + // at a build of the old form has none, and is composed there as it runs (Node.InOwn, Node.RunsOwn). + Builds map[string]string + // Held is each module the read held, with why: composed as what runs of it, and the rest without it. + Held map[string]string + // Running is what runs of each held module, its own composition, by which it is carried as it runs. + Running []delivery.Composed +} + // Place is where a node is, as its placement says (inv.Overlay). type Place struct { Key string diff --git a/internal/composer/composer.go b/internal/composer/composer.go index 9204bc1..92961f1 100644 --- a/internal/composer/composer.go +++ b/internal/composer/composer.go @@ -12,6 +12,7 @@ import ( "fmt" "slices" "sort" + "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" sdk "git.novox.be/novox/mesh-sdk/go/composer" @@ -205,8 +206,25 @@ func machine(in compose.Input, node, depth string, declarations bool) Machine { } return Machine{Composes: true} } + // Composed as the node-engine composes it, when its input says how (compose.Input.ComposedHow, issue 774): the same + // path, at the same builds, a module folded in its own composition kept as it runs. + if !declarations { + if _, how, err := frombus.ComposeSent(in, node); how { + if err != nil { + return Machine{Reason: err.Error()} + } + return Machine{Composes: true} + } + } d, problems, err := compose.Compose(in, node) if err != nil { + // An input from a node-engine before ComposedHow says nothing of what runs of the modules folded in the node's + // own composition: the sandbox cannot compose them as the node does, so it does not judge the node, and names + // it (ADR 0593 §9), never a failure of the change and never a silent pass. + if folded := foldedUnsaid(in, node); len(folded) > 0 && in.ComposedHow == nil { + return Machine{Composes: true, Reason: NotJudged + strings.Join(folded, ", ") + + " (its own composition carries them as they run, and the sandbox cannot): " + err.Error()} + } return Machine{Reason: err.Error()} } m := Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()} @@ -219,6 +237,32 @@ func machine(in compose.Input, node, depth string, declarations bool) Machine { return m } +// NotJudged opens the reason of a machine the sandbox does not judge: its node-engine sent an input that does not say +// how it composes (compose.Input.ComposedHow), and the node runs modules folded in its own composition. +const NotJudged = "not judged: its node-engine sends no running state for " + +// foldedUnsaid is each module node runs folded in its own composition (Node.InOwn) whose manifest names a build: what +// the node composes as it runs, and an input without ComposedHow cannot. +func foldedUnsaid(in compose.Input, node string) []string { + var out []string + for _, n := range in.Nodes { + if n.Name != node { + continue + } + for m, ok := range n.InOwn { + man, known := n.Kept[m] + if !known { + man, known = in.Catalogue[m] + } + if ok && known && compose.NamesBuild(man) { + out = append(out, m) + } + } + } + sort.Strings(out) + return out +} + // pathsOf is every resource of a declaration that names a path, with its owner, group and mode as declared, sorted by // path. func pathsOf(d compose.Declared) []Path { diff --git a/internal/composer/composer_test.go b/internal/composer/composer_test.go index 281c310..991b0ba 100644 --- a/internal/composer/composer_test.go +++ b/internal/composer/composer_test.go @@ -108,6 +108,42 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { t.Errorf("the sandbox composed n1 %+v; the bus reader declared %+v", got, want) } }) + // Issue 774 (the check of the bus want, the third of a kind: the sandbox's input differs from what the bus reader + // composes): a node like the bus node, its bus module folded in its own composition at a build of the old form and no + // build published for the module's server artifact, composes in the sandbox as its node-engine composes it, from the + // input that says how (compose.Input.ComposedHow); from an older engine's input, which does not, it is named not + // judged, never failed and never passed in silence. + t.Run("a module folded in the node's own composition", func(t *testing.T) { + bus := `{"module": "bus", "version": "1", "claims": [{"name": "node-files"}], ` + + `"resources": [{"id": "server", "type": "container", "name": "bus-server", "artifact": "server"}], ` + + `"build": {"artifacts": [{"name": "server", "kind": "upstream", "from": "nats:2.11@sha256:` + strings.Repeat("a", 64) + `"}]}}` + man, err := catalogue.Decode([]byte(bus)) + if err != nil { + t.Fatal(err) + } + node := compose.Node{Name: "a", Assigned: []string{"bus"}, InOwn: map[string]bool{"bus": true}, + RunsOwn: &delivery.Composed{Node: "a"}} + in := compose.Input{Catalogue: map[string]catalogue.Manifest{"bus": man}, Nodes: []compose.Node{node}} + request := func(in compose.Input) Request { + return withInput(t, Request{V: Version, Machines: []string{"a"}, Depth: DepthCompose, + Cases: []Case{{Name: "change", Manifests: map[string]Manifest{}}}}, in) + } + older, err := Run(request(in)) + if err != nil { + t.Fatal(err) + } + if m := older.Cases[0].Machines["a"]; !m.Composes || !strings.HasPrefix(m.Reason, NotJudged+"bus") { + t.Errorf("an older engine's input of a node with a folded module: %+v", m) + } + in.ComposedHow = &compose.ComposedHow{Node: "a", Builds: map[string]string{}} + sent, err := Run(request(in)) + if err != nil { + t.Fatal(err) + } + if m := sent.Cases[0].Machines["a"]; !m.Composes || m.Reason != "" { + t.Errorf("the input that says how the node composes, its bus folded: %+v", m) + } + }) v, err := Run(Request{V: Version, Cases: []Case{{Name: "only-read", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}}) if err != nil || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted || v.Cases[0].Machines != nil || v.SDK == "" { t.Fatalf("a request with no machines: %+v, %v", v, err) diff --git a/internal/selfcompose/candidate.go b/internal/selfcompose/candidate.go index 9d8b091..4e00674 100644 --- a/internal/selfcompose/candidate.go +++ b/internal/selfcompose/candidate.go @@ -181,11 +181,13 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask delivery if err != nil { return unready(err.Error()) } - laidComps, laidHeld, laidErr := laid.composeShadow(ctx, running, touched, func(scope frombus.Scope) { + laidComps, laidHeld, laidErr := laid.composeShadow(ctx, running, touched, func(scope frombus.Scope, fps map[string]string) { if !ask.Input { return } - in, err := frombus.Input(ctx, reader{c: laid}, laid.env.Mesh, scope) + // Read as the composition reads it, at the builds it composes at, with how it composes (compose.Input.ComposedHow), + // so the sandbox composes this node through the same path (issue 774). + in, err := frombus.ComposingInput(ctx, reader{c: laid}, laid.env.Mesh, scope, fps) if err != nil { return } @@ -411,7 +413,7 @@ func (c *Composer) layCandidate(ctx context.Context, cand delivery.Candidate) (m // composeShadow composes this node from what c holds, as the shadow composes it (shadowBuilds): each module at the build // it runs, one placed and not run at the indexer's, and one the candidate laid (laid) at the candidate's. input, when // given, is handed the scope. -func (c *Composer) composeShadow(ctx context.Context, running []delivery.Composed, laid map[string]bool, input func(frombus.Scope)) (map[string]delivery.Composed, map[string]string, error) { +func (c *Composer) composeShadow(ctx context.Context, running []delivery.Composed, laid map[string]bool, input func(frombus.Scope, map[string]string)) (map[string]delivery.Composed, map[string]string, error) { node := c.env.Node c.mu.Lock() placement, placed := c.placements[node] @@ -444,7 +446,7 @@ func (c *Composer) composeShadow(ctx context.Context, running []delivery.Compose c.mu.Unlock() scope := frombus.Scope{Node: node, KeptAt: kept, InOwn: inOwn, Running: running, SameValue: c.sameValue} if input != nil { - input(scope) + input(scope, fps) } comps, _, held, err := frombus.Compose(ctx, reader{c: c}, c.env.Mesh, scope, fps) if err != nil { diff --git a/internal/selfcompose/candidate_test.go b/internal/selfcompose/candidate_test.go index 2efbfbb..ea78e81 100644 --- a/internal/selfcompose/candidate_test.go +++ b/internal/selfcompose/candidate_test.go @@ -2,6 +2,7 @@ package selfcompose import ( "context" + "encoding/json" "strings" "sync/atomic" "testing" @@ -10,6 +11,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/agent" + "github.com/novox/mesh-host/internal/compose" ) // novox/hq ADR 0593 §7: the verb compose-candidate composes a check's candidate on this node in shadow. A candidate that @@ -105,6 +107,14 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { a.Own.Module != delivery.HaveNodeWord || a.Took > delivery.ComposeAnswerBound.Milliseconds() { t.Errorf("an accepted candidate answered %+v", a) } + // Issue 774: the input it sends the sandbox says how it composed the node (the builds), so the sandbox composes it + // through the same path. + sent := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: accepted, Input: true}) + var in compose.Input + if err := json.Unmarshal(sent.Input, &in); err != nil || in.ComposedHow == nil || in.ComposedHow.Node != "n1" || + in.ComposedHow.Builds["lemurs"] != fpNew { + t.Errorf("the input sent for the sandbox does not say how the node composes: %+v, %v", in.ComposedHow, err) + } r := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: refusedKey}) if r.Verdict != delivery.ComposeCandidateRefused || len(r.Modules) != 1 || r.Modules[0].State != delivery.ModuleRefusedState || !strings.Contains(r.Modules[0].Words, "a field the judge refuses") { -- 2.54.0 From 03b7fcd7bb21e9e62e8aac8a34d8b017b08019fa Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 10:59:55 +0100 Subject: [PATCH 02/20] The sandbox fails a node whose change refuses a module the node-engine did not hold, in that module's words, instead of passing it with the rest composed (issue 774, review of the sandbox input) --- internal/compose/frombus/frombus.go | 16 +++++++++++++++- internal/composer/composer_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 164baf2..c17e0f2 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -604,7 +604,21 @@ func ComposeSent(in compose.Input, node string) (map[string]delivery.Composed, b held = map[string]string{} } out, _, _, err := composeEach(in, node, c.Builds, false, c.Running, held, nil) - return out, true, err + if err != nil { + return nil, true, err + } + // A module the sandbox held that the node-engine did not is the change refusing it: the node does not compose, said + // in that module's words, never passed with the rest composed (issue 774, review of the sandbox input). + var refused []string + for _, m := range sortedKeys(held) { + if _, was := c.Held[m]; !was { + refused = append(refused, m+": "+held[m]) + } + } + if len(refused) > 0 { + return nil, true, fmt.Errorf("on %s the change refuses %s", node, strings.Join(refused, "; ")) + } + return out, true, nil } // Plans is the provisioning plan of each module handed to the provisioner on the scope's node, composed from the bus diff --git a/internal/composer/composer_test.go b/internal/composer/composer_test.go index 991b0ba..7ae1e90 100644 --- a/internal/composer/composer_test.go +++ b/internal/composer/composer_test.go @@ -143,6 +143,30 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { if m := sent.Cases[0].Machines["a"]; !m.Composes || m.Reason != "" { t.Errorf("the input that says how the node composes, its bus folded: %+v", m) } + // A module the change refuses beside the folded one fails the node in its words, never passed with the rest + // composed (review of the sandbox input); held already where the node-engine composed, it stays held as there. + app, err := catalogue.Decode([]byte(`{"module": "app", "version": "1", ` + + `"resources": [{"id": "env", "type": "file", "path": "/var/lib/app/env", "mode": "0600", "content": "N=${secret:repository}\n"}]}`)) + if err != nil { + t.Fatal(err) + } + in.Catalogue["app"] = app + in.Nodes[0].Assigned = []string{"bus", "app"} + refused, err := Run(request(in)) + if err != nil { + t.Fatal(err) + } + if m := refused.Cases[0].Machines["a"]; m.Composes || !strings.Contains(m.Reason, "app: ") || !strings.Contains(m.Reason, "repository") { + t.Errorf("a module the change refuses on a node composed as its node-engine does: %+v", m) + } + in.ComposedHow.Held = map[string]string{"app": "held on the node"} + held, err := Run(request(in)) + if err != nil { + t.Fatal(err) + } + if m := held.Cases[0].Machines["a"]; !m.Composes { + t.Errorf("a module the node-engine holds too: %+v", m) + } }) v, err := Run(Request{V: Version, Cases: []Case{{Name: "only-read", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}}) if err != nil || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted || v.Cases[0].Machines != nil || v.SDK == "" { -- 2.54.0 From 23cbf9294a3bc1ef09a776cfb38de5980b4db270 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 11:07:16 +0100 Subject: [PATCH 03/20] =?UTF-8?q?The=20node=20agent's=20applies=20run=20th?= =?UTF-8?q?rough=20a=20runner=20its=20options=20name,=20so=20one=20integra?= =?UTF-8?q?tion=20test=20proves=20the=20bus=20module's=20first=20want=20ta?= =?UTF-8?q?kes=20the=20bus=20server=20over=20from=20the=20node's=20own=20c?= =?UTF-8?q?omposition=20without=20moving=20it,=20and=20a=20build=20that=20?= =?UTF-8?q?changes=20it=20replaces=20it=20once=20(ADR=200599=20=C2=A74,=20?= =?UTF-8?q?=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/mesh-host/main.go | 15 +- cmd/mesh-host/nodeagent.go | 2 +- cmd/mesh-host/nodeagent_integration_test.go | 221 ++++++++++++++++++++ 3 files changed, 235 insertions(+), 3 deletions(-) diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index b352d48..bff6c87 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -156,6 +156,17 @@ type options struct { file string // out is where what a command says goes. Stdout, and a buffer under test. out io.Writer + // runner is what the node agent's applies run commands through (issue 774, ADR 0594): nil is the machine's own, + // apply.ExecRunner; a test gives one that records what an apply asked of the container runtime. + runner apply.Runner +} + +// run is the runner the node agent's applies use: the options' own, else the machine's. +func (o options) run() apply.Runner { + if o.runner != nil { + return o.runner + } + return apply.ExecRunner } // parseArgs takes the subcommand first, then its flags. @@ -1637,11 +1648,11 @@ func applyModuleHeld(ctx context.Context, opts options, declared *declaration.De func(known store.State) (apply.Report, store.State, error) { if scope == nil { return apply.ApplyMindingWindows(ctx, built, declared, known, store.OriginDeclared, - apply.ExecRunner, announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)), + opts.run(), announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)), apply.WindowsIn(filepath.Dir(opts.state))) } return apply.ApplyModule(ctx, built, declared, *scope, known, store.OriginDeclared, - apply.ExecRunner, announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)), + opts.run(), announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)), apply.WindowsIn(filepath.Dir(opts.state))) }, announceOr(say)) diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 8001ee3..eb9659a 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -508,7 +508,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus // What this engine applied, kept on the machine and read at the start, so what runs is known before the node // composes its own (issue 774). Applied: appliedStoreFor(opts.state), - Inactive: inactiveIn(&liveness.Exec{Run: apply.ExecRunner, System: builtFor}, heldBy(apply.WindowsIn(filepath.Dir(opts.state)))), + Inactive: inactiveIn(&liveness.Exec{Run: liveness.Runner(opts.run()), System: builtFor}, heldBy(apply.WindowsIn(filepath.Dir(opts.state)))), Revisions: func(ctx context.Context, key string) ([]agent.Update, error) { got, err := session.KVHistory(ctx, composedBucket, key) if err != nil { diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index 0c9f677..4243082 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -6,6 +6,7 @@ import ( "context" "crypto/ed25519" "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -14,13 +15,17 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" "github.com/novox/mesh-host/internal/agent" + "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/nodegrant" "github.com/novox/mesh-host/internal/selfcompose" + "github.com/novox/mesh-host/internal/store" + "github.com/novox/mesh-host/internal/system" ) // The node agent whole, against a real bus with JetStream started in the test: the wants bucket, the node's composition @@ -619,3 +624,219 @@ func mustComposedKey(t *testing.T, module, fp string) string { } return key } + +// recordingRuntime is a container runtime that keeps its containers in memory and records every command an apply asks +// of it (issue 774): what the bus server's container met is read from it, never from a real runtime. +type recordingRuntime struct { + mu sync.Mutex + containers map[string]string // name: the spec label it was made with + asked []string +} + +func (r *recordingRuntime) run(_ context.Context, name string, args ...string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.asked = append(r.asked, name+" "+strings.Join(args, " ")) + if name != "docker" || len(args) == 0 { + return "", nil + } + switch args[0] { + case "info": + return "27.0\n", nil + case "container": + if len(args) > 1 && args[1] == "inspect" { + spec, ok := r.containers[args[len(args)-1]] + if !ok { + return "", errors.New("no such container") + } + return "true\t" + spec + "\n", nil + } + case "image": + if len(args) > 1 && args[1] == "inspect" { + return "2026-01-01T00:00:00Z\n", nil + } + case "rm": + delete(r.containers, args[len(args)-1]) + case "rename": + if spec, ok := r.containers[args[1]]; ok { + delete(r.containers, args[1]) + r.containers[args[2]] = spec + } + case "run", "create": + var made, spec string + for i, a := range args { + if a == "--name" && i+1 < len(args) { + made = args[i+1] + } + if a == "--label" && i+1 < len(args) && strings.HasPrefix(args[i+1], "mesh-host.spec=") { + spec = strings.TrimPrefix(args[i+1], "mesh-host.spec=") + } + } + r.containers[made] = spec + return "made\n", nil + } + return "", nil +} + +// moved is each command that removed, made, stopped, renamed or restarted the container named. +func (r *recordingRuntime) moved(container string) []string { + r.mu.Lock() + defer r.mu.Unlock() + var out []string + for _, a := range r.asked { + for _, verb := range []string{"rm ", "run ", "create ", "stop ", "restart ", "kill ", "rename ", "start "} { + for _, prefix := range []string{"docker ", "docker container "} { + if strings.HasPrefix(a, prefix+verb) && strings.Contains(a, container) { + out = append(out, a) + } + } + } + } + return out +} + +// ADR 0599 §6 and §4 (issue 774): the bus server the node's own composition made while the bus module was folded is taken +// over by the bus module's first want, the node agent started whole against a real bus: it composes the bus module, writes +// its changed file and records the server as the bus module's, and asks the container runtime to remove, make, stop, +// rename or restart nothing, since the build does not change the server. A later want whose build changes the server +// replaces it once, as any module's, once the want carries the yes (§2: the assigner gives it from the release's Approve). +func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *testing.T) { + b := newItBed(t) + runtime := &recordingRuntime{containers: map[string]string{}} + b.opts.runner = runtime.run + const server = "mesh-broker-it" + conf := filepath.Join(b.dir, "nats.conf") + image := "registry.example/nats@sha256:" + strings.Repeat("a", 64) + + // What the node's own composition made of the folded bus: the server and its file, recorded as the node's own. + built, err := system.For(builtFor) + if err != nil { + t.Fatal(err) + } + folded, err := declaration.Parse([]byte(`{"declaration":1,"resources":[` + + `{"id":"nats.server-conf","type":"file","path":"` + conf + `","mode":"0644","content":"listen: old\n"},` + + `{"id":"nats.server","type":"container","name":"` + server + `","image":"` + image + `"}]}`)) + if err != nil { + t.Fatal(err) + } + _, known, err := apply.ApplyModule(context.Background(), built, folded, apply.Scope{Module: store.NodeModule}, store.State{}, + store.OriginDeclared, runtime.run, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if err := store.Save(b.opts.state, known); err != nil { + t.Fatal(err) + } + if moved := runtime.moved(server); len(moved) != 1 { + t.Fatalf("the folded server was not made once: %v", moved) + } + made := runtime.containers[server] + runtime.mu.Lock() + runtime.asked = nil + runtime.mu.Unlock() + + kvOf := func(bucket string) nats.KeyValue { + kv, err := b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: bucket, History: 5}) + if err != nil { + t.Fatal(err) + } + return kv + } + placement, modules, manifests := kvOf(delivery.PlacementBucket), kvOf(delivery.ModulesBucket), kvOf(delivery.ManifestsBucket) + kvOf(selfcompose.ResolvedBucket) + builds := kvOf(delivery.BuilderBuildsBucket) + kvOf(delivery.PlacementCandidatesBucket) + kvOf(link.PackagesBucket) + // The bus module at a build, done: its manifest written and the indexer's module naming it at fp. + publish := func(fp, env string) { + man := `{"module":"nats","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],"resources":[` + + `{"id":"server-conf","type":"file","path":"` + conf + `","mode":"0644","content":"listen: new\n"},` + + `{"id":"server","type":"container","name":"` + server + `","image":"` + image + `"` + env + `}]}` + digest := delivery.ManifestDigest([]byte(man)) + mraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "nats", JSON: man}) + mkey, _ := delivery.ManifestsKey(digest) + modv, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/mesh-catalog", Commit: strings.Repeat("3", 40)}) + if _, err := manifests.Put(mkey, mraw); err != nil { + t.Fatal(err) + } + bkey, _ := delivery.BuilderBuildKey("nats", fp) + done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest}) + if _, err := builds.Put(bkey, done); err != nil { + t.Fatal(err) + } + if _, err := modules.Put("nats", modv); err != nil { + t.Fatal(err) + } + } + rec, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: itNode, + Capabilities: []string{"service-manager", "package-manager", "container-runtime"}}}) + hkey, _ := delivery.HaveNodeKey(itNode) + places, _ := delivery.PlacementValues(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "nats"}}}) + // The have the engine wrote of the folded bus: the old-form build, no fingerprint. + natsHave, _ := delivery.HaveKey(itNode, "nats") + oldHave, _ := delivery.EncodeHave(delivery.Have{Build: "build-1792674595610176744", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + for key, value := range map[string][]byte{hkey: rec, natsHave: oldHave} { + if _, err := b.haves.Put(key, value); err != nil { + t.Fatal(err) + } + } + busWant := func(w delivery.Want) { + raw, err := delivery.EncodeWant(w) + if err != nil { + t.Fatal(err) + } + key, _ := delivery.WantKey(itNode, "nats") + if _, err := b.wants.Put(key, raw); err != nil { + t.Fatal(err) + } + } + first := fpIt("e") + publish(first, "") + busWant(delivery.Want{FP: first, Self: true}) + stop := b.start() + defer stop() + for key, value := range places { + if _, err := placement.Put(key, value); err != nil { + t.Fatal(err) + } + } + + within(t, 10*time.Second, "the bus module's first want was not applied", func() bool { + h := b.have("nats") + got, _ := os.ReadFile(conf) + return h.FP == first && h.Phase == delivery.PhaseRunning && string(got) == "listen: new\n" + }) + if moved := runtime.moved(server); len(moved) != 0 || runtime.containers[server] != made { + t.Fatalf("the first want moved the bus server its build does not change: %v", moved) + } + state, err := store.Load(b.opts.state) + if err != nil { + t.Fatal(err) + } + if rec, ok := state.Find("nats.server"); !ok || rec.Module != "nats" { + t.Errorf("the server is recorded %+v, not as the bus module's", rec) + } + + // A build that changes the server: replaced once, as any module's, with the yes the assigner writes from the release's + // Approve for a move the node judged to recreate it (ADR 0599 §2, §4). + next := fpIt("f") + publish(next, `,"env":{"CHANGED":"1"}`) + busWant(delivery.Want{FP: next, Self: true}) + within(t, 10*time.Second, "the build that changes the server was not applied", func() bool { + h := b.have("nats") + if h.Phase == delivery.PhaseWaitingYes && h.Waits != "" { + busWant(delivery.Want{FP: next, Self: true, Yes: h.Waits}) + } + return h.FP == next && h.Phase == delivery.PhaseRunning + }) + var runs int + for _, a := range runtime.moved(server) { + if strings.HasPrefix(a, "docker run ") && strings.Contains(a, " --name "+server+" ") { + runs++ + } + } + if runs != 1 || runtime.containers[server] == made { + t.Errorf("the build that changes the server made it %d times, its spec still the folded one %v: %v", runs, + runtime.containers[server] == made, runtime.moved(server)) + } +} -- 2.54.0 From 649ed21d55c7966bcf2e8b15bbab52a6f586200c Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 11:29:26 +0100 Subject: [PATCH 04/20] The sandbox refuses no module the node holds composing without the change, a module blamed for a refusal among them (issue 774, review round 1) --- internal/compose/frombus/frombus.go | 8 +++++--- internal/compose/input.go | 3 +++ internal/composer/composer_test.go | 11 +++++++++++ internal/selfcompose/candidate.go | 3 +++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index c17e0f2..3478df6 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -607,11 +607,13 @@ func ComposeSent(in compose.Input, node string) (map[string]delivery.Composed, b if err != nil { return nil, true, err } - // A module the sandbox held that the node-engine did not is the change refusing it: the node does not compose, said - // in that module's words, never passed with the rest composed (issue 774, review of the sandbox input). + // A module the sandbox held that the node-engine held neither in the read nor composing without the change is the change + // refusing it: the node does not compose, said in that module's words, never passed with the rest composed (issue 774, + // review of the sandbox input). var refused []string for _, m := range sortedKeys(held) { - if _, was := c.Held[m]; !was { + _, was := c.Held[m] + if _, before := c.HeldBefore[m]; !was && !before { refused = append(refused, m+": "+held[m]) } } diff --git a/internal/compose/input.go b/internal/compose/input.go index 4471e5e..d8eae82 100644 --- a/internal/compose/input.go +++ b/internal/compose/input.go @@ -249,6 +249,9 @@ type ComposedHow struct { Held map[string]string // Running is what runs of each held module, its own composition, by which it is carried as it runs. Running []delivery.Composed + // HeldBefore is each module the node holds composing without the change (the candidate's base), held by the read or + // blamed later for a refusal: the change refuses none of them (issue 774, review round 1). + HeldBefore map[string]string } // Place is where a node is, as its placement says (inv.Overlay). diff --git a/internal/composer/composer_test.go b/internal/composer/composer_test.go index 7ae1e90..3e669aa 100644 --- a/internal/composer/composer_test.go +++ b/internal/composer/composer_test.go @@ -159,6 +159,17 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { if m := refused.Cases[0].Machines["a"]; m.Composes || !strings.Contains(m.Reason, "app: ") || !strings.Contains(m.Reason, "repository") { t.Errorf("a module the change refuses on a node composed as its node-engine does: %+v", m) } + // Held composing without the change, blamed for a refusal beyond the read (issue 1960's unset names): no refusal of + // the change either (review round 1). + in.ComposedHow.HeldBefore = map[string]string{"app": "its route names are unset"} + before, err := Run(request(in)) + if err != nil { + t.Fatal(err) + } + if m := before.Cases[0].Machines["a"]; !m.Composes { + t.Errorf("a module the node holds composing without the change: %+v", m) + } + in.ComposedHow.HeldBefore = nil in.ComposedHow.Held = map[string]string{"app": "held on the node"} held, err := Run(request(in)) if err != nil { diff --git a/internal/selfcompose/candidate.go b/internal/selfcompose/candidate.go index 4e00674..1176808 100644 --- a/internal/selfcompose/candidate.go +++ b/internal/selfcompose/candidate.go @@ -191,6 +191,9 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask delivery if err != nil { return } + // What the node holds composing without the candidate, a module blamed for a refusal among them, is no refusal of + // the change (review round 1). + in.ComposedHow.HeldBefore = maps.Clone(baseHeld) // As the sandbox reads it (composer.SentInput, the one shape): this node's own modules as taken in from their // builds, every other module's manifest left to the cases. if body, err := json.Marshal(composer.SentInput(in, laid.env.Node)); err == nil { -- 2.54.0 From 12c80d7c2ef793040933e669cffda8629106b801 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 11:38:03 +0100 Subject: [PATCH 05/20] The node-engine carries a module's own secrets sealed by the vault when its manifest says they are the core's credentials, pinned at mesh-sdk 4f141ff (issue 774, the operator 2026-11-03) --- go.mod | 2 +- go.sum | 2 ++ internal/compose/composed_test.go | 2 +- internal/compose/credentials.go | 4 ++-- internal/compose/credentials_test.go | 2 +- internal/compose/node_test.go | 2 +- .../compose/resolution/core_credentials.go | 8 +++---- .../resolution/core_credentials_test.go | 22 +++++++++---------- 8 files changed, 23 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 7ec0e0a..5c8d904 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host 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.20261103103643-4f141ff08892 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index 43cc4ec..0fd448a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ 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.20261103103643-4f141ff08892 h1:XWorhIHDVECRCYzGbkaexEpJ7v03B9LrZ8IVy7vkUKs= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103103643-4f141ff08892/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/internal/compose/composed_test.go b/internal/compose/composed_test.go index 35091c1..09e36a7 100644 --- a/internal/compose/composed_test.go +++ b/internal/compose/composed_test.go @@ -188,7 +188,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { // 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words. func TestTheComposedPoliciesAreTheCatalogues(t *testing.T) { for _, p := range []string{catalogue.PolicyRoll, catalogue.PolicyTogether, catalogue.PolicyRecord} { - for _, from := range []string{catalogue.FromPerson, catalogue.FromModule, catalogue.FromBus, catalogue.FromData, catalogue.FromDefault} { + for _, from := range []string{catalogue.FromPerson, catalogue.FromModule, "the bus", catalogue.FromData, catalogue.FromDefault} { // "the bus": an older reader's word, still read v := delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("a", 64), Judgement: delivery.JudgedNeither, Policy: p, PolicyFrom: from} if _, err := delivery.EncodeComposedDigest(v); err != nil { t.Errorf("%s from %s: %v", p, from, err) diff --git a/internal/compose/credentials.go b/internal/compose/credentials.go index 374302d..e05339c 100644 --- a/internal/compose/credentials.go +++ b/internal/compose/credentials.go @@ -386,13 +386,13 @@ type CoreBoxWanted struct { Name string } -// coreModulesOf is every module of the plan on the core's list whose own secrets this machine's declaration carries, with their +// coreModulesOf is every module of the plan whose manifest says its own secrets are the core's credentials, whose own secrets this machine's declaration carries, with their // plain own secret names, sorted: one handed to the provisioner has its secrets from the vault through the client, and one issued // outside the mesh is never the vault's to seal here. func coreModulesOf(modules []catalogue.Manifest, handed func(catalogue.Manifest) bool) map[string][]string { out := map[string][]string{} for _, m := range modules { - if !resolution.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) { + if !resolution.CarriesVaultSealed(m) || (handed != nil && handed(m)) { continue } var names []string diff --git a/internal/compose/credentials_test.go b/internal/compose/credentials_test.go index dde374e..0ab188b 100644 --- a/internal/compose/credentials_test.go +++ b/internal/compose/credentials_test.go @@ -295,7 +295,7 @@ func aValidKey(t *testing.T, name string) string { return keyOf(t, name) } func TestACoreBoxTheInputHoldsNoneOfIsReturnedToKeepAndNothingIsWritten(t *testing.T) { mk := func() Input { in := aMesh(t) - in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", + in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", CoreCredentials: true, OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}} laptop := nodeOf(t, &in, "laptop") laptop.Assigned = append(laptop.Assigned, "nats") diff --git a/internal/compose/node_test.go b/internal/compose/node_test.go index 5dcbee4..1d2c67b 100644 --- a/internal/compose/node_test.go +++ b/internal/compose/node_test.go @@ -168,7 +168,7 @@ func TestWhatASendWouldMakeIsNamedAsForeseenAndNeverInvented(t *testing.T) { in.Issued[0].ForProvider = "" in.Catalogue["assistant"] = anAssistant() in.Catalogue["web"] = aWebModule() - in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", + in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", CoreCredentials: true, OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}} anchor := nodeOf(t, &in, "anchor") anchor.Assigned = append(anchor.Assigned, "assistant", "web", "nats") diff --git a/internal/compose/resolution/core_credentials.go b/internal/compose/resolution/core_credentials.go index 6b667f3..a584b7d 100644 --- a/internal/compose/resolution/core_credentials.go +++ b/internal/compose/resolution/core_credentials.go @@ -2,14 +2,14 @@ package resolution import ( "encoding/json" - "slices" mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) -// CarriesVaultSealed says whether module's own secrets are the core's credentials. -func CarriesVaultSealed(module string) bool { - return slices.Contains(mcat.CoreCredentialModules, module) +// CarriesVaultSealed says whether a module's own secrets are the core's credentials, as its manifest says +// (core-credentials): no list of modules is kept here. +func CarriesVaultSealed(m mcat.Manifest) bool { + return mcat.CarriesCoreCredentials(m) } // vaultSealedOf is the vault's box for one of module's own secrets, if the rendering was given one. diff --git a/internal/compose/resolution/core_credentials_test.go b/internal/compose/resolution/core_credentials_test.go index 6c7ffd2..4a3692c 100644 --- a/internal/compose/resolution/core_credentials_test.go +++ b/internal/compose/resolution/core_credentials_test.go @@ -1,24 +1,24 @@ package resolution import ( - "reflect" "testing" mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) +// The core's own credentials are what a module's manifest says (core-credentials, issue 774): no module is on a list. func TestTheCoresOwnCredentialsAreTheBusTheProvisionerAndItsClient(t *testing.T) { - if want := []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}; !reflect.DeepEqual(mcat.CoreCredentialModules, want) { - t.Fatalf("the core's modules are %v, want %v (mesh-vault's CoreModules is the same list)", mcat.CoreCredentialModules, want) - } - for _, m := range []string{"nats", "mesh-provisioner", "mesh-provisioner-client"} { - if !CarriesVaultSealed(m) { - t.Errorf("%s is not on the list", m) + for raw, core := range map[string]bool{ + `{"module":"nats","version":"1","core-credentials":true}`: true, + `{"module":"nats","version":"1"}`: false, + `{"module":"postgres","version":"1"}`: false, + } { + m, err := mcat.Decode([]byte(raw)) + if err != nil { + t.Fatal(err) } - } - for _, m := range []string{"postgres", "mesh-vault", "mesh-controller", ""} { - if CarriesVaultSealed(m) { - t.Errorf("%s is on the list", m) + if CarriesVaultSealed(m) != core { + t.Errorf("%s: carries the core's credentials %v", raw, !core) } } } -- 2.54.0 From db7cd273f0304f1f633e9f6804d6f44387f62983 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 11:55:26 +0100 Subject: [PATCH 06/20] The node-engine finds the tool runner by what its manifest says (tool-runner), never by its name, pinned at mesh-sdk a973bfa (issue 774, the operator 2026-11-03) --- go.mod | 2 +- go.sum | 2 + internal/compose/composed.go | 4 +- internal/compose/composed_test.go | 12 +-- internal/compose/frombus/answer.go | 9 +- internal/compose/frombus/held_rows_test.go | 17 ++-- internal/compose/resolution/declaration.go | 4 +- .../resolution/foundation_manifests_test.go | 6 +- internal/compose/resolution/proofs_test.go | 4 +- internal/compose/resolution/reads_test.go | 10 +-- internal/compose/resolution/runtime.go | 36 ++++---- .../compose/resolution/runtime_health_test.go | 10 +-- internal/compose/resolution/runtime_test.go | 84 +++++++++++-------- 13 files changed, 105 insertions(+), 95 deletions(-) diff --git a/go.mod b/go.mod index 5c8d904..ece9da5 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103103643-4f141ff08892 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103105230-a973bfaf42b2 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index 0fd448a..a8a3c3c 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAu 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.20261103103643-4f141ff08892 h1:XWorhIHDVECRCYzGbkaexEpJ7v03B9LrZ8IVy7vkUKs= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103103643-4f141ff08892/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103105230-a973bfaf42b2 h1:CMNEBKlWI36rQEG5lhFRKoICquvYP4hBD7h/jCVFIcc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103105230-a973bfaf42b2/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/internal/compose/composed.go b/internal/compose/composed.go index fffd0b8..03be678 100644 --- a/internal/compose/composed.go +++ b/internal/compose/composed.go @@ -316,7 +316,6 @@ func RunsAnyOf(runs *delivery.Composed, module string) bool { // nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build // (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process. func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle { - runtimeID := catalogue.RuntimeModule + "." + resolution.RuntimeProcessID() type resource struct { ID string `json:"id"` Module string `json:"module"` @@ -339,7 +338,8 @@ func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle { owner, _ = declaration.ModuleGuess(r.ID) } switch { - case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule: + case r.Type == "process" && owner != "" && r.ID == owner+"."+resolution.RuntimeProcessID(): + // The tool runner's process, by its id alone: `.runtime` (resolutionRuntimeProcess). env = r.Env case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+resolution.BundleID("")): name := strings.TrimPrefix(r.ID, owner+"."+resolution.BundleID("")) diff --git a/internal/compose/composed_test.go b/internal/compose/composed_test.go index 09e36a7..59cb564 100644 --- a/internal/compose/composed_test.go +++ b/internal/compose/composed_test.go @@ -104,7 +104,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { // indexer's commit has no bundles (shanks's runtime lost the folded systemd-resolved, novox's the folded nats), and a // held module's new build's bundles are not delivered. dg := func(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) } - runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1", + runtime := catalogue.Manifest{Module: "node-tools", Version: "1", ToolRunner: true, OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/node-tools/broker"}}, Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'), Digest: dg('a'), Language: "go", Binary: "node-tools"}}} @@ -125,7 +125,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { if dropped { env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29) } - return delivery.Composed{Node: "anchor", Module: catalogue.RuntimeModule, Resources: []json.RawMessage{json.RawMessage( + return delivery.Composed{Node: "anchor", Module: "node-tools", Resources: []json.RawMessage{json.RawMessage( `{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}} } lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') + @@ -155,13 +155,13 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { }, map[string]string{"lamp": fpFor('1')}, false}, } { tools := aMesh(t) - tools.Catalogue[catalogue.RuntimeModule], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp + tools.Catalogue["node-tools"], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp anchor := nodeOf(t, &tools, "anchor") - anchor.Assigned = append(anchor.Assigned, "artifacts", catalogue.RuntimeModule, "lamp") - tools.BusMinted = []string{"anchor." + catalogue.RuntimeModule} + anchor.Assigned = append(anchor.Assigned, "artifacts", "node-tools", "lamp") + tools.BusMinted = []string{"anchor." + "node-tools"} anchor.SealingKey = keyOf(t, "anchor") anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)}) - row.fps[overlay.Name], row.fps["artifacts"], row.fps[catalogue.RuntimeModule] = fpFor('a'), fpFor('5'), fpFor('e') + row.fps[overlay.Name], row.fps["artifacts"], row.fps["node-tools"] = fpFor('a'), fpFor('5'), fpFor('e') out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing) if err != nil { t.Fatalf("%s: %v", row.name, err) diff --git a/internal/compose/frombus/answer.go b/internal/compose/frombus/answer.go index d2b0162..79be25e 100644 --- a/internal/compose/frombus/answer.go +++ b/internal/compose/frombus/answer.go @@ -404,11 +404,11 @@ func firstReason(err error, failed map[string]string) error { return fmt.Errorf("%s; its stand-in is then refused: %w", strings.Join(named, "; "), err) } -// blames says the composition with m's stub (again) makes m the culprit of err: it composes, or fails otherwise. The +// blames says the composition with m's stub (again) makes m the culprit of err, m the tool runner (runner) or not: it composes, or fails otherwise. The // runtime's stub is never a runtime (no build is taken in, so it carries no bundle) and always fails otherwise: it is // the culprit only where its stub composes (issue 774, 2026-10-30 19:30: node-tools held for any error naming it). -func blames(m string, again, err error) bool { - if m == catalogue.RuntimeModule { +func blames(runner bool, again, err error) bool { + if runner { // Or where its stub fails only for the modules of their own account it would run (issue 774): they are held // after it, each for its own words, and the node composes the rest. var none *resolution.NoRuntimeToRunError @@ -470,7 +470,8 @@ func culprit(in compose.Input, node string, fps map[string]string, mode compose. } changes := func(m, leave string) bool { _, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode) - return blames(m, again, err) + man, known := manifestOn(in, node, m) + return blames(known && catalogue.IsToolRunner(man), again, err) } for _, m := range order { if _, done := failed[m]; done { diff --git a/internal/compose/frombus/held_rows_test.go b/internal/compose/frombus/held_rows_test.go index bc8fae7..ef66cee 100644 --- a/internal/compose/frombus/held_rows_test.go +++ b/internal/compose/frombus/held_rows_test.go @@ -8,7 +8,6 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/resolution" @@ -100,12 +99,12 @@ func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) { {"lamp", nil, true}, {"lamp", errors.New("other"), true}, {"lamp", errors.New("lamp refuses"), false}, - {catalogue.RuntimeModule, nil, true}, - {catalogue.RuntimeModule, errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false}, - {catalogue.RuntimeModule, fmt.Errorf("compose: %w", &resolution.NoRuntimeToRunError{Held: true}), true}, - {catalogue.RuntimeModule, &resolution.NoRuntimeToRunError{}, false}, + {"node-tools", nil, true}, + {"node-tools", errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false}, + {"node-tools", fmt.Errorf("compose: %w", &resolution.NoRuntimeToRunError{Held: true}), true}, + {"node-tools", &resolution.NoRuntimeToRunError{}, false}, } { - if got := blames(row.module, row.again, err); got != row.blamed { + if got := blames(row.module == "node-tools", row.again, err); got != row.blamed { t.Errorf("%s with %v: blamed %v, want %v", row.module, row.again, got, row.blamed) } } @@ -117,13 +116,13 @@ func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) { // another build than the trunk's is composed from the build its want names. func aRunningModuleWaitsForItsBuild(t *testing.T) { dg := func(c string) string { return "sha256:" + strings.Repeat(c, 64) } - tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` + + tools := `{"module":"node-tools","version":"1","tool-runner":true,"own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` + `"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}` lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}` store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` + `"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}` old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64) - running := []delivery.Composed{{Node: "n1", Module: catalogue.RuntimeModule, FP: old, Resources: []json.RawMessage{ + running := []delivery.Composed{{Node: "n1", Module: "node-tools", FP: old, Resources: []json.RawMessage{ json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`), json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` + dg("1") + `","digest":"` + dg("1") + `","path":"` + resolution.BundlePath("node-tools", "runtime") + `"}`)}}} @@ -207,7 +206,7 @@ func aHeldModulesFirstReasonLeads(t *testing.T) { // secrets: the node composes every other module, and the held one keeps the words it was first held for. func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) { dg := "sha256:" + strings.Repeat("1", 64) - tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` + + tools := `{"module":"node-tools","version":"1","tool-runner":true,"own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` + `"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}` own := func(name string) string { return `{"module":"` + name + `","version":"1","runs-as":"` + name + `","secrets-owner":"` + name + `","tools":["status"],` + diff --git a/internal/compose/resolution/declaration.go b/internal/compose/resolution/declaration.go index 3a774e3..3807e06 100644 --- a/internal/compose/resolution/declaration.go +++ b/internal/compose/resolution/declaration.go @@ -1046,7 +1046,7 @@ func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string] return nil, err } if process != nil { // none for a held runtime's stand-in: what runs of it is carried - owner[fmt.Sprint(process["id"])] = mcat.RuntimeModule + owner[fmt.Sprint(process["id"])] = fmt.Sprint(process["name"]) // the tool runner's module (resolutionRuntimeProcess) out = append(out, process) } // And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8). @@ -1308,7 +1308,7 @@ func resolutionOwnSecretOwner(r mcat.Resolution, m mcat.Manifest, name string) s if o := m.OwnSecrets[name].Owner; o != "" { return o } - if r.Account != "" && (m.Module == mcat.RuntimeModule || ToolsBusSecret(m, name)) { + if r.Account != "" && (mcat.IsToolRunner(m) || ToolsBusSecret(m, name)) { return r.Account } return m.SecretsOwner diff --git a/internal/compose/resolution/foundation_manifests_test.go b/internal/compose/resolution/foundation_manifests_test.go index 515f171..f495d15 100644 --- a/internal/compose/resolution/foundation_manifests_test.go +++ b/internal/compose/resolution/foundation_manifests_test.go @@ -113,7 +113,7 @@ func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) { // The number this node was given for the forge — the one the machine it is about to run on // already publishes. out, err := Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-broker"}}, + Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-broker"}}, Given: map[string]map[int]int{"gitea": {3000: 2999}}, }) if err != nil { @@ -129,7 +129,7 @@ func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) { t.Fatalf("the forge is not published on the port this node gave it: %v", server["ports"]) } // The forge's own code runs in the node's runtime (novox/hq ADR 0198), given its words there. - runtime := fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID()) + runtime := fileNamed(out, "node-tools"+"."+RuntimeProcessID()) if runtime == nil { t.Fatalf("the node's runtime is not in the declaration: %v", ids(out)) } @@ -168,7 +168,7 @@ func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any { givenPorts[k] = v } out, err := Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-broker"}}, + Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-broker"}}, Ports: map[string]map[int]int{"gitea": givenPorts}, Given: map[string]map[int]int{"gitea": given}, }) diff --git a/internal/compose/resolution/proofs_test.go b/internal/compose/resolution/proofs_test.go index cd1396b..892f382 100644 --- a/internal/compose/resolution/proofs_test.go +++ b/internal/compose/resolution/proofs_test.go @@ -46,9 +46,9 @@ func twoMergeableFiles(app, sidecar []any) mcat.Manifest { // The indexer's judge's proofs, items 1 to 13 of its list. func TestTheJudgesProofsOfAComposition(t *testing.T) { runtimeWith := mcat.Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}, "dash": {"token": "sealed-token"}}} + Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}, "dash": {"token": "sealed-token"}}} processOf := func(out []map[string]any) map[string]any { - return fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID()) + return fileNamed(out, "node-tools"+"."+RuntimeProcessID()) } t.Run("1 a bundle's words resolve per machine into the runtime's tool env", func(t *testing.T) { diff --git a/internal/compose/resolution/reads_test.go b/internal/compose/resolution/reads_test.go index 91a6ade..ced1bd1 100644 --- a/internal/compose/resolution/reads_test.go +++ b/internal/compose/resolution/reads_test.go @@ -13,13 +13,13 @@ import ( // place the operator-sealed secrets are kept — and no other module is told. func TestTheHolderOfNodeReadsIsToldWhereItsNodeKeepsASecret(t *testing.T) { with := mcat.Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}, "reads": {"broker": "own"}, "desk": {"broker": "own"}}} - goRuntime := mcat.Manifest{Module: mcat.RuntimeModule, Version: "1", - OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + mcat.RuntimeModule + "/broker"}}, + Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}, "reads": {"broker": "own"}, "desk": {"broker": "own"}}} + goRuntime := mcat.Manifest{Module: "node-tools", Version: "1", ToolRunner: true, + OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + "node-tools" + "/broker"}}, Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "runtime", Kind: mcat.ArtifactBundle, Language: "go", System: "arch", From: "cmd/node-tools"}}}} goRuntime, err := goRuntime.Resolve([]mcat.Built{{Name: "runtime", Kind: mcat.ArtifactBundle, - Reference: mcat.ArtifactStoreScheme + mcat.RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + Reference: mcat.ArtifactStoreScheme + "node-tools" + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) if err != nil { t.Fatal(err) } @@ -59,7 +59,7 @@ func TestTheHolderOfNodeReadsIsToldWhereItsNodeKeepsASecret(t *testing.T) { t.Fatalf("the holder is not told its node's secret places: %v (%v)", err, given) } want := []string{"/etc/app/db.secret", "/home/ops/.config/app", "/var/lib/desk", "/var/lib/mesh-reads", - "/var/lib/mesh-vault", "/var/lib/mesh/" + mcat.RuntimeModule} + "/var/lib/mesh-vault", "/var/lib/mesh/" + "node-tools"} if strings.Join(places, " ") != strings.Join(want, " ") { t.Errorf("secret places:\n got %v\nwant %v", places, want) } diff --git a/internal/compose/resolution/runtime.go b/internal/compose/resolution/runtime.go index 9eedf9c..ad85423 100644 --- a/internal/compose/resolution/runtime.go +++ b/internal/compose/resolution/runtime.go @@ -21,12 +21,8 @@ func BundlePath(module, bundle string) string { return mcat.BundleRoot + "/" + m // a node is sent exactly what it was sent before, bundles included, because a bundle nothing loads // is bytes nobody reads. func resolutionRuntimeHere(r mcat.Resolution) bool { - for _, m := range r.Modules { - if m.Module == mcat.RuntimeModule { - return true - } - } - return false + _, here := mcat.ToolRunnerOf(r.Modules) + return here } // bundleArchives is one archive per tools bundle of a module — a bundle the runtime LOADS something @@ -59,13 +55,13 @@ func RuntimeProcessID() string { return "runtime" } // interpreterFor is how a bundle in a language is run: the program the host's unit starts, with the // bundle's entrypoint after it. The one thing the composer takes from a language, and said here // rather than in a manifest because the runtime's process is the mesh's to compose (to-be 38 WP3). -func interpreterFor(language string) (string, error) { +func interpreterFor(module, language string) (string, error) { switch language { case "typescript": return "node", nil } return "", fmt.Errorf( - "%s is written in %q, and the mesh knows no interpreter to run a %q bundle with", mcat.RuntimeModule, language, language) + "%s is written in %q, and the mesh knows no interpreter to run a %q bundle with", module, language, language) } // runtimeProcess is the one process a machine runs the node's tool runtime as (novox/hq ADR 0175, @@ -80,7 +76,7 @@ func interpreterFor(language string) (string, error) { func resolutionRuntimeProcess(r mcat.Resolution, with mcat.Rendering) (map[string]any, error) { var runtime *mcat.Manifest for i := range r.Modules { - if r.Modules[i].Module == mcat.RuntimeModule { + if mcat.IsToolRunner(r.Modules[i]) { runtime = &r.Modules[i] } } @@ -93,7 +89,7 @@ func resolutionRuntimeProcess(r mcat.Resolution, with mcat.Rendering) (map[strin if len(runtime.Bundles) != 1 { return nil, fmt.Errorf( "%s is assigned to %s and its build produced %d bundle(s); the runtime is one bundle "+ - "the mesh runs, so the module declares exactly one (novox/hq to-be 38)", mcat.RuntimeModule, r.Node, len(runtime.Bundles)) + "the mesh runs, so the module declares exactly one (novox/hq to-be 38)", runtime.Module, r.Node, len(runtime.Bundles)) } bundle := runtime.Bundles[0] // What runs it (novox/hq ADR 0193): a runtime compiled to a binary runs itself, from its own @@ -105,9 +101,9 @@ func resolutionRuntimeProcess(r mcat.Resolution, with mcat.Rendering) (map[strin if len(bundle.Entrypoints) != 1 { return nil, fmt.Errorf( "%s's bundle %q names %d entrypoint(s); the runtime is run from one, so the module "+ - "declares exactly one (novox/hq to-be 38)", mcat.RuntimeModule, bundle.Name, len(bundle.Entrypoints)) + "declares exactly one (novox/hq to-be 38)", runtime.Module, bundle.Name, len(bundle.Entrypoints)) } - interpreter, err := interpreterFor(bundle.Language) + interpreter, err := interpreterFor(runtime.Module, bundle.Language) if err != nil { return nil, err } @@ -117,7 +113,7 @@ func resolutionRuntimeProcess(r mcat.Resolution, with mcat.Rendering) (map[strin if !declared { return nil, fmt.Errorf( "%s declares no own secret named broker, and the node's credential is delivered there: "+ - "a module that speaks on the bus declares \"own-secrets\": {\"broker\": }", mcat.RuntimeModule, + "a module that speaks on the bus declares \"own-secrets\": {\"broker\": }", runtime.Module, ) } @@ -163,7 +159,7 @@ func resolutionRuntimeProcess(r mcat.Resolution, with mcat.Rendering) (map[strin } } sort.Strings(served) - restartOn = append(restartOn, mcat.RuntimeModule+"."+mcat.NeedID("broker")) + restartOn = append(restartOn, runtime.Module+"."+mcat.NeedID("broker")) sort.Strings(restartOn) env := map[string]string{mcat.RuntimeToolModules: strings.Join(served, ","), mcat.RuntimeBrokerFile: credential.Path} @@ -176,7 +172,7 @@ func resolutionRuntimeProcess(r mcat.Resolution, with mcat.Rendering) (map[strin env[mcat.RuntimeToolEnv] = string(body) } process := map[string]any{ - "id": mcat.RuntimeModule + "." + RuntimeProcessID(), "type": "process", "name": mcat.RuntimeModule, + "id": runtime.Module + "." + RuntimeProcessID(), "type": "process", "name": runtime.Module, "source": bundle.Source, "digest": bundle.Digest, "run": run, "env": env, @@ -193,7 +189,7 @@ func resolutionRuntimeProcess(r mcat.Resolution, with mcat.Rendering) (map[strin } // Routed through the artifact store as this network reaches it now, like everything the mesh // built; refused with the same words when there is no store to route through. - if err := artifactsInto(process, mcat.RuntimeModule, with); err != nil { + if err := artifactsInto(process, runtime.Module, with); err != nil { return nil, err } return process, nil @@ -229,14 +225,14 @@ func resolutionOwnRuntimes(r mcat.Resolution, with mcat.Rendering) ([]map[string } var runtime *mcat.Manifest for i := range r.Modules { - if r.Modules[i].Module == mcat.RuntimeModule { + if mcat.IsToolRunner(r.Modules[i]) { runtime = &r.Modules[i] } } if runtime == nil || len(runtime.Bundles) != 1 || runtime.Bundles[0].Binary == "" { return nil, &NoRuntimeToRunError{Held: runtime != nil && runtime.Stubbed, said: fmt.Sprintf("%s runs as its own "+ - "account in a runtime of its own, and %s is not here to run it from: assign %s to %s first (novox/hq ADR 0259)", - own[0].Module, mcat.RuntimeModule, mcat.RuntimeModule, r.Node)} + "account in a runtime of its own, and the tool runner is not here to run it from: assign the tool runner to %s first "+ + "(novox/hq ADR 0259)", own[0].Module, r.Node)} } program := runtime.Bundles[0] var out []map[string]any @@ -314,7 +310,7 @@ func resolutionOwnRuntimes(r mcat.Resolution, with mcat.Rendering) ([]map[string if yielding != nil { process["yields"] = yielding } - if err := artifactsInto(process, mcat.RuntimeModule, with); err != nil { + if err := artifactsInto(process, runtime.Module, with); err != nil { return nil, err } out = append(out, process) diff --git a/internal/compose/resolution/runtime_health_test.go b/internal/compose/resolution/runtime_health_test.go index 65043cf..b4d3377 100644 --- a/internal/compose/resolution/runtime_health_test.go +++ b/internal/compose/resolution/runtime_health_test.go @@ -9,12 +9,12 @@ import ( // The runtime-health is composed onto .own-runtime, in the words the node-engine takes; to an engine // that does not read health it is not sent, as for every resource. func TestARuntimeHealthIsComposedOntoTheModulesOwnRuntime(t *testing.T) { - goRuntime := mcat.Manifest{Module: mcat.RuntimeModule, Version: "1", - OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + mcat.RuntimeModule + "/broker"}}, + goRuntime := mcat.Manifest{Module: "node-tools", Version: "1", ToolRunner: true, + OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + "node-tools" + "/broker"}}, Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "runtime", Kind: mcat.ArtifactBundle, Language: "go", System: "arch", From: "cmd/node-tools"}}}} goRuntime, err := goRuntime.Resolve([]mcat.Built{{Name: "runtime", Kind: mcat.ArtifactBundle, - Reference: mcat.ArtifactStoreScheme + mcat.RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + Reference: mcat.ArtifactStoreScheme + "node-tools" + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) if err != nil { t.Fatal(err) } @@ -26,7 +26,7 @@ func TestARuntimeHealthIsComposedOntoTheModulesOwnRuntime(t *testing.T) { "looks": 2.0} for _, reads := range []bool{true, false} { with := mcat.Rendering{ArtifactStore: "anchor.internal:5101", ReadsHealth: reads, - Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed"}, "keeper": {"broker": "own"}}} + Needed: map[string]map[string]string{"node-tools": {"broker": "sealed"}, "keeper": {"broker": "own"}}} out, err := Declaration(mcat.Resolution{Node: "anchor", Account: "ops", Modules: []mcat.Manifest{keeper, goRuntime}}, with) if err != nil { t.Fatal(err) @@ -46,7 +46,7 @@ func TestARuntimeHealthIsComposedOntoTheModulesOwnRuntime(t *testing.T) { h["timeout"] != "10s" || h["looks"] != 2 || h["grace"] != "1m0s" { t.Errorf("its own runtime's health: %v", own[mcat.HealthField]) } - if machine := fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID()); machine[mcat.HealthField] != nil { + if machine := fileNamed(out, "node-tools"+"."+RuntimeProcessID()); machine[mcat.HealthField] != nil { t.Errorf("the machine's runtime was given the module's health: %v", machine[mcat.HealthField]) } } diff --git a/internal/compose/resolution/runtime_test.go b/internal/compose/resolution/runtime_test.go index 0075bfe..ba0932f 100644 --- a/internal/compose/resolution/runtime_test.go +++ b/internal/compose/resolution/runtime_test.go @@ -31,12 +31,12 @@ func aToolsModule(t *testing.T, name string, entrypoints ...string) mcat.Manifes // loaded, and its broker secret to receive the node's credential in. func theRuntime(t *testing.T) mcat.Manifest { t.Helper() - m := mcat.Manifest{Module: mcat.RuntimeModule, Version: "1", - OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + mcat.RuntimeModule + "/broker"}}, + m := mcat.Manifest{Module: "node-tools", Version: "1", ToolRunner: true, + OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + "node-tools" + "/broker"}}, Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "runtime", Kind: mcat.ArtifactBundle, Language: "typescript", Entrypoints: []string{"src/main.js"}}}}} resolved, err := m.Resolve([]mcat.Built{{Name: "runtime", Kind: mcat.ArtifactBundle, - Reference: mcat.ArtifactStoreScheme + mcat.RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + Reference: mcat.ArtifactStoreScheme + "node-tools" + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) if err != nil { t.Fatal(err) } @@ -45,7 +45,7 @@ func theRuntime(t *testing.T) mcat.Manifest { func TestEveryToolsBundleIsDeliveredWhereTheRuntimeRuns(t *testing.T) { store := mcat.Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}}} + Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}}} nftables := aToolsModule(t, "nftables", "tools/index.js") zsh := aToolsModule(t, "zsh", "tools/index.js", "tools/more.js") @@ -70,7 +70,7 @@ func TestEveryToolsBundleIsDeliveredWhereTheRuntimeRuns(t *testing.T) { t.Errorf("zsh's tools bundle was not delivered: %v", ids(out)) } // The runtime's own bundle is run, not loaded: its process delivers it, not an archive. - if fileNamed(out, mcat.RuntimeModule+"."+BundleID("runtime")) != nil { + if fileNamed(out, "node-tools"+"."+BundleID("runtime")) != nil { t.Error("the runtime's own bundle was delivered as an archive beside its process") } }) @@ -86,6 +86,18 @@ func TestEveryToolsBundleIsDeliveredWhereTheRuntimeRuns(t *testing.T) { t.Errorf("%s was delivered to a machine running no runtime to load it", id) } } + // The tool runner is what says it is one, never a name (issue 774): node-tools saying nothing is no runner. + silent := theRuntime(t) + silent.ToolRunner = false + out, err = Declaration(mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{nftables, zsh, silent}}, store) + if err != nil { + t.Fatal(err) + } + for _, id := range ids(out) { + if strings.Contains(id, BundleID("")) || strings.HasSuffix(id, "."+RuntimeProcessID()) { + t.Errorf("%s was composed for a module that does not say it is the tool runner", id) + } + } }) } @@ -101,7 +113,7 @@ func ids(out []map[string]any) []string { // where its credential is, and who the operator is — restarted when any of that changes. func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) { with := mcat.Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}}} + Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}}} nftables := aToolsModule(t, "nftables", "tools/index.js") // A bundle carrying a daemon beside its tools says which files the runtime loads. showcase := mcat.Manifest{Module: "showcase", Version: "1", Tools: []string{"greet"}, @@ -118,12 +130,12 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) { if err != nil { t.Fatal(err) } - process := fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID()) + process := fileNamed(out, "node-tools"+"."+RuntimeProcessID()) if process == nil { t.Fatalf("no runtime process was composed: %v", ids(out)) } - if process["type"] != "process" || process["name"] != mcat.RuntimeModule || process["digest"] != bundleDigest || - process["source"] != "http://anchor.internal:5101/v2/"+mcat.RuntimeModule+"/runtime/blobs/"+bundleDigest { + if process["type"] != "process" || process["name"] != "node-tools" || process["digest"] != bundleDigest || + process["source"] != "http://anchor.internal:5101/v2/"+"node-tools"+"/runtime/blobs/"+bundleDigest { t.Errorf("the runtime's process is %v", process) } if fmt.Sprint(process["run"]) != "[node src/main.js]" { @@ -134,7 +146,7 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) { "showcase="+mcat.BundleRoot+"/showcase/code/tools/index.js" { t.Errorf("the runtime is told to serve %q: every loaded file, by module, and nothing a bundle runs", env[mcat.RuntimeToolModules]) } - if env[mcat.RuntimeBrokerFile] != "/var/lib/mesh/"+mcat.RuntimeModule+"/broker" { + if env[mcat.RuntimeBrokerFile] != "/var/lib/mesh/"+"node-tools"+"/broker" { t.Errorf("the runtime reads its credential at %q, not where the module's own secret is placed", env[mcat.RuntimeBrokerFile]) } if env[mcat.RuntimeOperatorAccount] != "ops" || env[mcat.RuntimeOperatorHome] != "/home/ops" || process["user"] != "ops" { @@ -142,18 +154,18 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) { } // The credential the process reads belongs to the account it runs as, or it could not read it // (to-be 38 WP3); other modules' secrets are left as their manifests say. - if credential := fileNamed(out, mcat.RuntimeModule+"."+mcat.NeedID("broker")); credential == nil || credential["owner"] != "ops" { + if credential := fileNamed(out, "node-tools"+"."+mcat.NeedID("broker")); credential == nil || credential["owner"] != "ops" { t.Errorf("the runtime's credential is not the account's to read: %v", credential) } restarts := fmt.Sprint(process["restart-on"]) - for _, want := range []string{"nftables." + BundleID("tools"), "showcase." + BundleID("code"), mcat.RuntimeModule + "." + mcat.NeedID("broker")} { + for _, want := range []string{"nftables." + BundleID("tools"), "showcase." + BundleID("code"), "node-tools" + "." + mcat.NeedID("broker")} { if !strings.Contains(restarts, want) { t.Errorf("the runtime is not restarted when %s changes: %s", want, restarts) } } // After every bundle and the credential, so both exist before it starts. names := ids(out) - if names[len(names)-1] != mcat.RuntimeModule+"."+RuntimeProcessID() { + if names[len(names)-1] != "node-tools"+"."+RuntimeProcessID() { t.Errorf("the runtime's process is not last: %v", names) } @@ -162,7 +174,7 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) { if err != nil { t.Fatal(err) } - process := fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID()) + process := fileNamed(out, "node-tools"+"."+RuntimeProcessID()) env := process["env"].(map[string]string) if _, set := env[mcat.RuntimeOperatorAccount]; set { t.Error("an operator account was named on a machine that has none") @@ -170,13 +182,13 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) { if _, set := process["user"]; set { t.Error("a user was set on a machine with no account") } - if credential := fileNamed(out, mcat.RuntimeModule+"."+mcat.NeedID("broker")); credential == nil || credential["owner"] != nil { + if credential := fileNamed(out, "node-tools"+"."+mcat.NeedID("broker")); credential == nil || credential["owner"] != nil { t.Errorf("the runtime's credential was given an owner on a machine with no account: %v", credential) } }) t.Run("a runtime module built wrong is refused by name", func(t *testing.T) { - two := mcat.Manifest{Module: mcat.RuntimeModule, Version: "1", OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/b"}}, + two := mcat.Manifest{Module: "node-tools", Version: "1", ToolRunner: true, OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/b"}}, Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "runtime", Kind: mcat.ArtifactBundle, Language: "typescript", Entrypoints: []string{"a.js", "b.js"}}}}} resolved, err := two.Resolve([]mcat.Built{{Name: "runtime", Kind: mcat.ArtifactBundle, @@ -195,7 +207,7 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) { // build from before launchers — so the move needs no flag day. func TestTheRuntimeStartsTheLauncherWhereTheBuildWroteOne(t *testing.T) { with := mcat.Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}}} + Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}}} launched := mcat.Manifest{Module: "dash", Version: "1", Tools: []string{"status"}, Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "tools", Kind: mcat.ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"}}}}} @@ -210,7 +222,7 @@ func TestTheRuntimeStartsTheLauncherWhereTheBuildWroteOne(t *testing.T) { if err != nil { t.Fatal(err) } - env := fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string) + env := fileNamed(out, "node-tools"+"."+RuntimeProcessID())["env"].(map[string]string) want := "dash=" + mcat.BundleRoot + "/dash/tools/tools/index.serve.mjs,nftables=" + mcat.BundleRoot + "/nftables/tools/tools/index.js" if env[mcat.RuntimeToolModules] != want { t.Errorf("the runtime is told %q, want %q", env[mcat.RuntimeToolModules], want) @@ -220,13 +232,13 @@ func TestTheRuntimeStartsTheLauncherWhereTheBuildWroteOne(t *testing.T) { // novox/hq ADR 0193: a runtime compiled to a binary runs itself from its own unpacked bundle. func TestARuntimeCompiledToABinaryRunsItself(t *testing.T) { with := mcat.Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}}} - goRuntime := mcat.Manifest{Module: mcat.RuntimeModule, Version: "1", - OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + mcat.RuntimeModule + "/broker"}}, + Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}}} + goRuntime := mcat.Manifest{Module: "node-tools", Version: "1", ToolRunner: true, + OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + "node-tools" + "/broker"}}, Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "runtime", Kind: mcat.ArtifactBundle, Language: "go", System: "arch", From: "cmd/node-tools"}}}} goRuntime, err := goRuntime.Resolve([]mcat.Built{{Name: "runtime", Kind: mcat.ArtifactBundle, - Reference: mcat.ArtifactStoreScheme + mcat.RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + Reference: mcat.ArtifactStoreScheme + "node-tools" + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) if err != nil { t.Fatal(err) } @@ -234,7 +246,7 @@ func TestARuntimeCompiledToABinaryRunsItself(t *testing.T) { if err != nil { t.Fatal(err) } - process := fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID()) + process := fileNamed(out, "node-tools"+"."+RuntimeProcessID()) if fmt.Sprint(process["run"]) != "[./node-tools]" { t.Errorf("a Go runtime is run as %v, want its own binary", process["run"]) } @@ -249,13 +261,13 @@ func TestARuntimeCompiledToABinaryRunsItself(t *testing.T) { // which runs as the operator's account and is given none of its words. func TestAModuleOfItsOwnAccountIsServedByARuntimeOfItsOwn(t *testing.T) { with := mcat.Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}, "telegram": {"broker": "own"}}} - goRuntime := mcat.Manifest{Module: mcat.RuntimeModule, Version: "1", - OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + mcat.RuntimeModule + "/broker"}}, + Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}, "telegram": {"broker": "own"}}} + goRuntime := mcat.Manifest{Module: "node-tools", Version: "1", ToolRunner: true, + OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + "node-tools" + "/broker"}}, Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "runtime", Kind: mcat.ArtifactBundle, Language: "go", System: "arch", From: "cmd/node-tools"}}}} goRuntime, err := goRuntime.Resolve([]mcat.Built{{Name: "runtime", Kind: mcat.ArtifactBundle, - Reference: mcat.ArtifactStoreScheme + mcat.RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + Reference: mcat.ArtifactStoreScheme + "node-tools" + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) if err != nil { t.Fatal(err) } @@ -267,7 +279,7 @@ func TestAModuleOfItsOwnAccountIsServedByARuntimeOfItsOwn(t *testing.T) { if err != nil { t.Fatal(err) } - machine := fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID()) + machine := fileNamed(out, "node-tools"+"."+RuntimeProcessID()) if served := machine["env"].(map[string]string)[mcat.RuntimeToolModules]; strings.Contains(served, "telegram") || !strings.Contains(served, "nftables") { t.Errorf("the machine's runtime serves %q", served) } @@ -342,13 +354,13 @@ func TestAModuleOfItsOwnAccountIsServedByARuntimeOfItsOwn(t *testing.T) { // as the account agents run as there — either would hand what it holds back to the accounts it is kept from. func TestAModuleOfItsOwnAccountIsRefusedTheOperatorsAndTheAgentsAccount(t *testing.T) { with := mcat.Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}, "telegram": {"broker": "own"}}} - goRuntime := mcat.Manifest{Module: mcat.RuntimeModule, Version: "1", - OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + mcat.RuntimeModule + "/broker"}}, + Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}, "telegram": {"broker": "own"}}} + goRuntime := mcat.Manifest{Module: "node-tools", Version: "1", ToolRunner: true, + OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + "node-tools" + "/broker"}}, Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "runtime", Kind: mcat.ArtifactBundle, Language: "go", System: "arch", From: "cmd/node-tools"}}}} goRuntime, err := goRuntime.Resolve([]mcat.Built{{Name: "runtime", Kind: mcat.ArtifactBundle, - Reference: mcat.ArtifactStoreScheme + mcat.RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + Reference: mcat.ArtifactStoreScheme + "node-tools" + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) if err != nil { t.Fatal(err) } @@ -368,16 +380,16 @@ func TestAModuleOfItsOwnAccountIsRefusedTheOperatorsAndTheAgentsAccount(t *testi // files it holds for its consumers, and what its words name — belongs to that account, never the operator's. func TestWhatAModuleOfItsOwnAccountReadsIsItsAccounts(t *testing.T) { with := mcat.Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}, "vault": {"broker": "own"}}, + Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}, "vault": {"broker": "own"}}, Kept: &mcat.KeptExport{Export: 1, OperatorKey: "k"}, Grants: []mcat.Grant{{Provision: "secret", Consumer: "anchor", From: "telegram", Sealed: "sealed-to-anchor", Values: map[string]any{"name": "telegram"}}}} - goRuntime := mcat.Manifest{Module: mcat.RuntimeModule, Version: "1", - OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + mcat.RuntimeModule + "/broker"}}, + goRuntime := mcat.Manifest{Module: "node-tools", Version: "1", ToolRunner: true, + OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + "node-tools" + "/broker"}}, Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "runtime", Kind: mcat.ArtifactBundle, Language: "go", System: "arch", From: "cmd/node-tools"}}}} goRuntime, err := goRuntime.Resolve([]mcat.Built{{Name: "runtime", Kind: mcat.ArtifactBundle, - Reference: mcat.ArtifactStoreScheme + mcat.RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + Reference: mcat.ArtifactStoreScheme + "node-tools" + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) if err != nil { t.Fatal(err) } -- 2.54.0 From 6ea23b67315dbde00cdae687753c0362c1aca098 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 13:29:21 +0100 Subject: [PATCH 07/20] =?UTF-8?q?The=20sandbox's=20composer=20marks=20a=20?= =?UTF-8?q?node=20it=20does=20not=20judge=20in=20the=20contract's=20field?= =?UTF-8?q?=20(Machine.NotJudged),=20neither=20composed=20nor=20refused,?= =?UTF-8?q?=20never=20by=20a=20prefix=20of=20its=20reason=20(ADR=200593=20?= =?UTF-8?q?=C2=A79;=20the=20prepare's=20seams=20finding=20on=20"not=20judg?= =?UTF-8?q?ed")?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/composer/composer.go | 7 ++++--- internal/composer/composer_test.go | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/composer/composer.go b/internal/composer/composer.go index 92961f1..61aad6c 100644 --- a/internal/composer/composer.go +++ b/internal/composer/composer.go @@ -222,7 +222,7 @@ func machine(in compose.Input, node, depth string, declarations bool) Machine { // own composition: the sandbox cannot compose them as the node does, so it does not judge the node, and names // it (ADR 0593 §9), never a failure of the change and never a silent pass. if folded := foldedUnsaid(in, node); len(folded) > 0 && in.ComposedHow == nil { - return Machine{Composes: true, Reason: NotJudged + strings.Join(folded, ", ") + + return Machine{NotJudged: NotJudged + strings.Join(folded, ", ") + " (its own composition carries them as they run, and the sandbox cannot): " + err.Error()} } return Machine{Reason: err.Error()} @@ -237,8 +237,9 @@ func machine(in compose.Input, node, depth string, declarations bool) Machine { return m } -// NotJudged opens the reason of a machine the sandbox does not judge: its node-engine sent an input that does not say -// how it composes (compose.Input.ComposedHow), and the node runs modules folded in its own composition. +// NotJudged opens the words of a machine the sandbox does not judge (Machine.NotJudged, the contract's field, ADR 0593 §9): +// its node-engine sent an input that does not say how it composes (compose.Input.ComposedHow), and the node runs modules +// folded in its own composition. The machine is then neither composed nor refused; a reader decides by the field. const NotJudged = "not judged: its node-engine sends no running state for " // foldedUnsaid is each module node runs folded in its own composition (Node.InOwn) whose manifest names a build: what diff --git a/internal/composer/composer_test.go b/internal/composer/composer_test.go index 3e669aa..73ecf12 100644 --- a/internal/composer/composer_test.go +++ b/internal/composer/composer_test.go @@ -132,7 +132,7 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { if err != nil { t.Fatal(err) } - if m := older.Cases[0].Machines["a"]; !m.Composes || !strings.HasPrefix(m.Reason, NotJudged+"bus") { + if m := older.Cases[0].Machines["a"]; m.Composes || !strings.HasPrefix(m.NotJudged, NotJudged+"bus") || m.Reason != "" { t.Errorf("an older engine's input of a node with a folded module: %+v", m) } in.ComposedHow = &compose.ComposedHow{Node: "a", Builds: map[string]string{}} -- 2.54.0 From faa20055381c8f30c91862aa08a44b49bdb5d6e3 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 13:31:44 +0100 Subject: [PATCH 08/20] =?UTF-8?q?A=20node=20agent=20that=20meets=20a=20man?= =?UTF-8?q?ifest=20which=20does=20not=20yet=20say=20it=20is=20the=20tool?= =?UTF-8?q?=20runner,=20or=20that=20its=20own=20secrets=20are=20the=20core?= =?UTF-8?q?'s=20credentials,=20keeps=20what=20runs=20of=20it,=20its=20tool?= =?UTF-8?q?=20runner=20process=20and=20its=20sealed=20secrets=20alike,=20k?= =?UTF-8?q?nown=20by=20what=20runs=20and=20said;=20never=20composed=20with?= =?UTF-8?q?out=20it=20(ADR=200601=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/compose/frombus/frombus.go | 14 +++- internal/compose/frombus/frombus_test.go | 32 ++++++++ internal/compose/frombus/keep.go | 100 +++++++++++++++++++++++ internal/compose/frombus/twice.go | 1 + internal/selfcompose/selfcompose.go | 2 +- 5 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 internal/compose/frombus/keep.go diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 3478df6..244d26a 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -154,6 +154,9 @@ type Scope struct { // key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them // as differing. SameValue func(a, b string) (same, known bool) + // Kept is told, in words, each module composed as what runs shows because its manifest does not yet say it (the tool + // runner, the core's credentials; ADR 0601 §4), so the node says it. Nil: said nowhere. + Kept func(words string) } // InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs @@ -229,7 +232,16 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module if err != nil { return compose.Input{}, err } - return b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted) + in, err := b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted) + if err == nil { + // What runs is kept while a manifest does not yet say what shared code no longer infers (ADR 0601 §4). + for _, words := range keepWhatRuns(&in, s.Node, s.Running) { + if s.Kept != nil { + s.Kept(words) + } + } + } + return in, err } // base is the part of a node's input that the fingerprints it composes its modules at leave alone (issue 1954): read once diff --git a/internal/compose/frombus/frombus_test.go b/internal/compose/frombus/frombus_test.go index cd566f5..abcb742 100644 --- a/internal/compose/frombus/frombus_test.go +++ b/internal/compose/frombus/frombus_test.go @@ -148,6 +148,7 @@ func TestInput(t *testing.T) { t.Run("a held module's first reason leads", aHeldModulesFirstReasonLeads) t.Run("a held module of its own account never refuses the node", aHeldModuleOfItsOwnAccountNeverRefusesTheNode) t.Run("another node not resolved is named", otherNodeNotResolvedIsNamed) + t.Run("what runs is kept while a manifest does not say it", whatRunsIsKeptWhileAManifestDoesNotSayIt) ctx := context.Background() xterm, err := os.ReadFile("testdata/catalogue-835bf22b-xterm.module.json") if err != nil { @@ -867,3 +868,34 @@ func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates { } return out } + +// ADR 0601 §4: the new node agent meets the tool runner's older manifest (no tool-runner) while the node runs its runtime +// process, and the provisioner's older manifest (no core-credentials) while its own secret runs as the vault's box: each is +// composed as what runs shows, said, by what runs and never by a name; a manifest that says it, or nothing running so, marks +// nothing. +func whatRunsIsKeptWhileAManifestDoesNotSayIt(t *testing.T) { + running := []delivery.Composed{ + {Node: "a", Module: "runner-x", Resources: []json.RawMessage{json.RawMessage(`{"id":"runner-x.runtime","type":"process"}`)}}, + {Node: "a", Module: "prov-y", Resources: []json.RawMessage{json.RawMessage(`{"id":"prov-y.need-broker","type":"file","vault-sealed":{"box":"b"}}`)}}, + {Node: "a", Module: "app", Resources: []json.RawMessage{json.RawMessage(`{"id":"app.need-broker","type":"file"}`)}}, + } + input := func(runnerSays bool) compose.Input { + return compose.Input{Nodes: []compose.Node{{Name: "a", Assigned: []string{"app", "prov-y", "runner-x"}}}, + Catalogue: map[string]catalogue.Manifest{"runner-x": {Module: "runner-x", ToolRunner: runnerSays}, "prov-y": {Module: "prov-y"}, + "app": {Module: "app"}}} + } + in := input(false) + said := keepWhatRuns(&in, "a", running) + if !in.Catalogue["runner-x"].ToolRunner || !in.Catalogue["prov-y"].CoreCredentials || in.Catalogue["app"].ToolRunner || + in.Catalogue["app"].CoreCredentials || len(said) != 2 { + t.Errorf("what runs was not kept: %+v, said %q", in.Catalogue, said) + } + in = input(true) + if said := keepWhatRuns(&in, "a", running[:1]); len(said) != 0 { + t.Errorf("a manifest that says it is the tool runner was marked again: %q", said) + } + in = input(false) + if said := keepWhatRuns(&in, "a", nil); len(said) != 0 || in.Catalogue["runner-x"].ToolRunner { + t.Errorf("nothing running marked %q", said) + } +} diff --git a/internal/compose/frombus/keep.go b/internal/compose/frombus/keep.go new file mode 100644 index 0000000..c766ec8 --- /dev/null +++ b/internal/compose/frombus/keep.go @@ -0,0 +1,100 @@ +package frombus + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + "sort" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/declaration" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" +) + +// **What runs is kept while a manifest does not yet say what shared code no longer infers** (novox/hq ADR 0601 §4). mesh-sdk +// knows the tool runner and the modules whose own secrets are the core's credentials only by what their manifests say +// (`tool-runner`, `core-credentials`), no longer by their names. A module placed on the composing node whose manifest (an +// older build's) does not say so yet, while what the node runs of it shows it (the tool runner's process, `.runtime`; +// an own secret carried as the vault's box, the file's `vault-sealed`), is composed as what runs shows: its tool runner +// process and its sealed secrets alike are kept, never composed away, and the node says so until the manifest says it. +// Known by what runs, never by a module's name. + +// keepWhatRuns marks, in in, each module placed on node that what runs shows is the tool runner or carries the core's +// credentials, where its manifest does not say so; it returns, sorted, what it marked, in words. The tool runner is marked +// only where no placed module's manifest says it is one. +func keepWhatRuns(in *compose.Input, node string, running []delivery.Composed) []string { + placed := assignedOn(*in, node) + if len(placed) == 0 || len(running) == 0 { + return nil + } + runner, sealed := "", map[string]bool{} + for _, run := range running { + for _, raw := range run.Resources { + var r struct { + ID string `json:"id"` + Type string `json:"type"` + } + if json.Unmarshal(raw, &r) != nil || r.ID == "" { + continue + } + owner := run.Module + if owner == "" { + owner, _ = declaration.ModuleGuess(r.ID) + } + if owner == "" || !slices.Contains(placed, owner) { + continue + } + if r.Type == "process" && r.ID == owner+"."+resolution.RuntimeProcessID() { + runner = owner + } + if r.Type == "file" { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) == nil { + if _, ok := fields[catalogue.VaultSealedField]; ok { + sealed[owner] = true + } + } + } + } + } + var said []string + saysRunner := false + for _, m := range placed { + if man, ok := manifestOn(*in, node, m); ok && catalogue.IsToolRunner(man) { + saysRunner = true + } + } + mark := func(module string, set func(*catalogue.Manifest), words string) { + for i := range in.Nodes { + if in.Nodes[i].Name != node { + continue + } + if man, ok := in.Nodes[i].Kept[module]; ok { + set(&man) + in.Nodes[i].Kept[module] = man + said = append(said, words) + return + } + } + if man, ok := in.Catalogue[module]; ok { + set(&man) + in.Catalogue[module] = man + said = append(said, words) + } + } + if runner != "" && !saysRunner { + mark(runner, func(m *catalogue.Manifest) { m.ToolRunner = true }, fmt.Sprintf("%s runs the tool runner's process here and its "+ + "manifest does not say it is the tool runner (tool-runner): it is composed as the tool runner it runs as, until its manifest says so", runner)) + } + for _, m := range slices.Sorted(maps.Keys(sealed)) { + if man, ok := manifestOn(*in, node, m); ok && !catalogue.CarriesCoreCredentials(man) { + mark(m, func(m *catalogue.Manifest) { m.CoreCredentials = true }, fmt.Sprintf("%s carries its own secrets sealed by the vault here "+ + "and its manifest does not say they are the core's credentials (core-credentials): they stay sealed, until its manifest says so", m)) + } + } + sort.Strings(said) + return said +} diff --git a/internal/compose/frombus/twice.go b/internal/compose/frombus/twice.go index cb133df..e9738dd 100644 --- a/internal/compose/frombus/twice.go +++ b/internal/compose/frombus/twice.go @@ -66,6 +66,7 @@ func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newe sc.into.Err = err continue } + keepWhatRuns(&in, sc.scope.Node, sc.scope.Running) // what runs is kept, as Compose keeps it (ADR 0601 §4) sc.into.Plans, sc.into.Err = plansHeldOf(in, s.Node, held) mark("plans at " + sc.name) } diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 3019186..a539ad5 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -844,7 +844,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { } // Its own certificate, asked of the vault when a module here asks for one and the record holds none it trusts. c.certifyIfWanted(ctx, r, own) - scope := frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn, Running: running} + scope := frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn, Running: running, Kept: c.sayOnceEach} c.mu.Lock() c.dataScope = frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn} // the data items are worked out as composed c.mu.Unlock() -- 2.54.0 From 5b455cecfe229dadc377b12005ead0ff9c21b5fa Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 13:35:12 +0100 Subject: [PATCH 09/20] =?UTF-8?q?A=20have=20waiting=20for=20a=20yes=20says?= =?UTF-8?q?=20whether=20the=20move=20changes=20the=20build=20alone:=20the?= =?UTF-8?q?=20composer=20composes=20the=20build=20the=20module=20runs=20wi?= =?UTF-8?q?th=20today's=20settings=20and=20secrets=20and=20the=20agent=20c?= =?UTF-8?q?ompares=20it=20with=20what=20runs,=20so=20a=20release's=20Appro?= =?UTF-8?q?ve=20answers=20it=20and=20a=20changed=20setting=20is=20still=20?= =?UTF-8?q?asked=20(ADR=200601=20=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/mesh-host/nodeagent.go | 4 +-- internal/agent/agent.go | 26 ++++++++++++++++- internal/agent/agent_test.go | 20 ++++++++++++- internal/link/have.go | 2 +- internal/link/messages.go | 2 ++ internal/selfcompose/selfcompose.go | 45 +++++++++++++++++++++++++++++ 6 files changed, 94 insertions(+), 5 deletions(-) diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index eb9659a..68759f5 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -120,7 +120,7 @@ func (h sessionHaves) Write(ctx context.Context, x agent.Have) { } h.session.NoteModule(ctx, link.ModuleFact{Module: x.Module, FP: x.FP, Composed: x.Composed, Phase: phase, Failed: phase == string(delivery.PhaseFailed), Detail: shorten(x.Detail), Judgement: x.Judgement, Waits: x.Waits, - Sealed: x.Sealed}) + BuildAlone: x.BuildAlone, Sealed: x.Sealed}) } func (h sessionHaves) Removed(ctx context.Context, module string) { @@ -630,7 +630,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus composer := selfComposer(session, node, hooks, say) machinesNow.Store(composer) // the machines a failing network part points at, by the placements (issue 774) composerNow.Store(composer) // what the verb compose asks a pass of, this run's alone - env.Composed, env.SelfComposed, env.Policy = composer, true, composer.Policy + env.Composed, env.SelfComposed, env.Policy, env.ComposedToday = composer, true, composer.Policy, composer.ComposedToday env.HoldsData = composer.HoldsData // what runs from the kept declaration says Data as its manifest does session.NoteComposes(linked, delivery.ComposesOwn, "it composes its own modules, and applies no composition made elsewhere") ag = agent.New(env) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c392947..35d5d42 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -58,6 +58,8 @@ type Have struct { // waits for a person's yes (ADR 0547 §3). Judgement string Waits string + // BuildAlone says, in phase waiting-yes, the move changes the build alone (novox/hq ADR 0601 §1, delivery.Have.BuildAlone). + BuildAlone bool // Sealed is, on a node composing its own, each value the composition it names carries sealed to the node's key, by // slot (delivery.Have.SealedAt): kept in the have so the composer reuses them after a restart (ADR 0547 §3), each at // its own slot alone (issue 1126, C1). @@ -153,6 +155,11 @@ type Env struct { // move of a module of policy record that recreates a container or moves its data waits for a person's yes in its // want (ADR 0547 §3). Nil judges no move. Policy func(ctx context.Context, module string) string + // ComposedToday is the digest of a module composed at the build fp with today's settings and secrets, as the node would + // compose it now, and whether it could be composed: a move to another build whose composition of the build it runs is + // what runs changes the build alone (novox/hq ADR 0601 §1), and the have says so. Nil: no move is said to change the + // build alone, and each is asked of a person. + ComposedToday func(ctx context.Context, module, fp string) (string, bool) // HoldsData says whether a module's manifest declares data (catalogue.Manifest.DataItems), and whether its manifest // could be read: what a composition's Data is, for what runs of a module from the kept declaration, which carries no // Data of its own. A composition says Data, so without it a data module from the kept declaration is judged to move @@ -1649,10 +1656,27 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp prev := a.running[module] a.mu.Unlock() a.writeHave(ctx, Have{Module: module, FP: prev.FP, Composed: prev.Composed, Phase: string(delivery.PhaseWaitingYes), - Detail: j.Words, Judgement: string(j.Judgement), Waits: comp.Dig}) + Detail: j.Words, Judgement: string(j.Judgement), Waits: comp.Dig, BuildAlone: a.buildAlone(ctx, module, prev, w.Want.FP)}) return true } +// buildAlone says a move of module from what runs to the build want changes the build alone (ADR 0601 §1): another build than +// the one it runs, and the build it runs composed with today's settings and secrets is the composition it runs. Anything it +// cannot tell is no: the move is then asked of a person. +func (a *Agent) buildAlone(ctx context.Context, module string, prev delivery.Have, want string) bool { + if prev.FP == "" && prev.Composed == "" { + // Nothing of it run since this agent's start: its last have says what runs, as the have written keeps it. + if h, ok := a.env.Haves.Read(ctx, module); ok { + prev.FP, prev.Composed = h.FP, h.Composed + } + } + if a.env.ComposedToday == nil || prev.FP == "" || prev.Composed == "" || prev.FP == want { + return false + } + dig, ok := a.env.ComposedToday(ctx, module, prev.FP) + return ok && dig == prev.Composed +} + // runningComposition is what the module runs now: the composition last applied, else its resources in the declaration // the node kept from before the switch. Called with a.mu held. // diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index f2b3306..a6e7b2f 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -1288,6 +1288,17 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( } return catalogue.PolicyRoll } + // ADR 0601 §1: what runs composed today. rec's build composed again is what it runs, so its move changes the build alone; + // db's is not (a setting changed too). + b.env.ComposedToday = func(_ context.Context, m, fp string) (string, bool) { + switch m { + case "rec": + return "sha256:" + strings.Repeat("8", 64), fp == fpOf("d") + case "db": + return "sha256:" + strings.Repeat("6", 64), true + } + return "", false + } // The bucket's revisions of each key, as the verb composition reads them (issue 774). var historyMu sync.Mutex history := map[string][]Update{} @@ -1452,6 +1463,9 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( h := b.haves.last("db") return h.Phase == string(delivery.PhaseWaitingYes) && h.Waits == ddigMoved }) + if h := b.haves.last("db"); h.BuildAlone { + t.Errorf("a revision at the build it runs is said to change the build alone: %+v", h) + } // The verb composition says why it waits: the revision that waits, what runs, the judgement, and with diff the // container it recreates; the revision that runs, asked by its digest, is no move (issue 774). keep(dk, draw) @@ -1508,7 +1522,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( for _, row := range []struct { module, fp, composed, waits string sealed delivery.Seals - }{{"db", fp, "", ddig2, nil}, {"rec", recFP, recDig, rdig, delivery.Seals{"rec.key#sealed": `{"box":"rec"}`}}} { + alone bool + }{{"db", fp, "", ddig2, nil, false}, {"rec", recFP, recDig, rdig, delivery.Seals{"rec.key#sealed": `{"box":"rec"}`}, true}} { eventually(t, row.module+"'s move did not wait for a yes", func() bool { h := b.haves.last(row.module) return h.Phase == string(delivery.PhaseWaitingYes) && h.Waits == row.waits @@ -1518,6 +1533,9 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( (row.sealed != nil && !maps.Equal(h.Sealed, row.sealed)) { t.Errorf("%s's have waiting for a yes does not keep what runs: %+v", row.module, h) } + if h.BuildAlone != row.alone { + t.Errorf("%s's have waiting for a yes says build alone %v, want %v", row.module, h.BuildAlone, row.alone) + } } if got := b.appliedList(); len(got) != applied { t.Fatalf("a move waiting for a yes was applied: %v", got) diff --git a/internal/link/have.go b/internal/link/have.go index 6f8040a..7f94aa6 100644 --- a/internal/link/have.go +++ b/internal/link/have.go @@ -375,7 +375,7 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) { h := delivery.Have{V: delivery.Version, FP: f.FP, Composed: f.Composed, Build: f.Build, Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown, Detail: f.Detail} if delivery.Phase(f.Phase) == delivery.PhaseWaitingYes { - h.Judgement, h.Waits = delivery.Judgement(f.Judgement), f.Waits + h.Judgement, h.Waits, h.BuildAlone = delivery.Judgement(f.Judgement), f.Waits, f.BuildAlone } switch { case f.Phase != "": diff --git a/internal/link/messages.go b/internal/link/messages.go index 531a1ab..05ec57a 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -639,6 +639,8 @@ type ModuleFact struct { // Judgement and Waits are, in phase waiting-yes, the judgement of the move and the composition waiting (ADR 0547 §3). Judgement string Waits string + // BuildAlone says, in phase waiting-yes, the move changes the build alone (ADR 0601 §1, delivery.Have.BuildAlone). + BuildAlone bool // Sealed is the values the composition it names carries sealed to this node's key, by slot (delivery.Have.SealedAt). Sealed delivery.Seals } diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index a539ad5..8726b82 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -1635,3 +1635,48 @@ func Transient(err error) bool { } return false } + +// ComposedToday is the digest of module composed at the build fp with today's settings and secrets, as this node would +// compose it now beside its other modules at their wanted builds, the boxes it runs kept as compose keeps them, and whether +// it composed (novox/hq ADR 0601 §1): the node agent compares it with what runs, to say whether a move to another build +// changes the build alone. A module held, a node refused, or no placement: false, and the move is asked of a person. +func (c *Composer) ComposedToday(ctx context.Context, module, fp string) (string, bool) { + c.mu.Lock() + fps, kept := map[string]string{}, map[string]string{} + for m, w := range c.wants { + if m == agent.NodeModule { + continue + } + fps[m] = w.FP + if mm, ok := c.modules[m]; ok && mm.FP2 != w.FP { + kept[m] = w.FP + } + } + fps[module] = fp + delete(kept, module) + if mm, ok := c.modules[module]; !ok || mm.FP2 != fp { + kept[module] = fp + } + own, placed := c.placements[c.env.Node] + inOwn := c.inOwnOf(own) + c.mu.Unlock() + if !placed { + return "", false + } + var running []delivery.Composed + if c.env.Running != nil { + running = c.env.Running() + } + scope := frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn, Running: running} + comps, _, held, err := frombus.Compose(ctx, reader{c: c}, c.env.Mesh, scope, fps) + if err != nil || held[module] != "" { + return "", false + } + for _, comp := range comps { + if comp.Module == module && comp.FP == fp { + dig, err := delivery.ComposedDigestOf(c.keepRunning(comp, running)) + return dig, err == nil + } + } + return "", false +} -- 2.54.0 From 75e3a26336d36d614f23a0b88f11a51d316ee04b Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 13:37:42 +0100 Subject: [PATCH 10/20] =?UTF-8?q?The=20integration=20test=20of=20the=20bus?= =?UTF-8?q?=20module's=20first=20want=20shows=20its=20node=20backing=20it?= =?UTF-8?q?=20up=20through=20the=20backup=20seat=20before=20that=20apply?= =?UTF-8?q?=20and=20before=20the=20one=20that=20replaces=20the=20server,?= =?UTF-8?q?=20as=20every=20module=20that=20keeps=20data=20(ADR=200601=20?= =?UTF-8?q?=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/mesh-host/nodeagent_integration_test.go | 38 ++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index 4243082..ba61810 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -700,6 +700,8 @@ func (r *recordingRuntime) moved(container string) []string { // its changed file and records the server as the bus module's, and asks the container runtime to remove, make, stop, // rename or restart nothing, since the build does not change the server. A later want whose build changes the server // replaces it once, as any module's, once the want carries the yes (§2: the assigner gives it from the release's Approve). +// ADR 0601 §2: the bus module keeps data (its streams), so before each of those applies the node agent asks its node's +// backup seat to back it up, as for every module that keeps data, and applies only once the backup says it is done. func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *testing.T) { b := newItBed(t) runtime := &recordingRuntime{containers: map[string]string{}} @@ -749,7 +751,8 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test kvOf(link.PackagesBucket) // The bus module at a build, done: its manifest written and the indexer's module naming it at fp. publish := func(fp, env string) { - man := `{"module":"nats","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],"resources":[` + + man := `{"module":"nats","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],` + + `"data":{"own":[{"id":"jetstream","path":"` + b.dir + `","class":"valuable","why":"the bus's streams"}]},"resources":[` + `{"id":"server-conf","type":"file","path":"` + conf + `","mode":"0644","content":"listen: new\n"},` + `{"id":"server","type":"container","name":"` + server + `","image":"` + image + `"` + env + `}]}` digest := delivery.ManifestDigest([]byte(man)) @@ -790,6 +793,33 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test t.Fatal(err) } } + // The node's backup seat: its holder says the bus module has something to back up, starts a backup on now, and says + // when it ended on its event; each ask of now notes what the bus's file held then, so the order is seen. + var backups []string + var backupsMu sync.Mutex + if _, err := b.conn.Subscribe(link.SeatToolSubject("node-backup", "backed-up", itNode), func(m *nats.Msg) { + _ = m.Respond([]byte(`{"result":[{"module":"nats","runs":1}]}`)) + }); err != nil { + t.Fatal(err) + } + if _, err := b.conn.Subscribe(link.SeatToolSubject("node-backup", "now", itNode), func(m *nats.Msg) { + got, _ := os.ReadFile(conf) + backupsMu.Lock() + backups = append(backups, string(got)) + backupsMu.Unlock() + _ = m.Respond([]byte(`{"result":{"started":true}}`)) + go func() { + time.Sleep(20 * time.Millisecond) + _ = b.conn.Publish(backedUpEvent, backupEvent(itNode, "nats", true, time.Now(), "")) + }() + }); err != nil { + t.Fatal(err) + } + backedUp := func() []string { + backupsMu.Lock() + defer backupsMu.Unlock() + return append([]string(nil), backups...) + } first := fpIt("e") publish(first, "") busWant(delivery.Want{FP: first, Self: true}) @@ -809,6 +839,9 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test if moved := runtime.moved(server); len(moved) != 0 || runtime.containers[server] != made { t.Fatalf("the first want moved the bus server its build does not change: %v", moved) } + if got := backedUp(); len(got) != 1 || got[0] != "listen: old\n" { + t.Fatalf("the bus module's first apply was not backed up first: the backups saw %q", got) + } state, err := store.Load(b.opts.state) if err != nil { t.Fatal(err) @@ -835,6 +868,9 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test runs++ } } + if got := backedUp(); len(got) != 2 || got[1] != "listen: new\n" { + t.Errorf("the replacing apply of the bus module was not backed up first: the backups saw %q", got) + } if runs != 1 || runtime.containers[server] == made { t.Errorf("the build that changes the server made it %d times, its spec still the folded one %v: %v", runs, runtime.containers[server] == made, runtime.moved(server)) -- 2.54.0 From b461b005e9f6ff34da90b130dea396b6e7f42ab7 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 13:46:47 +0100 Subject: [PATCH 11/20] Pin mesh-sdk 09ec82cb3551, the group's head of feat/l3b-bus (ADR 0601) --- go.mod | 2 +- go.sum | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index ece9da5..04c2aed 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103105230-a973bfaf42b2 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103121808-09ec82cb3551 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index a8a3c3c..271c794 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +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.20261103103643-4f141ff08892 h1:XWorhIHDVECRCYzGbkaexEpJ7v03B9LrZ8IVy7vkUKs= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103103643-4f141ff08892/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103105230-a973bfaf42b2 h1:CMNEBKlWI36rQEG5lhFRKoICquvYP4hBD7h/jCVFIcc= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103105230-a973bfaf42b2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103121808-09ec82cb3551 h1:DAxLU3IjZ9Kof8F3kVnINcauLqzCdQ1smwMpzX8yi7I= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103121808-09ec82cb3551/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= -- 2.54.0 From 7da4deb3f11ff9a7955473024e7e09a0b71f88bc Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 14:02:01 +0100 Subject: [PATCH 12/20] =?UTF-8?q?What=20a=20node=20keeps=20of=20a=20module?= =?UTF-8?q?=20as=20it=20runs,=20for=20a=20manifest=20that=20does=20not=20y?= =?UTF-8?q?et=20say=20it,=20is=20carried=20on=20the=20module's=20have=20by?= =?UTF-8?q?=20module,=20so=20the=20watchman=20raises=20its=20condition=20(?= =?UTF-8?q?ADR=200601=20=C2=A74;=20review=20round=201,=20finding=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/mesh-host/nodeagent.go | 13 ++++++-- internal/agent/agent.go | 41 ++++++++++++++++++++++++ internal/agent/agent_test.go | 9 ++++++ internal/compose/frombus/frombus.go | 6 ++-- internal/compose/frombus/frombus_test.go | 2 +- internal/compose/frombus/keep.go | 20 ++++++++---- internal/link/have.go | 1 + internal/link/messages.go | 2 ++ internal/selfcompose/selfcompose.go | 21 +++++++++++- 9 files changed, 100 insertions(+), 15 deletions(-) diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 68759f5..00b5aa2 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -120,7 +120,7 @@ func (h sessionHaves) Write(ctx context.Context, x agent.Have) { } h.session.NoteModule(ctx, link.ModuleFact{Module: x.Module, FP: x.FP, Composed: x.Composed, Phase: phase, Failed: phase == string(delivery.PhaseFailed), Detail: shorten(x.Detail), Judgement: x.Judgement, Waits: x.Waits, - BuildAlone: x.BuildAlone, Sealed: x.Sealed}) + BuildAlone: x.BuildAlone, Kept: x.Kept, Sealed: x.Sealed}) } func (h sessionHaves) Removed(ctx context.Context, module string) { @@ -606,6 +606,12 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus now.NotComposedEach(run, why, held) // a module held alone marks that module, never the others (issue 1126) } }, + // And what it keeps of a module as it runs, for a manifest that does not say it yet: on the module's have (ADR 0601 §4). + kept: func(kept map[string][]string) { + if now := own.get(); now != nil && run.Err() == nil { + now.KeptNow(run, kept) + } + }, // And the vault's authority beside the controller's, and this node's certificate asked of the vault when a // module here asks for one (ADR 0550 §2). vaultAuthority: trust.VaultAuthority, @@ -616,7 +622,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus // And its answer to every placement candidate, placed or not, applying nothing (novox/hq ADR 0559 §4): written into // its own key, `.node`, under candidates. shadowHooks := hooks - shadowHooks.refused, shadowHooks.certify = nil, nil + shadowHooks.refused, shadowHooks.kept, shadowHooks.certify = nil, nil, nil shadowEnv := composerEnv(session, node, say, shadowHooks) shadow := selfcompose.NewShadow(shadowEnv, func(ctx context.Context, answers map[string]delivery.CandidateAnswer) { @@ -958,6 +964,7 @@ type composerHooks struct { running func() []delivery.Composed unseal func(string) ([]byte, error) refused func(why string, held map[string]string) + kept func(kept map[string][]string) held func(module string) (delivery.Seals, []string, error) // vaultAuthority and certify are selfcompose.Env's VaultAuthority and Certify. vaultAuthority func() string @@ -1011,6 +1018,6 @@ func composerEnv(session *link.Session, node string, say func(string), hooks com Hear: session.Hear, // And a moved module's templates archive, in the artifact store, as every archive is fetched (issue 1126). Fetch: apply.Fetch, - Running: hooks.running, Unseal: hooks.unseal, Refused: hooks.refused, Held: hooks.held, + Running: hooks.running, Unseal: hooks.unseal, Refused: hooks.refused, Held: hooks.held, Kept: hooks.kept, VaultAuthority: hooks.vaultAuthority, Certify: hooks.certify} } diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 35d5d42..b9ff6ff 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -17,6 +17,7 @@ import ( "context" "encoding/json" "fmt" + "slices" "sort" "strings" "sync" @@ -60,6 +61,9 @@ type Have struct { Waits string // BuildAlone says, in phase waiting-yes, the move changes the build alone (novox/hq ADR 0601 §1, delivery.Have.BuildAlone). BuildAlone bool + // Kept says, in words, what the node keeps of the module as it runs because its manifest does not yet say it (ADR 0601 + // §4, delivery.Have.Kept): the watchman raises a condition from it. + Kept []string // Sealed is, on a node composing its own, each value the composition it names carries sealed to the node's key, by // slot (delivery.Have.SealedAt): kept in the have so the composer reuses them after a restart (ADR 0547 §3), each at // its own slot alone (issue 1126, C1). @@ -205,6 +209,8 @@ type Agent struct { // keptData is, for each module of the kept declaration whose manifest could be read, whether it declares data // (Env.HoldsData): the Data of what runs of it from the kept declaration. keptData map[string]bool + // kept is, by module, what the composer's last pass keeps of it as it runs (KeptNow, ADR 0601 §4). Under mu. + kept map[string][]string // attempt is the last apply of a module that failed or is held, with the revisions it was made from. attempt map[string]attempt checked map[string]bool @@ -579,6 +585,11 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string // said runs, its fingerprint, digest and sealed values, rather than a have naming nothing: what runs is not lost, and // the boxes the composer reuses after a restart stay (review of L2, rounds 2 and 3). func (a *Agent) writeHave(ctx context.Context, h Have) { + if h.Module != NodeModule { + a.mu.Lock() + h.Kept = a.kept[h.Module] + a.mu.Unlock() + } if h.Composed == "" && h.Module != NodeModule { if last, ok := a.env.Haves.Read(ctx, h.Module); ok && last.Composed != "" { h.FP, h.Composed = last.FP, last.Composed @@ -595,6 +606,36 @@ func (a *Agent) writeHave(ctx context.Context, h Have) { a.env.Haves.Write(ctx, h) } +// KeptNow takes in what the composer's last pass keeps of each module as it runs because its manifest does not yet say it +// (ADR 0601 §4), and writes again the have of each module whose words changed, so it carries them; a have waiting for a yes +// takes them at its next write, which keeps what it waits for. +func (a *Agent) KeptNow(ctx context.Context, kept map[string][]string) { + a.mu.Lock() + var changed []string + for m := range a.kept { + if _, still := kept[m]; !still { + changed = append(changed, m) + } + } + for m, words := range kept { + if !slices.Equal(a.kept[m], words) { + changed = append(changed, m) + } + } + a.kept = map[string][]string{} + for m, words := range kept { + a.kept[m] = slices.Clone(words) + } + a.mu.Unlock() + slices.Sort(changed) + for _, m := range changed { + if h, ok := a.env.Haves.Read(ctx, m); ok && m != NodeModule && h.Phase != string(delivery.PhaseWaitingYes) { + h.Module = m + a.writeHave(ctx, h) + } + } +} + func (a *Agent) sealedOf(ctx context.Context, module, fp, composed string) delivery.Seals { a.mu.Lock() if c, ok := a.applied[module]; ok && c.Err == nil && c.Dig == composed { diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index a6e7b2f..9887661 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -1340,6 +1340,15 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( want("web", fp, "") eventually(t, "the node's own composition was not applied", func() bool { return len(b.appliedList()) == 1 }) eventually(t, "the have does not name the composed digest", func() bool { return b.haves.last("web").Composed == dig }) + // ADR 0601 §4: what the composer keeps of web as it runs is carried on its have, and goes once it keeps none. + b.ag.KeptNow(context.Background(), map[string][]string{"web": {"web runs the tool runner's process here"}}) + if h := b.haves.last("web"); len(h.Kept) != 1 || h.Composed != dig { + t.Errorf("web's have does not carry what is kept as it runs: %+v", h) + } + b.ag.KeptNow(context.Background(), nil) + if h := b.haves.last("web"); len(h.Kept) != 0 || h.Composed != dig { + t.Errorf("web's have still carries what was kept: %+v", h) + } // The same composition handed again is no move. b.comps.put(ck, craw) time.Sleep(50 * time.Millisecond) diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 244d26a..ce704e6 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -156,7 +156,7 @@ type Scope struct { SameValue func(a, b string) (same, known bool) // Kept is told, in words, each module composed as what runs shows because its manifest does not yet say it (the tool // runner, the core's credentials; ADR 0601 §4), so the node says it. Nil: said nowhere. - Kept func(words string) + Kept func(module, words string) } // InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs @@ -235,9 +235,9 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module in, err := b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted) if err == nil { // What runs is kept while a manifest does not yet say what shared code no longer infers (ADR 0601 §4). - for _, words := range keepWhatRuns(&in, s.Node, s.Running) { + for _, k := range keepWhatRuns(&in, s.Node, s.Running) { if s.Kept != nil { - s.Kept(words) + s.Kept(k.Module, k.Words) } } } diff --git a/internal/compose/frombus/frombus_test.go b/internal/compose/frombus/frombus_test.go index abcb742..7e030c1 100644 --- a/internal/compose/frombus/frombus_test.go +++ b/internal/compose/frombus/frombus_test.go @@ -887,7 +887,7 @@ func whatRunsIsKeptWhileAManifestDoesNotSayIt(t *testing.T) { in := input(false) said := keepWhatRuns(&in, "a", running) if !in.Catalogue["runner-x"].ToolRunner || !in.Catalogue["prov-y"].CoreCredentials || in.Catalogue["app"].ToolRunner || - in.Catalogue["app"].CoreCredentials || len(said) != 2 { + in.Catalogue["app"].CoreCredentials || len(said) != 2 || said[0].Module != "prov-y" || said[1].Module != "runner-x" { t.Errorf("what runs was not kept: %+v, said %q", in.Catalogue, said) } in = input(true) diff --git a/internal/compose/frombus/keep.go b/internal/compose/frombus/keep.go index c766ec8..08f11fa 100644 --- a/internal/compose/frombus/keep.go +++ b/internal/compose/frombus/keep.go @@ -23,9 +23,9 @@ import ( // Known by what runs, never by a module's name. // keepWhatRuns marks, in in, each module placed on node that what runs shows is the tool runner or carries the core's -// credentials, where its manifest does not say so; it returns, sorted, what it marked, in words. The tool runner is marked -// only where no placed module's manifest says it is one. -func keepWhatRuns(in *compose.Input, node string, running []delivery.Composed) []string { +// credentials, where its manifest does not say so; it returns, sorted by module, what it marked, in words. The tool runner is +// marked only where no placed module's manifest says it is one. +func keepWhatRuns(in *compose.Input, node string, running []delivery.Composed) []KeptAsRuns { placed := assignedOn(*in, node) if len(placed) == 0 || len(running) == 0 { return nil @@ -60,7 +60,7 @@ func keepWhatRuns(in *compose.Input, node string, running []delivery.Composed) [ } } } - var said []string + var said []KeptAsRuns saysRunner := false for _, m := range placed { if man, ok := manifestOn(*in, node, m); ok && catalogue.IsToolRunner(man) { @@ -68,6 +68,7 @@ func keepWhatRuns(in *compose.Input, node string, running []delivery.Composed) [ } } mark := func(module string, set func(*catalogue.Manifest), words string) { + k := KeptAsRuns{Module: module, Words: words} for i := range in.Nodes { if in.Nodes[i].Name != node { continue @@ -75,14 +76,14 @@ func keepWhatRuns(in *compose.Input, node string, running []delivery.Composed) [ if man, ok := in.Nodes[i].Kept[module]; ok { set(&man) in.Nodes[i].Kept[module] = man - said = append(said, words) + said = append(said, k) return } } if man, ok := in.Catalogue[module]; ok { set(&man) in.Catalogue[module] = man - said = append(said, words) + said = append(said, k) } } if runner != "" && !saysRunner { @@ -95,6 +96,11 @@ func keepWhatRuns(in *compose.Input, node string, running []delivery.Composed) [ "and its manifest does not say they are the core's credentials (core-credentials): they stay sealed, until its manifest says so", m)) } } - sort.Strings(said) + sort.SliceStable(said, func(i, j int) bool { return said[i].Module < said[j].Module }) return said } + +// KeptAsRuns is one module composed as what runs shows, and why, in words (ADR 0601 §4): its have carries the words. +type KeptAsRuns struct { + Module, Words string +} diff --git a/internal/link/have.go b/internal/link/have.go index 7f94aa6..eb13fb0 100644 --- a/internal/link/have.go +++ b/internal/link/have.go @@ -374,6 +374,7 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) { f := s.modules[module] h := delivery.Have{V: delivery.Version, FP: f.FP, Composed: f.Composed, Build: f.Build, Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown, Detail: f.Detail} + h.Kept = f.Kept if delivery.Phase(f.Phase) == delivery.PhaseWaitingYes { h.Judgement, h.Waits, h.BuildAlone = delivery.Judgement(f.Judgement), f.Waits, f.BuildAlone } diff --git a/internal/link/messages.go b/internal/link/messages.go index 05ec57a..dd65cb3 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -641,6 +641,8 @@ type ModuleFact struct { Waits string // BuildAlone says, in phase waiting-yes, the move changes the build alone (ADR 0601 §1, delivery.Have.BuildAlone). BuildAlone bool + // Kept is what the node keeps of the module as it runs, for a manifest that does not say it yet (delivery.Have.Kept). + Kept []string // Sealed is the values the composition it names carries sealed to this node's key, by slot (delivery.Have.SealedAt). Sealed delivery.Seals } diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 8726b82..a8f81a0 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -78,6 +78,9 @@ type Env struct { // only within its own module. // An error is a have the bus did not answer for, never one absent: the pass is then tried again (BusFailed). Held func(module string) (slots delivery.Seals, legacy []string, err error) + // Kept is told, after each pass that composed, what the node keeps of each module as it runs because its manifest does + // not yet say it (ADR 0601 §4), by module, so each module's have carries it (agent.KeptNow). Nil tells nobody. + Kept func(kept map[string][]string) // Fetch reads an archive at a URL in the artifact store: a moved module's templates archive, whose texts its built // manifest carries (issue 1126). Read once per digest. Nil reads none, and a moved module does not compose. Fetch func(ctx context.Context, url string) ([]byte, error) @@ -844,7 +847,18 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { } // Its own certificate, asked of the vault when a module here asks for one and the record holds none it trusts. c.certifyIfWanted(ctx, r, own) - scope := frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn, Running: running, Kept: c.sayOnceEach} + // What it keeps of a module as it runs, for a manifest that does not say it yet (ADR 0601 §4): said, and carried on the + // module's have, from which the watchman raises its condition. + keptRuns := map[string][]string{} + var keptMu sync.Mutex + scope := frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn, Running: running, Kept: func(module, words string) { + keptMu.Lock() + defer keptMu.Unlock() + if !slices.Contains(keptRuns[module], words) { + keptRuns[module] = append(keptRuns[module], words) + } + c.sayOnceEach(words) + }} c.mu.Lock() c.dataScope = frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn} // the data items are worked out as composed c.mu.Unlock() @@ -906,6 +920,11 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { done := c.waiting("the agent marking each held module's have") c.refused("", held) done() + if c.env.Kept != nil { + keptMu.Lock() + c.env.Kept(keptRuns) + keptMu.Unlock() + } if len(held) == 0 { c.sayOnce("") } else { -- 2.54.0 From 2239fb253d5225b38209e1b1f929f1e00806a047 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 14:02:01 +0100 Subject: [PATCH 13/20] =?UTF-8?q?Rows=20for=20the=20composer's=20compositi?= =?UTF-8?q?on=20of=20today:=20the=20same=20inputs=20give=20the=20running?= =?UTF-8?q?=20digest,=20a=20changed=20input=20another,=20a=20build=20it=20?= =?UTF-8?q?cannot=20compose=20none=20(ADR=200601=20=C2=A71;=20review=20rou?= =?UTF-8?q?nd=201,=20finding=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/selfcompose/selfcompose_test.go | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index a9e663a..170a0b3 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -268,6 +268,31 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { b.mu.Unlock() t.Fatalf("handed %v, want %s and %s", got, lemursKey, nodeKey) } + // ADR 0601 §1: the module composed today at the build it runs, as the agent compares it with what runs. The same inputs give + // the digest handed; a changed input (here its manifest's service, as a changed setting would change a file) another; a + // build that cannot be composed (no done build or manifest of it: held) gives none. + c.mu.Lock() + handedDig := c.handed[lemursKey] + c.mu.Unlock() + if dig, ok := c.ComposedToday(ctx, "lemurs", fp); !ok || dig != handedDig { + t.Errorf("composed today at the build it runs: %s %v, want %s", dig, ok, handedDig) + } + if dig, ok := c.ComposedToday(ctx, "lemurs", delivery.SourcePrefix2+strings.Repeat("9", 64)); ok { + t.Errorf("a build that cannot be composed was composed today: %s", dig) + } + chgJSON := strings.Replace(lemurs, `"state":"running"`, `"state":"stopped"`, 1) + chgDigest := delivery.ManifestDigest([]byte(chgJSON)) + chgMan, _ := delivery.EncodeManifest(delivery.Manifest{Module: "lemurs", JSON: chgJSON}) + chgKey, _ := delivery.ManifestsKey(chgDigest) + b.mu.Lock() + b.kv[delivery.ManifestsBucket+"/"+chgKey] = chgMan + b.mu.Unlock() + chgMod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: chgDigest, Repository: "novox/x", Commit: "c"}) + c.takeModule(agent.Update{Key: "lemurs", Value: chgMod}) + if dig, ok := c.ComposedToday(ctx, "lemurs", fp); !ok || dig == handedDig { + t.Errorf("composed today with a changed input: %s %v, want another digest than %s", dig, ok, handedDig) + } + c.takeModule(agent.Update{Key: "lemurs", Value: mod}) // Issue 1126, the switch made safe: a bus not answering for a moment (a reload of its accounts) is tried again and // refuses nothing; a pass whose run ended refuses nothing either. -- 2.54.0 From b47668421fbb3297b841865e7d12e59e661b8795 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 14:02:01 +0100 Subject: [PATCH 14/20] =?UTF-8?q?The=20witness=20judges=20the=20tool=20run?= =?UTF-8?q?ner's=20process=20by=20its=20id,=20.runtime,=20wh?= =?UTF-8?q?ich=20the=20composition=20gives=20only=20the=20module=20whose?= =?UTF-8?q?=20manifest=20says=20it=20is=20the=20tool=20runner,=20never=20b?= =?UTF-8?q?y=20a=20name=20(ADR=200601=20=C2=A74;=20review=20round=201,=20f?= =?UTF-8?q?inding=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/apply/process.go | 2 +- internal/witness/contract.go | 17 ++++++++--------- internal/witness/contract_test.go | 11 +++++++---- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/internal/apply/process.go b/internal/apply/process.go index 80c386e..121b683 100644 --- a/internal/apply/process.go +++ b/internal/apply/process.go @@ -536,7 +536,7 @@ func witnessOf(r *declaration.Process) string { if r.Witness != "" { return r.Witness } - return witness.Default(r.Name) + return witness.Default(r.ID, r.Name) } // digestWritten is the bundle digest a process's record says was placed: the first half of what it diff --git a/internal/witness/contract.go b/internal/witness/contract.go index 35e8b0b..9c1c6e6 100644 --- a/internal/witness/contract.go +++ b/internal/witness/contract.go @@ -9,6 +9,7 @@ package witness import ( "time" + "github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/link" ) @@ -33,15 +34,13 @@ const ( GiveUp = 30 * time.Minute ) -// NodeToolsService is the runtime's name on the services protocol, and its instance is this -// machine's node name: what `$SRV.PING..` asks, so only this machine's runtime can -// answer (mesh-tools node-tools internal/runtime, announce.Service{Name: module, ID: node}). -const NodeToolsService = "node-tools" - -// Default is the witness a process is judged by when it names none: the core process the mesh composes on every -// machine, by the name it composes it under, and nothing else. -func Default(process string) string { - if process == NodeToolsService { +// Default is the witness a process is judged by when it names none: the tool runner's process, the core process the mesh +// composes on every machine for the module whose manifest says it is the tool runner (catalogue.IsToolRunner), with the id +// `.runtime` and named as its module (resolution's runtime process), and nothing else. It is pinged by that +// name on this machine's instance, `$SRV.PING..`, so only this machine's runtime answers; never known here by +// a module's name (ADR 0601 §4). +func Default(id, process string) string { + if process != "" && id == process+"."+resolution.RuntimeProcessID() { return ByPing } return ByNone diff --git a/internal/witness/contract_test.go b/internal/witness/contract_test.go index 2ea5c5c..6677fe9 100644 --- a/internal/witness/contract_test.go +++ b/internal/witness/contract_test.go @@ -13,7 +13,7 @@ import ( // The runtime is asked by its own name and this machine's: only this machine's runtime can answer. func TestTheRuntimeIsAskedByItsNameAndThisMachines(t *testing.T) { - if got := link.PingSubject(NodeToolsService, "anchor"); got != "$SRV.PING.node-tools.anchor" { + if got := link.PingSubject("runner-x", "anchor"); got != "$SRV.PING.runner-x.anchor" { t.Fatalf("the host asks %s; the runtime answers $SRV.PING.node-tools.", got) } } @@ -21,9 +21,12 @@ func TestTheRuntimeIsAskedByItsNameAndThisMachines(t *testing.T) { // What is judged by default is the mesh's core process on every machine, by the name it is composed under, and // nothing else: the controller ended (issue 1126), so its name is judged as any other. func TestOnlyTheCoreProcessIsJudgedByDefault(t *testing.T) { - for name, want := range map[string]string{"mesh-controller": ByNone, "node-tools": ByPing, "greeter": ByNone} { - if got := Default(name); got != want { - t.Errorf("%s is judged by %q, want %q", name, got, want) + // The tool runner's process by its id and name, whatever its module is called (ADR 0601 §4); a process named so by + // another id, or another process, is not. + for id, want := range map[string]string{"runner-x.runtime": ByPing, "node-tools.runtime": ByNone, "runner-x.server": ByNone, + "mesh-controller.runtime": ByNone} { + if got := Default(id, "runner-x"); got != want { + t.Errorf("%s is judged by %q, want %q", id, got, want) } } if NodeToolsWithin != 60*time.Second || PingWithin != 5*time.Second { -- 2.54.0 From 72bcf2087054f7329174b252c81ed89b204653fa Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 14:02:01 +0100 Subject: [PATCH 15/20] The bus module's integration test publishes the backup's end at once: the node agent hears it before it asks, so no sleep (review round 1, finding 5) --- cmd/mesh-host/nodeagent_integration_test.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index ba61810..cecee45 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -807,11 +807,9 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test backupsMu.Lock() backups = append(backups, string(got)) backupsMu.Unlock() + // The node agent hears the backup's end before it asks (backupNode), so the event follows the answer at once. _ = m.Respond([]byte(`{"result":{"started":true}}`)) - go func() { - time.Sleep(20 * time.Millisecond) - _ = b.conn.Publish(backedUpEvent, backupEvent(itNode, "nats", true, time.Now(), "")) - }() + _ = b.conn.Publish(backedUpEvent, backupEvent(itNode, "nats", true, time.Now(), "")) }); err != nil { t.Fatal(err) } -- 2.54.0 From 98719a1bd5a7838f81f8ed9830cb994e78f91a0b Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 14:05:07 +0100 Subject: [PATCH 16/20] Pin mesh-sdk 825483043549, the group's head of feat/l3b-bus (review round 1) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 04c2aed..6b2a390 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103121808-09ec82cb3551 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103125438-825483043549 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index 271c794..9f1230b 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103121808-09ec82cb3551 h1:DAxLU3IjZ9Kof8F3kVnINcauLqzCdQ1smwMpzX8yi7I= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103121808-09ec82cb3551/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103125438-825483043549 h1:YihsvVnQ6LQwsuUx7if6Gp5rEB7hmx2kcdzZzhWqiVw= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103125438-825483043549/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= -- 2.54.0 From c777fbae8f3d117b6c5001d9828743a2e1785fd4 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 14:12:50 +0100 Subject: [PATCH 17/20] Pin mesh-sdk e7ce86ff62b5, the group's head of feat/l3b-bus (review round 2) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 6b2a390..3cb48cc 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103125438-825483043549 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103131008-e7ce86ff62b5 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index 9f1230b..310e531 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103125438-825483043549 h1:YihsvVnQ6LQwsuUx7if6Gp5rEB7hmx2kcdzZzhWqiVw= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103125438-825483043549/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103131008-e7ce86ff62b5 h1:snMd7KsMT+tKlWYtrXSXrHF4u60DElvHmbe/aq58DqU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103131008-e7ce86ff62b5/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= -- 2.54.0 From 53b0e8d318a38bbc5ae65434c522fb5cb80eaa29 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 16:17:27 +0100 Subject: [PATCH 18/20] Genesis installs no catalog module: the catalogue step and the take of mesh-catalog go (ADR 0604 section 7, issue 774) --- internal/bootstrap/adopted.go | 2 +- internal/bootstrap/bootstrap.go | 9 +-------- internal/bootstrap/genesis_toolchains_test.go | 14 ++++++++++++-- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/internal/bootstrap/adopted.go b/internal/bootstrap/adopted.go index fdb5583..514ddd5 100644 --- a/internal/bootstrap/adopted.go +++ b/internal/bootstrap/adopted.go @@ -185,7 +185,7 @@ func (r Rewritten) declares(id string) bool { // container runtime's configuration whole — and neither is anything the operator installs later. var genesisTakes = map[string]bool{ RegistryModule: true, ControlPlaneModule: true, BuilderModule: true, - "postgres": true, BusModule: true, "mesh-vault": true, "mesh-catalog": true, + "postgres": true, BusModule: true, "mesh-vault": true, } // takeIfAdopted takes one of genesis's own modules on an adopted node, once it is assigned and diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index dbb2e08..425c9a4 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -64,7 +64,6 @@ const ( StepStore Step = "store" StepBroker Step = "broker" StepVault Step = "vault" - StepCatalogue Step = "catalogue" StepNetwork Step = "network" StepFilter Step = "filter" StepExtras Step = "extras" @@ -89,7 +88,7 @@ var Steps = []Step{ // Phase two. The twelve above make a mesh that RUNS; these make one that WORKS — able to // build, to say what it holds, on its network, filtering. They used to be things somebody // typed afterwards, which is how they went missing without anything complaining. - StepBase, StepStore, StepBroker, StepVault, StepCatalogue, StepNetwork, StepFilter, StepExtras, StepExport, + StepBase, StepStore, StepBroker, StepVault, StepNetwork, StepFilter, StepExtras, StepExport, } // Error is a failure, named by the step it happened in. @@ -750,12 +749,6 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro return result, failed(StepVault, err) } - // ---- 15. catalogue -------------------------------------------------------------------- - say("catalogue — the module graph: what is held, what a change reaches, what to rebuild") - if err := InstallFromCatalogue(ctx, o, permanentControl, "mesh-catalog", say); err != nil { - return result, failed(StepCatalogue, err) - } - // ---- 16. network ---------------------------------------------------------------------- say("network — the private network, and this machine's name on it") if err := PlaceOnTheNetwork(ctx, o, permanentControl, rewritten.BrokerAddress, say); err != nil { diff --git a/internal/bootstrap/genesis_toolchains_test.go b/internal/bootstrap/genesis_toolchains_test.go index cfae225..f1a81e3 100644 --- a/internal/bootstrap/genesis_toolchains_test.go +++ b/internal/bootstrap/genesis_toolchains_test.go @@ -13,14 +13,24 @@ import ( // stopped genesis at step 17: "this mesh holds no copy of it. Build mesh-tools-go first". // genesisModules is every catalogue module genesis installs on its own, read from the steps that -// install them: the registry, the build agent, the store, the bus, the vault, the catalogue and the +// install them: the registry, the build agent, the store, the bus, the vault and the // filter (extras are the operator's choice and come after the base too, but are not genesis's own). -var genesisModules = []string{RegistryModule, BuilderModule, "postgres", BusModule, "mesh-vault", "mesh-catalog", "nftables"} +var genesisModules = []string{RegistryModule, BuilderModule, "postgres", BusModule, "mesh-vault", "nftables"} // Every genesis module's bundles are compiled in a toolchain the base step builds, read from the real // catalogue's manifests: a module that gains a language genesis does not build fails here, not at a // genesis. func TestTheBaseStepBuildsEveryToolchainAGenesisModuleIsCompiledIn(t *testing.T) { + // ADR 0604 section 7: the catalog is the indexer's seat now, so genesis has no catalogue step and installs or takes no + // mesh-catalog module. + for _, step := range Steps { + if step == "catalogue" { + t.Fatal("genesis still has a catalogue step") + } + } + if genesisTakes["mesh-catalog"] { + t.Fatal("an adopted genesis still takes mesh-catalog") + } built := map[string]bool{} for _, tc := range genesisToolchains { built[tc.Language] = true -- 2.54.0 From 02c7f8e052fb010544fdc9ea56df276cdf45456e Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 16:24:55 +0100 Subject: [PATCH 19/20] Pin mesh-sdk 65b7adc8bdd9; the seat table here takes the SDK's mesh-catalog entry with its four verbs (ADR 0604 section 1) --- go.mod | 2 +- go.sum | 4 ++-- internal/compose/resolution/seats.go | 12 +++++++++++- internal/compose/resolution/seats_test.go | 20 ++++++++++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 3cb48cc..410da9b 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103131008-e7ce86ff62b5 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103152334-65b7adc8bdd9 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index 310e531..c1f0628 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103131008-e7ce86ff62b5 h1:snMd7KsMT+tKlWYtrXSXrHF4u60DElvHmbe/aq58DqU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103131008-e7ce86ff62b5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103152334-65b7adc8bdd9 h1:XJ7L9ZZ26URj0WxT467I9xZXijKDlQ5ZfE9NK/SnV0Y= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103152334-65b7adc8bdd9/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/internal/compose/resolution/seats.go b/internal/compose/resolution/seats.go index 85b5015..5e1beb8 100644 --- a/internal/compose/resolution/seats.go +++ b/internal/compose/resolution/seats.go @@ -193,7 +193,8 @@ var defaultSeats = append([]mcat.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: mcat.ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"}, - {Name: "mesh-catalog", Scope: mcat.ScopeMesh, Decision: "novox/hq ADR 0121"}, + // The catalog (novox/hq ADR 0604): the SDK's entry with its four verbs, so the copies of the table agree. + sdkSeat(mcat.CatalogSeat), // 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. @@ -649,3 +650,12 @@ func vaultIntake(first map[string]string) map[string]string { } return out } + +// sdkSeat is the SDK's entry for a seat, taken whole, so this copy of the table cannot disagree with it. +func sdkSeat(name string) mcat.Seat { + seat, ok := mcat.SeatNamed(name) + if !ok { + panic("the SDK's seat table holds no seat " + name) + } + return seat +} diff --git a/internal/compose/resolution/seats_test.go b/internal/compose/resolution/seats_test.go index 8c3cdd5..bcbac2c 100644 --- a/internal/compose/resolution/seats_test.go +++ b/internal/compose/resolution/seats_test.go @@ -1,6 +1,7 @@ package resolution import ( + "reflect" "strings" "testing" @@ -69,4 +70,23 @@ func TestTheHolderIsTheModuleNotTheMachine(t *testing.T) { if !held || holder.Module != "gitea" { t.Fatalf("the holder was not told apart from a neighbour: %+v", holder) } + + // ADR 0604 section 1, F3: the seat table written here answers as the SDK's does for the catalog seat, its four + // verbs included, so no two copies of the table disagree. + want, ok := mcat.SeatNamed(mcat.CatalogSeat) + if !ok { + t.Fatal("the SDK holds no catalog seat") + } + var have *mcat.Seat + for i := range defaultSeats { + if defaultSeats[i].Name == mcat.CatalogSeat { + have = &defaultSeats[i] + } + } + if have == nil { + t.Fatal("the table here holds no catalog seat") + } + if !reflect.DeepEqual(*have, want) { + t.Errorf("the table here has %+v, the SDK's is %+v", *have, want) + } } -- 2.54.0 From a24a1d72ad45a7a2484f109aa988edc4d72d8a56 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 17:02:14 +0100 Subject: [PATCH 20/20] Pin mesh-sdk cf5812101b0b, the group's final head: the module graph is no part of mesh-sdk (ADR 0603, ADR 0604, issue 1367) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 410da9b..d8584bc 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103152334-65b7adc8bdd9 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103155154-cf5812101b0b github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index c1f0628..764e281 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103152334-65b7adc8bdd9 h1:XJ7L9ZZ26URj0WxT467I9xZXijKDlQ5ZfE9NK/SnV0Y= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103152334-65b7adc8bdd9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103155154-cf5812101b0b h1:UPkJqQj+1y6WraOxDidkrokNjA25jxmCk+zIRkujjMc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103155154-cf5812101b0b/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= -- 2.54.0