From 754edfae69244246faaabfd5072fdfab452ef9fe Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 30 Oct 2026 19:23:21 +0100 Subject: [PATCH] Hold only the module whose manifest the mesh refuses, so one bad item no longer fails every node's composition (issue 774) On 2026-10-30 at 19:13 power's bar item (mesh-catalog ca8d6f33) was refused by ParseManifest, and frombus's input returned that refusal for the whole read: every node-engine marked all its modules failed and no update could land. The third of a kind that day, after the bus user list and the provisioner's plans (PlansHeld). Quick fix: skip a manifest that does not parse in input's catalogue loop. Thorough fix: input follows the rule a build that cannot be read already follows there (refuse + stub), so Compose, Answer and PlansHeld all hold the one module: held with why on a node it is placed on, read for its offer alone (its unjudged stub, without items) elsewhere. A manifest the bus does not answer for still ends the read (transient, waited out by the caller), and Input (refuse unset, the check's whole composition) still refuses. Chose thorough: one place serves all three callers. --- go/compose/frombus/frombus.go | 44 +++++++++++++++--- .../frombus/frombustest/frombustest_test.go | 45 ++++++++++++++++--- 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index ac67876..ac832ac 100644 --- a/go/compose/frombus/frombus.go +++ b/go/compose/frombus/frombus.go @@ -180,9 +180,11 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er return input(ctx, r, mesh, s, nil) } -// input is Input; with refuse set, a module placed on the composing node whose build cannot be read or taken in is -// handed to refuse and composed as a stub (compose.Stub, kept for that node alone) rather than ending the read, so a -// node's answer names every module it cannot compose (issue 1126). +// input is Input; with refuse set, a module placed on the composing node whose build cannot be read or taken in, or whose +// manifest the mesh refuses, is handed to refuse and composed as a stub (compose.Stub, kept for that node alone) +// 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) { placements, err := r.Placements(ctx) if err != nil { @@ -248,14 +250,33 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module } return parse(raw) } + ownPlaced := map[string]bool{} + for _, pm := range placements[s.Node].Modules { + ownPlaced[pm.Module] = true + } for _, name := range sortedKeys(modules) { m := modules[name] if m.Manifest == "" { continue // a registered module the indexer has not read: nothing to compose it from } - man, err := read(name, m.Manifest) + raw, err := r.Manifest(ctx, m.Manifest) if err != nil { - return compose.Input{}, err + // Not read is no refusal of the module: a bus that does not answer is waited out by the caller. + return compose.Input{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err) + } + man, err := parse(raw) + if err != nil { + if refuse == nil { + return compose.Input{}, err + } + // One module's manifest the mesh refuses refuses that module alone, by the rule a build that cannot be read + // follows (issue 774: power's bar item refused every node's composition, 2026-10-30). It stands in as its + // unjudged stub, so what it provides and requires still shapes the rest; it is held on the composing node + // where it is placed there, and elsewhere only its offer is read. + man = unusable(name, raw) + if ownPlaced[name] { + refuse(name, err) + } } in.Catalogue[name] = man } @@ -530,6 +551,19 @@ func plansIn(in compose.Input, node string, mode compose.Mode) (map[string]catal return composed.Plans, nil } +// unusable is what stands in for a module's manifest the mesh refuses: its stub (compose.Stub) +// read from its JSON unjudged, so what it provides, requires and serves still shapes the rest, and without its items, +// since nothing judged what they would put into another module's collection. Bytes that are no manifest of the module +// stand in as a stub that offers nothing. +func unusable(module string, raw []byte) catalogue.Manifest { + var m catalogue.Manifest + if json.Unmarshal(raw, &m) != nil || m.Module != module { + m = catalogue.Manifest{Module: module} + } + m.Items = nil + return compose.Stub(m) +} + // parser reads a manifest as the indexer does, against the mesh's predefined keys when given. func parser(mesh Mesh) func([]byte) (catalogue.Manifest, error) { return func(raw []byte) (catalogue.Manifest, error) { diff --git a/go/compose/frombus/frombustest/frombustest_test.go b/go/compose/frombus/frombustest/frombustest_test.go index 476bb1d..24e7b87 100644 --- a/go/compose/frombus/frombustest/frombustest_test.go +++ b/go/compose/frombus/frombustest/frombustest_test.go @@ -12,7 +12,10 @@ import ( // ADR 0570 clause 2 (issue 1954; a3's H5): the in-memory mesh composes a plan from the bus that hands restic to the // provisioner and names its postgres-database, provided by postgres on n1. And one bad module must not drop its node // (issue 774): with a module placed beside it whose built manifest cannot be used, or whose plan refuses, PlansHeld -// still hands restic's plan and names the bad one with why, where Plans refuses the whole node. +// still hands restic's plan and names the bad one with why, where Plans refuses the whole node. A manifest the indexer +// holds that the mesh refuses (power's bar item with attributes its bar places by none of, mesh-catalog ca8d6f33, +// 2026-10-30) holds that module alone on its node, in the plans and in the node's composition (Compose), and on a node +// it is not placed on holds nothing. func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { fp := "src2:" + strings.Repeat("9", 64) for _, row := range []struct { @@ -21,6 +24,8 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { // built is the manifest backup's done build names; empty gives it no build built string why string // words backup's hold says + // elsewhere leaves backup in the indexer's modules and places it on no node: nothing is held. + elsewhere bool }{ {name: "every module composes"}, {name: "a built manifest that cannot be used", @@ -29,15 +34,19 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { {name: "a plan that refuses", bad: `{"module":"backup","version":"1","resources":[{"id":"conf","type":"file","path":"/etc/backup.conf",` + `"mode":"0600","content":"key=${secret:nope}"}]}`, why: "${secret:nope}"}, + {name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`}, + {name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true}, } { b, err := Provisioned() if err != nil { t.Fatal(err) } if row.bad != "" { - p := b.Placement[Node] - p.Modules = append(p.Modules, delivery.PlacedModule{Module: "backup"}) - b.Placement[Node] = p + if !row.elsewhere { + p := b.Placement[Node] + p.Modules = append(p.Modules, delivery.PlacedModule{Module: "backup"}) + b.Placement[Node] = p + } digest := delivery.ManifestDigest([]byte(row.bad)) b.Manifests[digest] = []byte(row.bad) b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest} @@ -47,10 +56,27 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest, Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}} } - if _, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}); err == nil { + if _, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}); err == nil && !row.elsewhere { t.Errorf("%s: Plans composed the node; the row does not refuse it", row.name) } } + if row.why != "" || row.bad == "" { + // The node's own composition holds by the same rule: every other module composed, the bad one named. + fps := FPs() + if row.bad != "" { + fps["backup"] = fp + } + out, _, held, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps) + if err != nil { + t.Fatalf("%s: the node's composition: %v", row.name, err) + } + if key, _ := delivery.ComposedKey(Node, "restic", fps["restic"]); out[key].Module != "restic" { + t.Errorf("%s: restic is not composed (held %v)", row.name, held) + } + if why, ok := held["backup"]; (row.bad == "" && len(held) > 0) || (row.bad != "" && (!ok || !strings.Contains(why, row.why) || len(held) != 1)) { + t.Errorf("%s: the node's composition held %v, want backup alone, saying %q", row.name, held, row.why) + } + } plans, held, err := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) if err != nil { t.Fatalf("%s: %v", row.name, err) @@ -60,7 +86,7 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" { t.Fatalf("%s: the plans are %+v (held %v)", row.name, plans, held) } - if row.bad == "" { + if row.bad == "" || row.elsewhere { if len(held) > 0 { t.Errorf("%s: held %v", row.name, held) } @@ -74,3 +100,10 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { } } } + +// unusableItem is a manifest the mesh refuses as the node-engine did power's at mesh-catalog ca8d6f33: a bar block item +// with attributes its bar places by none of. +const unusableItem = `{"module":"backup","version":"1","moved":true,` + + `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` + + `"items":[{"collection":"contribution:node-bar:block",` + + `"attributes":{"bar":"bottom","place":"status","shows":"state","state":"power.draw"},"template":"files/bar"}]}`