A build not done yet holds its module, never every module of a node, and a refusal names what was held (issue 774) #140
@@ -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)
|
||||
}
|
||||
@@ -373,6 +373,16 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b
|
||||
return out, skipped, stood, nil
|
||||
}
|
||||
|
||||
// blames says the composition with m's stub (again) makes m the culprit of err: it composes, or fails otherwise. The
|
||||
// runtime's stub is never a runtime (no build is taken in, so it carries no bundle) and always fails otherwise: it is
|
||||
// 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
|
||||
}
|
||||
return again == nil || again.Error() != err.Error()
|
||||
}
|
||||
|
||||
// leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); any other word leaves out
|
||||
// what it contributes to that provision alone.
|
||||
const leaveAll = "*"
|
||||
@@ -419,7 +429,7 @@ func culprit(in compose.Input, node string, fps map[string]string, mode compose.
|
||||
}
|
||||
changes := func(m, leave string) bool {
|
||||
_, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode)
|
||||
return again == nil || again.Error() != err.Error()
|
||||
return blames(m, again, err)
|
||||
}
|
||||
for _, m := range order {
|
||||
if _, done := failed[m]; done {
|
||||
|
||||
@@ -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).
|
||||
@@ -429,7 +430,18 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
// elsewhere, a stand-in for the node's own) refuses the node, err.
|
||||
func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]delivery.Composed, skipped []string, 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() })
|
||||
// A node refused whole says what it held on the way, since the refusal can be of a held module's stub (issue 774,
|
||||
// 2026-10-30 19:30: node-tools held, then "its build produced 0 bundle(s)" with its own refusal unsaid).
|
||||
defer func() {
|
||||
if err != nil && len(held) > 0 {
|
||||
var said []string
|
||||
for _, m := range sortedKeys(held) {
|
||||
said = append(said, m+": "+held[m])
|
||||
}
|
||||
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() }, fps)
|
||||
if err != nil {
|
||||
return nil, nil, held, err
|
||||
}
|
||||
@@ -467,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
|
||||
}
|
||||
@@ -607,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) {
|
||||
@@ -667,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
|
||||
@@ -687,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 {
|
||||
@@ -703,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:
|
||||
|
||||
@@ -138,6 +138,10 @@ func TestInput(t *testing.T) {
|
||||
// saying why (answer_rows_test.go).
|
||||
t.Run("every refusal, and an old build left to the node", answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode)
|
||||
t.Run("no store reached says why", noStoreReachedSaysWhy)
|
||||
// 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")
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given.
|
||||
func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) {
|
||||
t.Helper()
|
||||
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
|
||||
b := &bus{records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}},
|
||||
modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}}
|
||||
fps := map[string]string{}
|
||||
for i, raw := range manifests {
|
||||
module := strings.SplitN(strings.TrimPrefix(raw, `{"module":"`), `"`, 2)[0]
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: module})
|
||||
b.modules[module] = delivery.Module{Manifest: delivery.ManifestDigest([]byte(raw))}
|
||||
b.manifests[delivery.ManifestDigest([]byte(raw))] = []byte(raw)
|
||||
fps[module] = "src2:" + strings.Repeat(string(rune('1'+i)), 64)
|
||||
}
|
||||
b.placements = map[string]delivery.Placement{"n1": p}
|
||||
return b, fps
|
||||
}
|
||||
|
||||
// Issue 774, 2026-10-30 19:30: a node refused whole after holding a module says what it held and why, since the last
|
||||
// refusal can be of the held module's stub (node-tools held, then "its build produced 0 bundle(s)", its own why unsaid).
|
||||
func aNodeRefusedWholeSaysWhatItHeld(t *testing.T) {
|
||||
refused := `{"module":"power","version":"1","items":[{"collection":"contribution:node-bar:block","attributes":{"shows":"state"},"template":"files/a"}]}`
|
||||
needs := `{"module":"lamp","version":"1","requires":["nothing-provides-this"]}`
|
||||
b, fps := aNode(t, refused, needs)
|
||||
_, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps)
|
||||
if err == nil || !strings.Contains(err.Error(), "nothing provides") || !strings.Contains(err.Error(), `held before: power: `) ||
|
||||
!strings.Contains(err.Error(), `"shows" is none of them`) {
|
||||
t.Errorf("the node refused whole with %v (held %v), want lamp's refusal and power held before, with its why", err, held)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 774: the culprit rule takes a module whose stub composes or fails otherwise; the runtime only where its stub
|
||||
// composes, since its stub always fails otherwise.
|
||||
func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) {
|
||||
err := errors.New("lamp refuses")
|
||||
for _, row := range []struct {
|
||||
module string
|
||||
again error
|
||||
blamed bool
|
||||
}{
|
||||
{"lamp", nil, true},
|
||||
{"lamp", errors.New("other"), true},
|
||||
{"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},
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user