From 7b8f5e7805ec59464c873a7049501e5b648a3c20 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 09:38:21 +0100 Subject: [PATCH 01/24] =?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/24] 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/24] =?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/24] 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/24] 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/24] 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/24] =?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/24] =?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/24] =?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/24] =?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/24] 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/24] =?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/24] =?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/24] =?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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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 From a0ce37fb2c16598cfceeb71cf661e0ee80e65ba2 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 18:50:12 +0100 Subject: [PATCH 21/24] Pin mesh-sdk b7abe203a929, the group's head of feat/l3b-bus: it keeps, deprecated, the exported names the running parts still use Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d8584bc..aec42d1 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.20261103155154-cf5812101b0b + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index 764e281..abad296 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -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= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= -- 2.54.0 From 3544d644ba015acd9ebdccde0dd8c2b259c5ea91 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:27:52 +0100 Subject: [PATCH 22/24] =?UTF-8?q?Pin=20mesh-sdk=2085ba0a9a70d8,=20the=20he?= =?UTF-8?q?ad=20of=20feat/l3-final:=20the=20contract=20states=20the=20read?= =?UTF-8?q?er=20capabilities=20a=20node-engine=20reads=20and=20the=20node?= =?UTF-8?q?=20record=20carries=20them=20(ADR=200614=20=C2=A74,=20issue=207?= =?UTF-8?q?74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index aec42d1..c3e5c35 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.20261103174208-b7abe203a929 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 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 abad296..7679a5d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/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 32a99144f65e27c13b7db4fdb6d78b4fecd21f50 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:29:17 +0100 Subject: [PATCH 23/24] =?UTF-8?q?The=20node=20record=20states=20the=20read?= =?UTF-8?q?er=20capabilities=20of=20the=20mesh-sdk=20the=20engine=20is=20b?= =?UTF-8?q?uilt=20with=20(ADR=200614=20=C2=A74,=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recordOf writes catalogue.ManifestReads in NodeRecord.ManifestReads, once per build, so the assigner moves a want only to an engine that reads its manifest. A row holds the engine's own manifest to the baseline's words, so its new build is never held for a word only it reads. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- internal/link/have_test.go | 3 ++- internal/link/record.go | 9 ++++++++- packaging/operator_keys_test.go | 7 +++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/internal/link/have_test.go b/internal/link/have_test.go index f092ed2..2fc4c41 100644 --- a/internal/link/have_test.go +++ b/internal/link/have_test.go @@ -270,7 +270,8 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) { } wantRecord := delivery.NodeRecord{V: delivery.Version, Node: "n1", TunnelKey: "tk", SealingKey: "sk", ServingKey: "pk", Certificate: "cert", Trusts: keys.Trusts, Capabilities: []string{"apt", "systemd"}, OutwardLinks: []string{"eth0"}, NetworksSaid: true, - Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, Contract: NodeKeyContract} + Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, Contract: NodeKeyContract, + ManifestReads: catalogue.ManifestReads()} // what it reads of a manifest: its contract's (ADR 0614 §4) if !delivery.SameNodeRecord(*got.Record, wantRecord) { t.Errorf("record = %+v, want %+v", *got.Record, wantRecord) } diff --git a/internal/link/record.go b/internal/link/record.go index f176bab..df204af 100644 --- a/internal/link/record.go +++ b/internal/link/record.go @@ -3,6 +3,7 @@ package link import ( "encoding/json" "sort" + "sync" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -27,6 +28,11 @@ type RecordKeys struct { Trusts []string } +// manifestReads is what this engine reads of a manifest (novox/hq ADR 0614 §4): the reader capabilities of the mesh-sdk it +// is built with, stated in its record so the assigner moves a want only to an engine that reads its manifest. Computed +// once: it is the build's, and never changes while it runs. +var manifestReads = sync.OnceValue(catalogue.ManifestReads) + // recordOf is the node's record from one facts report and its keys; nil for a report that carries no profile (one that // says nothing of the machine, such as a rekey), so the record last written stands. func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord { @@ -47,7 +53,8 @@ func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord { return nil } rec := &delivery.NodeRecord{V: delivery.Version, Node: node, TunnelKey: keys.TunnelKey, SealingKey: keys.SealingKey, - ServingKey: keys.ServingKey, Certificate: keys.Certificate, Authority: keys.Authority, Trusts: keys.Trusts} + ServingKey: keys.ServingKey, Certificate: keys.Certificate, Authority: keys.Authority, Trusts: keys.Trusts, + ManifestReads: manifestReads()} for _, c := range p.Capabilities { if c.Present && c.Name != "" { rec.Capabilities = append(rec.Capabilities, c.Name) diff --git a/packaging/operator_keys_test.go b/packaging/operator_keys_test.go index b86f6c4..5c77c08 100644 --- a/packaging/operator_keys_test.go +++ b/packaging/operator_keys_test.go @@ -75,6 +75,13 @@ func TestTheManifestConsumesTheBackupEvent(t *testing.T) { if err != nil { t.Fatal(err) } + // ADR 0614 §4: the node-engine's own manifest uses only words every running engine reads (the baseline, what an engine + // stating none is read as), so its new build is never held for a word only it reads. + if needs, err := catalogue.ManifestNeeds(raw); err != nil || len(needs) == 0 { + t.Fatalf("the manifest's needs: %v, %v", needs, err) + } else if missing := catalogue.MissingManifestReads(needs, nil); len(missing) != 0 { + t.Errorf("the manifest needs %v, which an engine stating nothing does not read: its new build would be held", missing) + } // 774 (the alert table): the state health, one key per node, the node's own health report, kept until // written again (no TTL: a node that stops writing keeps its last word, which the watchman reads with its time). // ADR 0577 clause 1: and the state data, one key per module placed on the node that declares data items, kept until -- 2.54.0 From 30b86ea602a8f157bcd05aad745c44f32ef7897c Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 00:45:35 +0100 Subject: [PATCH 24/24] An input carries the modules it holds as their stubs across the bus, and the sandbox names why the node-engine holds one (issue 774, group check 278aa5f0ccbe) The stub's mark (Manifest.Stubbed) is no field of a manifest's JSON, so an input a node-engine sent the check's sandbox lost it: node-tools, held by its manifest's verdict, read as a tool runner with no bundle and refused every module of ace, g14 and shanks for "its build produced 0 bundle(s)". compose.Input now writes its held modules (catalogue and each node's kept) beside its fields and marks them again on reading; an input from an engine before the mark is read by its contract, every module Refused names held as its stub. The sandbox composer also says, in the module's read, why the input's node-engine holds a module the case carries, so the check names the cause, not a rule about the stub. One subtest round-trips an input holding a refused tool runner, in both shapes; it refused for 0 bundles before. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- internal/compose/input_held.go | 97 ++++++++++++++++++++++++++++++ internal/composer/composer.go | 8 +++ internal/composer/composer_test.go | 41 +++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 internal/compose/input_held.go diff --git a/internal/compose/input_held.go b/internal/compose/input_held.go new file mode 100644 index 0000000..2d0befd --- /dev/null +++ b/internal/compose/input_held.go @@ -0,0 +1,97 @@ +package compose + +import ( + "encoding/json" + "sort" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// What an input says of its held modules on the wire (issue 774, the group check of feat/l3-final 278aa5f0ccbe). A held +// module stands in the input as its stub (Stub), and the stub's mark (catalogue.Manifest.Stubbed) is no field of a +// manifest's JSON: an input a node-engine sent the check's sandbox lost it, so the sandbox read node-tools, held by its +// manifest's verdict, as a tool runner with no bundle and refused every module of ace, g14 and shanks for "its build +// produced 0 bundle(s)" instead of naming the verdict. So the input carries the mark itself, by module, for its catalogue +// and for each node's kept manifests, and a reader marks each again. An input from a node-engine before the mark (no +// Held) is read by its contract: a module Refused names stands in Catalogue as its stub. + +// heldMarks are the modules an input holds as their stubs: those of its catalogue, and those each node keeps, by node. +type heldMarks struct { + Catalogue []string `json:",omitempty"` + Kept map[string][]string `json:",omitempty"` +} + +// inputFields is Input without its JSON methods. +type inputFields Input + +// inputWire is an input as it crosses the bus: its fields, and the modules it holds as their stubs. +type inputWire struct { + inputFields + Held *heldMarks `json:",omitempty"` +} + +// MarshalJSON writes the input with the modules it holds as their stubs named beside it (heldMarks). +func (in Input) MarshalJSON() ([]byte, error) { + w := inputWire{inputFields: inputFields(in)} + marks := heldMarks{Catalogue: stubbedOf(in.Catalogue)} + for _, n := range in.Nodes { + if kept := stubbedOf(n.Kept); len(kept) > 0 { + if marks.Kept == nil { + marks.Kept = map[string][]string{} + } + marks.Kept[n.Name] = kept + } + } + if len(marks.Catalogue) > 0 || len(marks.Kept) > 0 { + w.Held = &marks + } + return json.Marshal(w) +} + +// UnmarshalJSON reads an input and marks again each module it holds as its stub: those it names held, and, by the +// input's contract, every module Refused names that its catalogue holds. +func (in *Input) UnmarshalJSON(raw []byte) error { + var w inputWire + if err := json.Unmarshal(raw, &w); err != nil { + return err + } + *in = Input(w.inputFields) + held := map[string]bool{} + for m := range in.Refused { + held[m] = true + } + if w.Held != nil { + for _, m := range w.Held.Catalogue { + held[m] = true + } + } + for m := range held { + if man, ok := in.Catalogue[m]; ok { + man.Stubbed = true + in.Catalogue[m] = man + } + } + if w.Held != nil { + for i := range in.Nodes { + for _, m := range w.Held.Kept[in.Nodes[i].Name] { + if man, ok := in.Nodes[i].Kept[m]; ok { + man.Stubbed = true + in.Nodes[i].Kept[m] = man + } + } + } + } + return nil +} + +// stubbedOf is each module of manifests held as its stub, sorted. +func stubbedOf(manifests map[string]catalogue.Manifest) []string { + var out []string + for name, m := range manifests { + if m.Stubbed { + out = append(out, name) + } + } + sort.Strings(out) + return out +} diff --git a/internal/composer/composer.go b/internal/composer/composer.go index 61aad6c..b5a8898 100644 --- a/internal/composer/composer.go +++ b/internal/composer/composer.go @@ -150,6 +150,14 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) { for name, m := range base.Catalogue { shelf[name] = m } + // A module the input's node-engine holds as refused (compose.Input.Refused: its verdict, or bytes that do not decode) is + // held here as there, and its read says why, whatever the case's own verdicts: the check then names the cause the node + // holds it for, never a pass the node would not compose, nor a rule about its stub (issue 774, group check 278aa5f0ccbe). + for name, why := range base.Refused { + if read, carried := cv.Manifests[name]; carried && read.Accepted { + cv.Manifests[name] = ManifestRead{Refused: "its node-engine holds it: " + why} + } + } in.Catalogue = shelf in.Nodes = slices.Clone(in.Nodes) in.Holdings = slices.Clone(in.Holdings) diff --git a/internal/composer/composer_test.go b/internal/composer/composer_test.go index 73ecf12..18c5309 100644 --- a/internal/composer/composer_test.go +++ b/internal/composer/composer_test.go @@ -179,6 +179,47 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { t.Errorf("a module the node-engine holds too: %+v", m) } }) + // Issue 774 (the group check of feat/l3-final 278aa5f0ccbe): a tool runner its node-engine holds by its manifest's + // verdict is sent as its stub, and the input says it holds it: the sandbox composes the node with it held, never refuses + // it for its stub's missing bundle. An input from an engine before the mark is read by Refused alone, the same way. + t.Run("a refused tool runner the engine sends as its stub", func(t *testing.T) { + raw := `{"module": "runner", "version": "1", "tool-runner": true, ` + + `"build": {"artifacts": [{"name": "runtime", "kind": "bundle", "language": "go", "from": "cmd/runner", "binary": "runner"}]}}` + man, err := catalogue.Decode([]byte(raw)) + if err != nil { + t.Fatal(err) + } + in := compose.Input{Catalogue: map[string]catalogue.Manifest{"runner": compose.Stub(man)}, + Refused: map[string]string{"runner": "the indexer's judge refused the manifest of runner"}, + Nodes: []compose.Node{{Name: "a", Assigned: []string{"runner"}}}} + wire, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + var back compose.Input + if err := json.Unmarshal(wire, &back); err != nil || !back.Catalogue["runner"].Stubbed || !strings.Contains(string(wire), `"Held"`) { + t.Fatalf("the input's held mark across the wire: %s, %v", wire, err) + } + older := strings.Replace(string(wire), `"Held":{"Catalogue":["runner"]}`, `"Unsaid":null`, 1) + for name, sent := range map[string]string{"with the mark": string(wire), "from an engine before it": older} { + var r Request + r = Request{V: Version, Machines: []string{"a"}, Depth: DepthCompose, + Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"runner": {Raw: json.RawMessage(raw)}}}}} + if err := json.Unmarshal([]byte(sent), &r.Input); err != nil { + t.Fatal(err) + } + v, err := Run(r) + if err != nil { + t.Fatal(err) + } + if m := v.Cases[0].Machines["a"]; !m.Composes || strings.Contains(m.Reason, "bundle") { + t.Errorf("%s: the node with its tool runner held: %+v", name, m) + } + if read := v.Cases[0].Manifests["runner"]; read.Accepted || !strings.Contains(read.Refused, "the indexer's judge refused the manifest of runner") { + t.Errorf("%s: the runner's read names not why its node-engine holds it: %+v", name, read) + } + } + }) 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) -- 2.54.0