From faa20055381c8f30c91862aa08a44b49bdb5d6e3 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 13:31:44 +0100 Subject: [PATCH] =?UTF-8?q?A=20node=20agent=20that=20meets=20a=20manifest?= =?UTF-8?q?=20which=20does=20not=20yet=20say=20it=20is=20the=20tool=20runn?= =?UTF-8?q?er,=20or=20that=20its=20own=20secrets=20are=20the=20core's=20cr?= =?UTF-8?q?edentials,=20keeps=20what=20runs=20of=20it,=20its=20tool=20runn?= =?UTF-8?q?er=20process=20and=20its=20sealed=20secrets=20alike,=20known=20?= =?UTF-8?q?by=20what=20runs=20and=20said;=20never=20composed=20without=20i?= =?UTF-8?q?t=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()