From 9685990a15ed4d37931a176f5aa7e97b5210ee6c Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 30 Oct 2026 19:45:32 +0100 Subject: [PATCH] Compose a module at the build its want names and hold a running one whose build is not done, so the runtime never loses its bundle (issue 774) At 19:30 on 2026-10-30 node-tools' trunk fingerprint moved to a build not yet done; fromBuilds then composed it from the indexer's manifest, which names no bundle, and ace, g14 and shanks refused every module ("its build produced 0 bundle(s)") until another input moved after the build landed. Quick fix: skip the runtime's check while its build is not done. Thorough fix: Compose reads each placed module at the build its want names (as Scope.KeptAt), and a module that runs whose build is not done is held, keeping what runs (a held runtime is composed as what runs of it, its running bundle), named with why; a module nothing runs of still composes from the indexer's manifest as before. Input, PlansHeld and Answer read the trunk's builds as before. Chose thorough: no running module is ever composed from a source manifest that names no build. --- go/compose/frombus/answer.go | 2 +- go/compose/frombus/frombus.go | 46 ++++++++++++++---- go/compose/frombus/frombus_test.go | 1 + go/compose/frombus/held_rows_test.go | 70 ++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 11 deletions(-) diff --git a/go/compose/frombus/answer.go b/go/compose/frombus/answer.go index 74f7337..dcdb2f2 100644 --- a/go/compose/frombus/answer.go +++ b/go/compose/frombus/answer.go @@ -41,7 +41,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st // Every module it cannot compose is named, not the first only (issue 1126): a build it cannot read or take in, and // below, a module the composition refuses, each composed as a stub so the rest is composed and compared. failed := map[string]string{} - in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() }) + in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() }, nil) if err != nil { return refuse(err) } diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index eabb180..315d20c 100644 --- a/go/compose/frombus/frombus.go +++ b/go/compose/frombus/frombus.go @@ -34,6 +34,7 @@ import ( "context" "encoding/json" "fmt" + "maps" "net/netip" "slices" "sort" @@ -177,7 +178,7 @@ func InOwn(running []delivery.Composed, p delivery.Placement) []string { // Input is the composition input for s, read through r. func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, error) { - return input(ctx, r, mesh, s, nil) + return input(ctx, r, mesh, s, nil, nil) } // input is Input; with refuse set, a module placed on the composing node whose build cannot be read or taken in, or whose @@ -185,7 +186,7 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er // rather than ending the read, so a node's answer names every module it cannot compose (issue 1126, issue 774); a // module placed elsewhere whose manifest the mesh refuses is read for its offer alone (unusable). A manifest the bus does // not answer for still ends the read: that is the bus's, not the module's. -func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error)) (compose.Input, error) { +func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) { placements, err := r.Placements(ctx) if err != nil { return compose.Input{}, fmt.Errorf("frombus: placement: %w", err) @@ -397,7 +398,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module if _, ok := records[s.Node]; !ok { return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node) } - if err := fromBuilds(ctx, r, &in, s, placements[s.Node], modules, read, parse, refuse); err != nil { + if err := fromBuilds(ctx, r, &in, s, placements[s.Node], modules, read, parse, refuse, wanted); err != nil { return compose.Input{}, err } // Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 ยง5). @@ -440,7 +441,7 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s err = fmt.Errorf("%w (held before: %s)", err, strings.Join(said, "; ")) } }() - in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }) + in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, fps) if err != nil { return nil, nil, held, err } @@ -478,7 +479,7 @@ func Plans(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string]catalo // err. The seat mesh-provisioner's holder composes so: a node with one bad manifest still has every other module's plan. func PlansHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (plans map[string]catalogue.Plan, held map[string]string, err error) { held = map[string]string{} - in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }) + in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, nil) if err != nil { return nil, held, err } @@ -618,7 +619,7 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed // (issue 1126). func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module, read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error), - refuse func(module string, err error)) error { + refuse func(module string, err error), wanted map[string]string) error { // stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides // and requires still shapes the rest. stub := func(module string, err error) { @@ -678,9 +679,27 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del } return resolved, nil } + // A module wanted at another build than the trunk's is composed from the build its want names (issue 774), as one + // the scope keeps at another build is. + keptAt := maps.Clone(s.KeptAt) + for _, pm := range p.Modules { + if m, ok := modules[pm.Module]; ok && m.FP2 != "" && wanted[pm.Module] != "" && wanted[pm.Module] != m.FP2 && + keptAt[pm.Module] == "" && !slices.Contains(s.InOwn, pm.Module) { + if keptAt == nil { + keptAt = map[string]string{} + } + keptAt[pm.Module] = wanted[pm.Module] + } + } + runs := map[string]bool{} + for _, c := range s.Running { + if c.Module != "" { + runs[c.Module] = true + } + } for _, pm := range p.Modules { m, ok := modules[pm.Module] - if !ok || m.FP2 == "" || s.KeptAt[pm.Module] != "" || slices.Contains(s.InOwn, pm.Module) { + if !ok || m.FP2 == "" || keptAt[pm.Module] != "" || slices.Contains(s.InOwn, pm.Module) { // A module it runs inside its own composition is composed there as before: no build of the new form is what // it runs (Scope.InOwn). continue @@ -698,7 +717,14 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del continue } if b == nil || b.State != delivery.BuildDone { - continue // no done build at the trunk's: composed from the indexer's manifest, as before + if refuse != nil && runs[pm.Module] { + // A module that runs is never composed from the indexer's manifest, which names no build's artifacts (no + // runtime's bundle): it is held, keeping what runs, until its build lands (issue 774, 2026-10-30 19:30: + // node-tools on ace, g14 and shanks, "its build produced 0 bundle(s)", and every module refused). + stub(pm.Module, fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build yet; it "+ + "keeps running what it runs", pm.Module, m.FP2)) + } + continue // nothing of it runs: composed from the indexer's manifest, as before } resolved, err := built(pm.Module, m.FP2, b) if err != nil { @@ -714,8 +740,8 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del if in.Nodes[i].Name != s.Node { continue } - for _, module := range sortedKeys(s.KeptAt) { - fp := s.KeptAt[module] + for _, module := range sortedKeys(keptAt) { + fp := keptAt[module] b, err := r.Build(ctx, module, fp) switch { case err != nil: diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go index 7dc9ab5..b77fa2f 100644 --- a/go/compose/frombus/frombus_test.go +++ b/go/compose/frombus/frombus_test.go @@ -141,6 +141,7 @@ func TestInput(t *testing.T) { // Issue 774: a node refused whole says what it held on the way. t.Run("a node refused whole says what it held", aNodeRefusedWholeSaysWhatItHeld) t.Run("the runtime is blamed only where its stub composes", theRuntimeIsBlamedOnlyWhereItsStubComposes) + t.Run("a running module waits for its build", aRunningModuleWaitsForItsBuild) t.Run("another node not resolved is named", otherNodeNotResolvedIsNamed) ctx := context.Background() xterm, err := os.ReadFile("../../catalogue/testdata/catalogue-835bf22b-xterm.module.json") diff --git a/go/compose/frombus/held_rows_test.go b/go/compose/frombus/held_rows_test.go index fc3438e..4deb0ce 100644 --- a/go/compose/frombus/held_rows_test.go +++ b/go/compose/frombus/held_rows_test.go @@ -2,11 +2,14 @@ package frombus import ( "context" + "encoding/json" "errors" + "fmt" "strings" "testing" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/compose" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -61,3 +64,70 @@ func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) { } } } + +// Issue 774, 2026-10-30 19:30: the trunk's node-tools moved to a build not yet done, and ace, g14 and shanks composed +// it from the indexer's manifest, which names no bundle: "its build produced 0 bundle(s)", every module refused. A +// module that runs is now held until its build lands, the runtime composed as what runs of it; and a module wanted at +// 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"},` + + `"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}}}` + 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{ + 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":"` + catalogue.BundlePath("node-tools", "runtime") + `"}`)}}} + for _, row := range []struct { + name string + want string // the build node-tools' want names + done bool // the trunk's build is done + held string // words node-tools' hold says; empty: composed + digest string // the runtime bundle's digest composed + }{ + {name: "the trunk's build not done yet", want: next, held: "no done build yet"}, + {name: "wanted at the build that runs, the trunk's not done", want: old, digest: dg("1")}, + {name: "the trunk's build done", want: next, done: true, digest: dg("2")}, + } { + b, fps := aNode(t, store, tools, lamp) + b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} + mod := b.modules["node-tools"] + mod.FP2 = next + b.modules["node-tools"] = mod + b.builds["node-tools "+old] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, + Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}} + b.builds["node-tools "+next] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest} + if row.done { + b.builds["node-tools "+next] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, + Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("2"), Digest: dg("2")}}} + } + fps["node-tools"] = row.want + out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1", Running: running}, fps) + if err != nil { + t.Errorf("%s: the node is refused: %v", row.name, err) + continue + } + if lampKey, _ := delivery.ComposedKey("n1", "lamp", fps["lamp"]); out[lampKey].Module != "lamp" { + t.Errorf("%s: lamp is not composed (held %v)", row.name, held) + } + if why := held["node-tools"]; (row.held == "") != (why == "") || !strings.Contains(why, row.held) { + t.Errorf("%s: node-tools held %q, want %q", row.name, why, row.held) + } + if row.digest != "" { + key, _ := delivery.ComposedKey("n1", "node-tools", row.want) + if c := out[key]; !strings.Contains(fmt.Sprint(rawStrings(c.Resources)), row.digest) { + t.Errorf("%s: node-tools is composed as %v, want its bundle %s", row.name, rawStrings(c.Resources), row.digest) + } + } + } +} + +func rawStrings(rs []json.RawMessage) []string { + var out []string + for _, r := range rs { + out = append(out, string(r)) + } + return out +}