Never refuse a node for a held module's stand-in by its own runtime's rules (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@b74e23f973ab
mesh/delivery-group group fix/held-module-and-assigner-contract merged: every member is merged
mesh/check checking eff81f1220e2 on b74e23f973ab | novox/mesh-sdk#142 in group fix/held-module-and-assigner-contract

On 2026-10-30 at 21:04 mesh-self-check (ace) and mesh-conditions, mesh-healers (novox) were placed before
their bus accounts were issued. Each was held for "needs a secret called broker and none was made"; its bare
stub then lost its own secrets and was refused by ownRuntimes ("runs as its own account and declares no own
secret broker"), a refusal no culprit takes, so 74 modules on ace and 79 on novox failed. A held runtime's stub
met the same kind earlier today ("its build produced 0 bundle(s)").

Quick fix: let the bare stub keep its broker own secret, for this one rule.
Thorough fix (chosen, the fourth of the kind today): a held module's stand-in (Stubbed) has no runtime
composed at all, so no rule about its runtime, bundle or broker can refuse it: ownRuntimes passes it over and
runtimeProcess composes none for a held runtime (what runs of either is carried, as before). A module of its
own account left without its runtime by a held runtime is refused with a typed NoRuntimeToRunError, which makes
the held runtime the culprit, so those modules are held after it and the node composes the rest.
This commit is contained in:
2026-10-30 21:18:06 +01:00
parent 6a71d29b33
commit eff81f1220
5 changed files with 103 additions and 8 deletions
+5 -3
View File
@@ -1349,8 +1349,10 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
if err != nil { if err != nil {
return nil, err return nil, err
} }
owner[fmt.Sprint(process["id"])] = RuntimeModule if process != nil { // none for a held runtime's stand-in: what runs of it is carried
out = append(out, process) 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). // 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) owns, err := r.ownRuntimes(with)
if err != nil { if err != nil {
@@ -1375,7 +1377,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
words[m.Module] = w words[m.Module] = w
} }
// And a file a module's words name is one the runtime is restarted for when it changes. // 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) restarts, _ := process["restart-on"].([]any)
seen := map[string]bool{} seen := map[string]bool{}
for _, id := range restarts { for _, id := range restarts {
+22 -4
View File
@@ -124,7 +124,10 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
runtime = &r.Modules[i] 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 return nil, nil
} }
if len(runtime.Bundles) != 1 { if len(runtime.Bundles) != 1 {
@@ -240,6 +243,16 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
return process, nil 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). // OwnRuntimeID names the process a module of its own account is served by (novox/hq ADR 0259 §8).
func OwnRuntimeID() string { return "own-runtime" } 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) { func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
var own []Manifest var own []Manifest
for _, m := range r.Modules { 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) 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 == "" { 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 "+ return nil, &NoRuntimeToRunError{Held: runtime != nil && runtime.Stubbed, said: fmt.Sprintf("%s runs as its own "+
"from: assign %s to %s first (novox/hq ADR 0259)", own[0].Module, RuntimeModule, RuntimeModule, r.Node) "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] program := runtime.Bundles[0]
var out []map[string]any var out []map[string]any
+5 -1
View File
@@ -2,6 +2,7 @@ package frombus
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"maps" "maps"
"regexp" "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). // 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 { func blames(m string, again, err error) bool {
if m == catalogue.RuntimeModule { 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() return again == nil || again.Error() != err.Error()
} }
+1
View File
@@ -143,6 +143,7 @@ func TestInput(t *testing.T) {
t.Run("the runtime is blamed only where its stub composes", theRuntimeIsBlamedOnlyWhereItsStubComposes) 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 running module waits for its build", aRunningModuleWaitsForItsBuild)
t.Run("a held module's first reason leads", aHeldModulesFirstReasonLeads) 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("another node not resolved is named", otherNodeNotResolvedIsNamed)
ctx := context.Background() ctx := context.Background()
xterm, err := os.ReadFile("../../catalogue/testdata/catalogue-835bf22b-xterm.module.json") xterm, err := os.ReadFile("../../catalogue/testdata/catalogue-835bf22b-xterm.module.json")
+70
View File
@@ -58,6 +58,8 @@ func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) {
{"lamp", errors.New("lamp refuses"), false}, {"lamp", errors.New("lamp refuses"), false},
{catalogue.RuntimeModule, nil, true}, {catalogue.RuntimeModule, nil, true},
{catalogue.RuntimeModule, errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false}, {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 { 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) 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)
}
}
}
}