diff --git a/go/catalogue/declaration.go b/go/catalogue/declaration.go index 6389454..962ffbc 100644 --- a/go/catalogue/declaration.go +++ b/go/catalogue/declaration.go @@ -1349,8 +1349,10 @@ func (r Resolution) compose(with Rendering, owner map[string]string, if err != nil { return nil, err } - owner[fmt.Sprint(process["id"])] = RuntimeModule - out = append(out, process) + if process != nil { // none for a held runtime's stand-in: what runs of it is carried + owner[fmt.Sprint(process["id"])] = RuntimeModule + out = append(out, process) + } // And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8). owns, err := r.ownRuntimes(with) if err != nil { @@ -1375,7 +1377,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string, words[m.Module] = w } // And a file a module's words name is one the runtime is restarted for when it changes. - if named := givenTo(out, owner, words, r.Account); len(named) > 0 { + if named := givenTo(out, owner, words, r.Account); len(named) > 0 && process != nil { restarts, _ := process["restart-on"].([]any) seen := map[string]bool{} for _, id := range restarts { diff --git a/go/catalogue/runtime.go b/go/catalogue/runtime.go index 46deb00..f8beae0 100644 --- a/go/catalogue/runtime.go +++ b/go/catalogue/runtime.go @@ -124,7 +124,10 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) { runtime = &r.Modules[i] } } - if runtime == nil { + if runtime == nil || runtime.Stubbed { + // Held (its stand-in, Stubbed): its process is never applied, what runs of it is carried, and its stand-in is + // never refused by a rule about its bundle or its broker (issue 774: node-tools held for a build not done, then + // "its build produced 0 bundle(s)", refused every module of ace, g14 and shanks). return nil, nil } if len(runtime.Bundles) != 1 { @@ -240,6 +243,16 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) { return process, nil } +// NoRuntimeToRunError is a module of its own account with no runtime program on its node to run it from. Held says the +// runtime is there and held (its stand-in, Stubbed): the module cannot compose because the runtime does not, so a +// composition that holds the runtime holds it too, never the node (issue 774). +type NoRuntimeToRunError struct { + Held bool + said string +} + +func (e *NoRuntimeToRunError) Error() string { return e.said } + // OwnRuntimeID names the process a module of its own account is served by (novox/hq ADR 0259 §8). func OwnRuntimeID() string { return "own-runtime" } @@ -250,7 +263,11 @@ func OwnRuntimeID() string { return "own-runtime" } func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) { var own []Manifest for _, m := range r.Modules { - if m.RunsAs != "" && !(with.Adopted && m.Filtering != nil) { + // A held module's stand-in (Stubbed) has no runtime of its own composed, and so none of this function's rules + // about one: none of its composition is applied, and what runs of it is carried as it runs. Refusing it here + // refused its whole node (issue 774, 2026-10-30 21:04: mesh-self-check, held for its broker not made, then + // "runs as its own account and declares no own secret broker", failed 74 modules on ace and 79 on novox). + if m.RunsAs != "" && !m.Stubbed && !(with.Adopted && m.Filtering != nil) { own = append(own, m) } } @@ -264,8 +281,9 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) { } } if runtime == nil || len(runtime.Bundles) != 1 || runtime.Bundles[0].Binary == "" { - return nil, fmt.Errorf("%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, RuntimeModule, RuntimeModule, r.Node) + 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, RuntimeModule, RuntimeModule, r.Node)} } program := runtime.Bundles[0] var out []map[string]any diff --git a/go/compose/frombus/answer.go b/go/compose/frombus/answer.go index 1f56daf..f7d6e93 100644 --- a/go/compose/frombus/answer.go +++ b/go/compose/frombus/answer.go @@ -2,6 +2,7 @@ package frombus import ( "context" + "errors" "fmt" "maps" "regexp" @@ -395,7 +396,10 @@ func firstReason(err error, failed map[string]string) error { // 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 { - return again == nil + // 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 *catalogue.NoRuntimeToRunError + return again == nil || errors.As(again, &none) && none.Held } return again == nil || again.Error() != err.Error() } diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go index 4cda26e..f91e4dc 100644 --- a/go/compose/frombus/frombus_test.go +++ b/go/compose/frombus/frombus_test.go @@ -143,6 +143,7 @@ func TestInput(t *testing.T) { t.Run("the runtime is blamed only where its stub composes", theRuntimeIsBlamedOnlyWhereItsStubComposes) t.Run("a running module waits for its build", aRunningModuleWaitsForItsBuild) 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) 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 491929d..f38dac6 100644 --- a/go/compose/frombus/held_rows_test.go +++ b/go/compose/frombus/held_rows_test.go @@ -58,6 +58,8 @@ func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) { {"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", &catalogue.NoRuntimeToRunError{Held: true}), true}, + {catalogue.RuntimeModule, &catalogue.NoRuntimeToRunError{}, false}, } { if got := blames(row.module, row.again, err); got != row.blamed { t.Errorf("%s with %v: blamed %v, want %v", row.module, row.again, got, row.blamed) @@ -151,3 +153,71 @@ func aHeldModulesFirstReasonLeads(t *testing.T) { } } } + +// Issue 774, 2026-10-30 21:04: mesh-conditions and mesh-healers on novox and mesh-self-check on ace were placed before their +// bus accounts were issued. Held for "needs a secret called broker and none was made", each one's bare stub was then +// refused as "runs as its own account and declares no own secret broker", and that refusal failed every module of the +// node (79 on novox, 74 on ace). A held module's stand-in is never refused by a rule about its own runtime or its own +// 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"},` + + `"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"],` + + `"own-secrets":{"broker":"/var/lib/` + name + `/broker"},` + + `"resources":[{"id":"account","type":"user","name":"` + name + `"}],` + + `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}` + } + 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}}}` + for _, row := range []struct { + name string + issued []string // the own-account modules whose broker is issued + building bool // node-tools' build is not done, and nothing of it runs + held map[string]string // the modules held, each with words of the reason it was first held for + }{ + {name: "none issued", held: map[string]string{"check": "broker", "heal": "broker"}}, + {name: "one issued", issued: []string{"heal"}, held: map[string]string{"check": "broker"}}, + {name: "both issued", issued: []string{"check", "heal"}}, + // The runtime held: its stand-in is not refused for its bundle; the modules it would run are held, not the node. + {name: "the runtime's build not done", issued: []string{"check", "heal"}, building: true, + held: map[string]string{"node-tools": "its build produced 0 bundle(s)", "check": "not here to run it from", "heal": "not here to run it from"}}, + } { + b, fps := aNode(t, store, tools, own("check"), lamp, own("heal")) + b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} + for _, m := range row.issued { + b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}) + } + for _, m := range []string{"node-tools", "check", "heal"} { + name := "tools" + if m == "node-tools" { + name = "runtime" + } + mod := b.modules[m] + mod.FP2 = fps[m] + b.modules[m] = mod + b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, + Artifacts: []delivery.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}} + if m == "node-tools" && row.building { + b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest} + } + } + out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps) + if err != nil { + t.Errorf("%s: the node is refused: %v", row.name, err) + continue + } + for _, m := range []string{"node-tools", "check", "lamp", "heal"} { + key, _ := delivery.ComposedKey("n1", m, fps[m]) + _, composed := out[key] + why, isHeld := held[m] + want, wantHeld := row.held[m] + if isHeld != wantHeld || composed == wantHeld { + t.Errorf("%s: %s held %v (%q), composed %v; want held %v", row.name, m, isHeld, why, composed, wantHeld) + } else if isHeld && !strings.Contains(why, want) { + t.Errorf("%s: %s is held for %q, want its first reason, %q", row.name, m, why, want) + } + } + } +}