From f50e004e8d21b70e44a2dd1ee3ac03ec0ef4016a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 29 Oct 2026 01:30:50 +0100 Subject: [PATCH 1/3] A module composed as what runs of it serves its tools as they run, so the runtime no longer drops it (issue 1126) After the take-over of placement on 2026-10-29, shanks's runtime stopped serving the folded systemd-resolved (the node-resolver seat's verbs answer "nothing serves") and novox's the folded nats. Neither has a want: each runs inside its node's own composition at a build of the old form (Scope.InOwn), and is composed as Carried of the indexer's manifest. That manifest is the catalogue's, which no build resolved, so it has no bundles (catalogue/build.go: Bundles are set only by a build's resolution), and the runtime's MESH_TOOL_MODULES and MESH_TOOL_ENV are made from bundles (catalogue/runtime.go). The controller composed them from the resolved manifest its store kept. The kind: a module composed as what runs of it (Stub, Carried: held, or folded at an old build) contributes to the others from its manifest, not from what runs. For a held module with a new build it is the other way round: its new bundles are served, which were never delivered. - The quick fix: read the old-form build's resolved manifest for a folded module. One instance; a held module still hands its new bundles. - The thorough fix, chosen: compose.ToolsRunning reads what the node's runtime serves as it runs (its process's MESH_TOOL_MODULES and MESH_TOOL_ENV, each bundle's archive), frombus gives it as Node.RunsTools, and every stubbed module of the machine's runtime (held or carried) contributes those bundles, whatever its manifest says. Tests: two rows (folded, held) added to TestCompositionsAreTheDeclarationSplitByModule; no new test function. --- go/compose/composed.go | 89 +++++++++++++++++++++++++++++++++++ go/compose/composed_test.go | 75 +++++++++++++++++++++++++++++ go/compose/frombus/frombus.go | 2 + go/compose/input.go | 6 +++ 4 files changed, 172 insertions(+) diff --git a/go/compose/composed.go b/go/compose/composed.go index b5a7d7a..66fc29c 100644 --- a/go/compose/composed.go +++ b/go/compose/composed.go @@ -130,6 +130,22 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli n.Kept = kept } } + // A module composed as what runs of it contributes to the runtime what the runtime serves of it as it runs + // (Node.RunsTools); one of its own account is served by a runtime of its own, from its manifest as before. + for i := range reading.Nodes { + n := &reading.Nodes[i] + if n.Name != node || n.RunsTools == nil { + continue + } + kept := make(map[string]catalogue.Manifest, len(n.Kept)) + for m, man := range n.Kept { + if man.Stubbed && man.RunsAs == "" { + man.Bundles = n.RunsTools[m] + } + kept[m] = man + } + n.Kept = kept + } isCarried := func(module, id string) bool { if module == "" { module, _ = declaration.ModuleGuess(id) @@ -293,6 +309,79 @@ func RunsAnyOf(runs *delivery.Composed, module string) bool { return false } +// ToolsRunning is, by module, the tools bundles the node's runtime serves as it runs (Node.RunsTools): read from the +// runtime's process in running (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out) +// and each bundle's archive there; nil when running holds no runtime process. +func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle { + runtimeID := catalogue.RuntimeModule + "." + catalogue.RuntimeProcessID() + type resource struct { + ID string `json:"id"` + Type string `json:"type"` + Path string `json:"path"` + Source string `json:"source"` + Digest string `json:"digest"` + Env map[string]string `json:"env"` + } + var env map[string]string + archives := map[string]resource{} + for _, c := range running { + for _, raw := range c.Resources { + var r resource + if json.Unmarshal(raw, &r) != nil { + continue + } + switch { + case r.Type == "process" && r.ID == runtimeID: + env = r.Env + case r.Type == "archive" && strings.HasPrefix(r.Path, catalogue.BundleRoot+"/"): + archives[r.Path] = r + } + } + } + if env == nil { + return nil + } + var words map[string]map[string]string + _ = json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &words) + out := map[string][]catalogue.Bundle{} + for _, entry := range strings.Split(env[catalogue.RuntimeToolModules], ",") { + module, path, ok := strings.Cut(entry, "=") + if !ok { + continue + } + name, load, ok := strings.Cut(strings.TrimPrefix(path, catalogue.BundleRoot+"/"+module+"/"), "/") + if !ok || !strings.HasPrefix(path, catalogue.BundleRoot+"/"+module+"/") { + continue + } + var own map[string]string + for w, v := range words[module] { + if w == catalogue.FilesOwnedPaths || w == catalogue.ReadsSecretPlaces { + continue // the mesh's to say, composed again + } + if own == nil { + own = map[string]string{} + } + own[w] = v + } + bundles := out[module] + i := slices.IndexFunc(bundles, func(b catalogue.Bundle) bool { return b.Name == name }) + if i < 0 { + a, delivered := archives[catalogue.BundlePath(module, name)] + if !delivered { + continue // no archive of it runs: nothing to load + } + bundles = append(bundles, catalogue.Bundle{Name: name, Source: catalogue.Recorded(a.Source), Digest: a.Digest, Env: own}) + i = len(bundles) - 1 + } + bundles[i].Loads = append(bundles[i].Loads, load) + out[module] = bundles + } + for _, bundles := range out { + sort.Slice(bundles, func(i, j int) bool { return bundles[i].Name < bundles[j].Name }) + } + return out +} + // NamesBuild says a manifest names what a build makes: its build, or a resource naming an artifact. A node cannot // compose such a module folded into its own composition: the build of the old form it runs is held by the controller // alone (issue 1126). diff --git a/go/compose/composed_test.go b/go/compose/composed_test.go index 589f156..eb37442 100644 --- a/go/compose/composed_test.go +++ b/go/compose/composed_test.go @@ -8,6 +8,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/overlay" ) func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) } @@ -96,6 +97,80 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil { t.Errorf("a node the mesh does not have was composed") } + + // Issue 1126, 2026-10-29: a module composed as what runs of it (folded at a build of the old form, or held) still + // contributes to the runtime what the runtime serves of it as it runs (Node.RunsTools): its manifest at the + // 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", + 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"}}} + store := catalogue.Manifest{Module: "artifacts", Version: "1", + Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}} + lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"}, + Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}}, + Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle, + Language: "go", System: "arch", From: "cmd/lamp-tools"}}}} + newLamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle, + Reference: catalogue.ArtifactStoreScheme + "lamp/tools/blobs/" + dg('c'), Digest: dg('c')}}) + if err != nil { + t.Fatal(err) + } + served := "lamp=" + catalogue.BundlePath("lamp", "tools") + "/lamp-tools" + runsTools := []delivery.Composed{{Node: "anchor", Module: catalogue.RuntimeModule, Resources: []json.RawMessage{json.RawMessage( + `{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":"` + + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"}}`)}}} + lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') + + `","digest":"` + dg('b') + `","path":"` + catalogue.BundlePath("lamp", "tools") + `"}` + for _, row := range []struct { + name string + set func(n *Node) delivery.Composed // and what runs of lamp + fps map[string]string + }{ + {"folded at a build of the old form", func(n *Node) delivery.Composed { + n.InOwn = map[string]bool{"lamp": true} + n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}} + return *n.RunsOwn + }, map[string]string{}}, + {"held, its new build not delivered", func(n *Node) delivery.Composed { + n.Held = map[string]bool{"lamp": true} + lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage( + strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}} + n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)} + return lr + }, map[string]string{"lamp": fpFor('1')}}, + } { + tools := aMesh(t) + tools.Catalogue[catalogue.RuntimeModule], 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.SealingKey = keyOf(t, "anchor") + anchor.RunsTools = ToolsRunning(append(append([]delivery.Composed{}, runsTools...), row.set(anchor))) + row.fps[overlay.Name], row.fps["artifacts"], row.fps[catalogue.RuntimeModule] = fpFor('a'), fpFor('5'), fpFor('e') + out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing) + if err != nil { + t.Fatalf("%s: %v", row.name, err) + } + var env map[string]any + for _, c := range out { + for _, raw := range c.Resources { + var r struct { + ID string `json:"id"` + Env map[string]any `json:"env"` + } + if json.Unmarshal(raw, &r) == nil && r.ID == "node-tools.runtime" { + env = r.Env + } + } + } + if env[catalogue.RuntimeToolModules] != served || env[catalogue.RuntimeToolEnv] != `{"lamp":{"LAMP_CONF":"/var/lib/lamp/conf"}}` { + t.Errorf("%s: the runtime serves %v with %v, want what runs of lamp, %s", row.name, + env[catalogue.RuntimeToolModules], env[catalogue.RuntimeToolEnv], served) + } + } } // 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words. diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index d034a48..f2caf0d 100644 --- a/go/compose/frombus/frombus.go +++ b/go/compose/frombus/frombus.go @@ -313,6 +313,8 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module } } if node == s.Node { + // What its runtime serves as it runs: what a module composed as what runs of it contributes there (issue 1126). + n.RunsTools = compose.ToolsRunning(s.Running) for _, module := range s.InOwn { if n.InOwn == nil { n.InOwn = map[string]bool{} diff --git a/go/compose/input.go b/go/compose/input.go index 5b9cba0..dbd2900 100644 --- a/go/compose/input.go +++ b/go/compose/input.go @@ -210,6 +210,12 @@ type Node struct { // (RunsOwn), folded or leaving the fold, is carried there as it runs, whatever its manifest names, so the node's own // never drops it. Held map[string]bool + // RunsTools is, by module, the tools bundles the node's runtime serves of it as it runs (ToolsRunning of what the + // node runs); nil when not known. A module composed as what runs of it (its Stub or Carried: held, or folded at a + // build of the old form) contributes these to the runtime, never its manifest's: a manifest no build resolved has no + // bundles, and a held module's new build's bundles are not delivered (issue 1126, 2026-10-29: shanks's runtime lost + // the folded systemd-resolved, novox's the folded nats). + RunsTools map[string][]catalogue.Bundle } // Place is where a node is, as its placement says (inv.Overlay). -- 2.54.0 From b6768dad2568f601fcda13232aaa5157831b9a97 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 29 Oct 2026 01:36:17 +0100 Subject: [PATCH 2/3] A data flag that differs alone moves no data: the judgement reads the data items first, and says the flag loudly (issue 1126) novox's mongodb and mssql asked a yes for moving their data on a toolchain move: the running side, read back from the kept declaration, carries no data flag, and judge.go returned moves-data on the flag before it compared the data directories and files, so a flag mismatch both raised a false ask and hid a real move. Now moves-data only when a data item (a directory or file within the data places, or a process of a module with data) changes; a flag-only difference is neither, with a WARNING in its words. One row changed and one added in TestJudgeMove. --- go/delivery/judge.go | 17 +++++++++++++---- go/delivery/judge_test.go | 6 +++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/go/delivery/judge.go b/go/delivery/judge.go index f030401..1aef5da 100644 --- a/go/delivery/judge.go +++ b/go/delivery/judge.go @@ -3,6 +3,7 @@ package delivery import ( "bytes" "encoding/json" + "fmt" "path" "sort" "strings" @@ -84,9 +85,6 @@ func judged(from, to Composed) (Judgement, string) { if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 { return JudgedRecreates, "it recreates " + strings.Join(ids, ", ") } - if from.Data != to.Data { - return JudgedMovesData, "its data declaration changes" - } places := dataPlaces(before, after) held := func(kind string) bool { return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile) @@ -104,9 +102,20 @@ func judged(from, to Composed) (Judgement, string) { if to.Data || from.Data { moved = append(moved, changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeProcess) })...) } + // A data flag that differs is no move of data by itself: data moves only when a data item does (issue 1126, 2026-10-29: + // a running composition read back from the kept declaration carries no flag, and mongodb's toolchain move asked a yes for + // moving its data). It is said loudly in the words, and never hides an item that moved. if len(moved) > 0 { sort.Strings(moved) - return JudgedMovesData, "it changes where its data is kept: " + strings.Join(moved, ", ") + words := "it changes where its data is kept: " + strings.Join(moved, ", ") + if from.Data != to.Data { + words += fmt.Sprintf("; its data flag differs too (running %v, composed %v)", from.Data, to.Data) + } + return JudgedMovesData, words + } + if from.Data != to.Data { + return JudgedNeither, fmt.Sprintf("it recreates no container and moves no data; WARNING: its data flag differs "+ + "(running %v, composed %v) while no data item does, so one side's flag is wrong", from.Data, to.Data) } return JudgedNeither, "it recreates no container and moves no data" } diff --git a/go/delivery/judge_test.go b/go/delivery/judge_test.go index 4e61fd5..2ffa7c1 100644 --- a/go/delivery/judge_test.go +++ b/go/delivery/judge_test.go @@ -48,7 +48,8 @@ func TestJudgeMove(t *testing.T) { {"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false}, {"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false}, {"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false}, - {"its data declaration", mail('a', false, container, spool), mail('b', true, container, spool), JudgedMovesData, false}, + {"its data flag alone", mail('a', false, container, spool), mail('b', true, container, spool), JudgedNeither, false}, + {"its data flag and its spool", mail('a', false, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false}, {"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false}, {"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false}, {"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false}, @@ -61,6 +62,9 @@ func TestJudgeMove(t *testing.T) { if got.Judgement != c.want || got.UserListOnly != c.userList || got.Words == "" { t.Errorf("%s: %+v, want %s (user list only %v)", c.name, got, c.want, c.userList) } + if strings.Contains(c.name, "data flag alone") && !strings.Contains(got.Words, "WARNING: its data flag differs") { + t.Errorf("%s: the flag's difference is not said: %q", c.name, got.Words) + } } if got := JudgeMove(nil, Composed{Node: "ace"}); got.Judgement != JudgedNeither { t.Errorf("the node's own composition: %+v", got) -- 2.54.0 From 575cb82fba692865a147f29649948f72e30151b6 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 29 Oct 2026 01:37:47 +0100 Subject: [PATCH 3/3] A runtime that already dropped a carried module's tools serves them again, read from what runs and its build (issue 1126, review) rev-held2: ToolsRunning took a carried module's bundles from the runtime's MESH_TOOL_MODULES alone, and shanks had already applied the drop of systemd-resolved, so its runs stayed empty and the drop permanent. Now every bundle archive that runs at its module's BundlePath is a running bundle; one the runtime loads nothing of takes its loads and words from the module's build (its built bundles, else its build's artifacts, by catalogue.BundleLoads, the rule a build's resolution keeps, now one function). The runtime process and each archive are matched by their owning module too, not only by id and path. A row added: the runtime already dropped it. --- go/catalogue/build.go | 46 ++++++++++++++--------- go/compose/composed.go | 73 +++++++++++++++++++++++++------------ go/compose/composed_test.go | 31 +++++++++++----- 3 files changed, 100 insertions(+), 50 deletions(-) diff --git a/go/catalogue/build.go b/go/catalogue/build.go index 91e3460..0a2ff73 100644 --- a/go/catalogue/build.go +++ b/go/catalogue/build.go @@ -126,23 +126,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) { continue } made := by[a.Name] - // What the runtime loads: what the artifact said, else every entrypoint of a module - // that declares tools, else nothing (the field's own rule; see Artifact.Loads). - // - // **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213). - // A process the host runs is the module's service, not its tools: the controller declares - // the verbs it answers as `tools` and serves them itself, and its bundle would otherwise - // have been launched a second time by the node's runtime, as an MCP child it is not. - loads := append([]string(nil), a.Loads...) - if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] { - loads = append([]string(nil), a.Entrypoints...) - // A bundle compiled to a binary has no entrypoints: the binary is what it is, and what - // the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served - // as Go — the runtime execs it — exactly as a TypeScript one is through its launcher. - if bin := BinaryOf(a); bin != "" { - loads = []string{bin} - } - } + loads := BundleLoads(m, a, run) // **Kept, never routed** (ADR 0155): the builder publishes to the store at the address // it reached it by, and a manifest carrying that address names an installation — // registration refused node-tools for exactly this on 2026-10-02. The build record @@ -715,3 +699,31 @@ func undeliveredBundles(m Manifest) []string { } return problems } + +// BundleLoads is what the runtime loads of the bundle artifact a of m (run: the bundles one of m's own resources runs, +// runByAResource): what the artifact said, else every entrypoint of a module that declares tools, else nothing; a +// bundle compiled to a binary is served by its binary. The rule a build's resolution keeps on Bundle.Loads, and what a +// module composed as what runs of it reads again when the runtime already dropped it (issue 1126). +func BundleLoads(m Manifest, a Artifact, run map[string]bool) []string { + // What the runtime loads: what the artifact said, else every entrypoint of a module + // that declares tools, else nothing (the field's own rule; see Artifact.Loads). + // + // **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213). + // A process the host runs is the module's service, not its tools: the controller declares + // the verbs it answers as `tools` and serves them itself, and its bundle would otherwise + // have been launched a second time by the node's runtime, as an MCP child it is not. + loads := append([]string(nil), a.Loads...) + if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] { + loads = append([]string(nil), a.Entrypoints...) + // A bundle compiled to a binary has no entrypoints: the binary is what it is, and what + // the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served + // as Go — the runtime execs it — exactly as a TypeScript one is through its launcher. + if bin := BinaryOf(a); bin != "" { + loads = []string{bin} + } + } + return loads +} + +// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools). +func RunByAResource(m Manifest) map[string]bool { return runByAResource(m) } diff --git a/go/compose/composed.go b/go/compose/composed.go index 66fc29c..fd15ffe 100644 --- a/go/compose/composed.go +++ b/go/compose/composed.go @@ -140,7 +140,7 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli kept := make(map[string]catalogue.Manifest, len(n.Kept)) for m, man := range n.Kept { if man.Stubbed && man.RunsAs == "" { - man.Bundles = n.RunsTools[m] + man.Bundles = runningBundles(n.RunsTools[m], reading.Catalogue[m]) } kept[m] = man } @@ -309,13 +309,16 @@ func RunsAnyOf(runs *delivery.Composed, module string) bool { return false } -// ToolsRunning is, by module, the tools bundles the node's runtime serves as it runs (Node.RunsTools): read from the -// runtime's process in running (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out) -// and each bundle's archive there; nil when running holds no runtime process. +// ToolsRunning is, by module, the tools bundles that run on the node (Node.RunsTools): each bundle archive a module's +// resource unpacks at its BundlePath, with what the runtime's own process loads of it and the words it hands it +// (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads +// 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 + "." + catalogue.RuntimeProcessID() type resource struct { ID string `json:"id"` + Module string `json:"module"` Type string `json:"type"` Path string `json:"path"` Source string `json:"source"` @@ -323,18 +326,25 @@ func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle { Env map[string]string `json:"env"` } var env map[string]string - archives := map[string]resource{} + out := map[string][]catalogue.Bundle{} for _, c := range running { for _, raw := range c.Resources { var r resource if json.Unmarshal(raw, &r) != nil { continue } + owner := r.Module + if owner == "" { + owner, _ = declaration.ModuleGuess(r.ID) + } switch { - case r.Type == "process" && r.ID == runtimeID: + case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule: env = r.Env - case r.Type == "archive" && strings.HasPrefix(r.Path, catalogue.BundleRoot+"/"): - archives[r.Path] = r + case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+catalogue.BundleID("")): + name := strings.TrimPrefix(r.ID, owner+"."+catalogue.BundleID("")) + if r.Path == catalogue.BundlePath(owner, name) { + out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest}) + } } } } @@ -343,15 +353,16 @@ func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle { } var words map[string]map[string]string _ = json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &words) - out := map[string][]catalogue.Bundle{} for _, entry := range strings.Split(env[catalogue.RuntimeToolModules], ",") { module, path, ok := strings.Cut(entry, "=") - if !ok { + if !ok || !strings.HasPrefix(path, catalogue.BundleRoot+"/"+module+"/") { continue } name, load, ok := strings.Cut(strings.TrimPrefix(path, catalogue.BundleRoot+"/"+module+"/"), "/") - if !ok || !strings.HasPrefix(path, catalogue.BundleRoot+"/"+module+"/") { - continue + bundles := out[module] + i := slices.IndexFunc(bundles, func(b catalogue.Bundle) bool { return b.Name == name }) + if !ok || i < 0 { + continue // no archive of it runs: nothing to load } var own map[string]string for w, v := range words[module] { @@ -363,18 +374,8 @@ func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle { } own[w] = v } - bundles := out[module] - i := slices.IndexFunc(bundles, func(b catalogue.Bundle) bool { return b.Name == name }) - if i < 0 { - a, delivered := archives[catalogue.BundlePath(module, name)] - if !delivered { - continue // no archive of it runs: nothing to load - } - bundles = append(bundles, catalogue.Bundle{Name: name, Source: catalogue.Recorded(a.Source), Digest: a.Digest, Env: own}) - i = len(bundles) - 1 - } bundles[i].Loads = append(bundles[i].Loads, load) - out[module] = bundles + bundles[i].Env = own } for _, bundles := range out { sort.Slice(bundles, func(i, j int) bool { return bundles[i].Name < bundles[j].Name }) @@ -382,6 +383,32 @@ func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle { return out } +// runningBundles is the bundles of a module composed as what runs of it (Node.RunsTools): each that runs, with what the +// runtime loads of it and its words; one the runtime loads nothing of takes its loads and words from the module's build +// (man: its built bundles, else its build's artifacts, catalogue.BundleLoads), so a drop already applied is undone. +func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue.Bundle { + var out []catalogue.Bundle + for _, b := range runs { + if len(b.Loads) == 0 { + for _, built := range man.Bundles { + if built.Name == b.Name { + b.Loads, b.Env, b.Launchers, b.Binary = built.Loads, built.Env, built.Launchers, built.Binary + } + } + if man.Build != nil { + run := catalogue.RunByAResource(man) + for _, a := range man.Build.Artifacts { + if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name { + b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a) + } + } + } + } + out = append(out, b) + } + return out +} + // NamesBuild says a manifest names what a build makes: its build, or a resource naming an artifact. A node cannot // compose such a module folded into its own composition: the build of the old form it runs is held by the controller // alone (issue 1126). diff --git a/go/compose/composed_test.go b/go/compose/composed_test.go index eb37442..a96ea85 100644 --- a/go/compose/composed_test.go +++ b/go/compose/composed_test.go @@ -112,35 +112,46 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"}, Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}}, Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle, - Language: "go", System: "arch", From: "cmd/lamp-tools"}}}} + Language: "go", System: "arch", From: "cmd/lamp-tools", Env: map[string]string{"LAMP_CONF": "/var/lib/lamp/conf"}}}}} newLamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle, Reference: catalogue.ArtifactStoreScheme + "lamp/tools/blobs/" + dg('c'), Digest: dg('c')}}) if err != nil { t.Fatal(err) } served := "lamp=" + catalogue.BundlePath("lamp", "tools") + "/lamp-tools" - runsTools := []delivery.Composed{{Node: "anchor", Module: catalogue.RuntimeModule, Resources: []json.RawMessage{json.RawMessage( - `{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":"` + - served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"}}`)}}} + runtimeRuns := func(dropped bool) delivery.Composed { + env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"` + 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( + `{"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') + `","digest":"` + dg('b') + `","path":"` + catalogue.BundlePath("lamp", "tools") + `"}` for _, row := range []struct { - name string - set func(n *Node) delivery.Composed // and what runs of lamp - fps map[string]string + name string + set func(n *Node) delivery.Composed // and what runs of lamp + fps map[string]string + dropped bool }{ {"folded at a build of the old form", func(n *Node) delivery.Composed { n.InOwn = map[string]bool{"lamp": true} n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}} return *n.RunsOwn - }, map[string]string{}}, + }, map[string]string{}, false}, + {"folded, the runtime already dropped it", func(n *Node) delivery.Composed { + n.InOwn = map[string]bool{"lamp": true} + n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}} + return *n.RunsOwn + }, map[string]string{}, true}, {"held, its new build not delivered", func(n *Node) delivery.Composed { n.Held = map[string]bool{"lamp": true} lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage( strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}} n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)} return lr - }, map[string]string{"lamp": fpFor('1')}}, + }, map[string]string{"lamp": fpFor('1')}, false}, } { tools := aMesh(t) tools.Catalogue[catalogue.RuntimeModule], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp @@ -148,7 +159,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { anchor.Assigned = append(anchor.Assigned, "artifacts", catalogue.RuntimeModule, "lamp") tools.BusMinted = []string{"anchor." + catalogue.RuntimeModule} anchor.SealingKey = keyOf(t, "anchor") - anchor.RunsTools = ToolsRunning(append(append([]delivery.Composed{}, runsTools...), row.set(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') out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing) if err != nil { -- 2.54.0