Compose a module at the build its want names and hold a running one whose build is not done, so the runtime never loses its bundle (issue 774)
At 19:30 on 2026-10-30 node-tools' trunk fingerprint moved to a build not yet done; fromBuilds then composed it from
the indexer's manifest, which names no bundle, and ace, g14 and shanks refused every module ("its build produced 0
bundle(s)") until another input moved after the build landed.
Quick fix: skip the runtime's check while its build is not done. Thorough fix: Compose reads each placed module at the
build its want names (as Scope.KeptAt), and a module that runs whose build is not done is held, keeping what runs (a
held runtime is composed as what runs of it, its running bundle), named with why; a module nothing runs of still
composes from the indexer's manifest as before. Input, PlansHeld and Answer read the trunk's builds as before. Chose
thorough: no running module is ever composed from a source manifest that names no build.
This commit is contained in:
@@ -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
|
// 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.
|
// below, a module the composition refuses, each composed as a stub so the rest is composed and compared.
|
||||||
failed := map[string]string{}
|
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 {
|
if err != nil {
|
||||||
return refuse(err)
|
return refuse(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -177,7 +178,7 @@ func InOwn(running []delivery.Composed, p delivery.Placement) []string {
|
|||||||
|
|
||||||
// Input is the composition input for s, read through r.
|
// Input is the composition input for s, read through r.
|
||||||
func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, error) {
|
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
|
// 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
|
// 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
|
// 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.
|
// 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)
|
placements, err := r.Placements(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return compose.Input{}, fmt.Errorf("frombus: placement: %w", err)
|
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 {
|
if _, ok := records[s.Node]; !ok {
|
||||||
return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node)
|
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
|
return compose.Input{}, err
|
||||||
}
|
}
|
||||||
// Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5).
|
// Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5).
|
||||||
@@ -440,7 +441,7 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s
|
|||||||
err = fmt.Errorf("%w (held before: %s)", err, strings.Join(said, "; "))
|
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() })
|
in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, fps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, held, err
|
return nil, nil, held, err
|
||||||
}
|
}
|
||||||
@@ -478,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.
|
// 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) {
|
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{}
|
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 {
|
if err != nil {
|
||||||
return nil, held, err
|
return nil, held, err
|
||||||
}
|
}
|
||||||
@@ -618,7 +619,7 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed
|
|||||||
// (issue 1126).
|
// (issue 1126).
|
||||||
func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module,
|
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),
|
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
|
// 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.
|
// and requires still shapes the rest.
|
||||||
stub := func(module string, err error) {
|
stub := func(module string, err error) {
|
||||||
@@ -678,9 +679,27 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
|||||||
}
|
}
|
||||||
return resolved, nil
|
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 {
|
for _, pm := range p.Modules {
|
||||||
m, ok := modules[pm.Module]
|
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
|
// 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).
|
// it runs (Scope.InOwn).
|
||||||
continue
|
continue
|
||||||
@@ -698,7 +717,14 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if b == nil || b.State != delivery.BuildDone {
|
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)
|
resolved, err := built(pm.Module, m.FP2, b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -714,8 +740,8 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
|||||||
if in.Nodes[i].Name != s.Node {
|
if in.Nodes[i].Name != s.Node {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, module := range sortedKeys(s.KeptAt) {
|
for _, module := range sortedKeys(keptAt) {
|
||||||
fp := s.KeptAt[module]
|
fp := keptAt[module]
|
||||||
b, err := r.Build(ctx, module, fp)
|
b, err := r.Build(ctx, module, fp)
|
||||||
switch {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ func TestInput(t *testing.T) {
|
|||||||
// Issue 774: a node refused whole says what it held on the way.
|
// 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("a node refused whole says what it held", aNodeRefusedWholeSaysWhatItHeld)
|
||||||
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("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")
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package frombus
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,3 +64,70 @@ func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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