Never refuse a node for a held module's stand-in by its own runtime's rules (issue 774)
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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user