The sandbox composes a node as its node-engine does, from an input that says how, and names a node it cannot judge from an older engine's input (issue 774, ADR 0593 §9)

This commit is contained in:
2026-11-03 13:28:44 +01:00
parent 798d73eb11
commit 7b8f5e7805
6 changed files with 152 additions and 4 deletions
+36
View File
@@ -571,6 +571,42 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s
return out, skipped, held, err
}
// ComposingInput is the input the scope's node composes itself from at the builds fps, as Compose reads it (the same read,
// the refusals it holds), with how Compose composes from it (compose.Input.ComposedHow): what a node-engine sends a
// check's sandbox, so the sandbox composes the node through ComposeSent, the path Compose takes (issue 774). Read no
// credential: the sandbox composes no pair value.
func ComposingInput(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (compose.Input, error) {
held := map[string]string{}
in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, fps)
if err != nil {
return compose.Input{}, err
}
c := &compose.ComposedHow{Node: s.Node, Builds: maps.Clone(fps), Held: held}
for _, run := range s.Running {
if _, isHeld := held[run.Module]; isHeld && run.Module != "" {
c.Running = append(c.Running, run)
}
}
in.ComposedHow = c
return in, nil
}
// ComposeSent composes node from an input ComposingInput read, through the path Compose takes (composeEach, the read's holds
// first): what a check's sandbox runs. False when the input says no way of composing node (an input from a node-engine
// before ComposedHow): the caller cannot compose it as the node does.
func ComposeSent(in compose.Input, node string) (map[string]delivery.Composed, bool, error) {
c := in.ComposedHow
if c == nil || c.Node != node {
return nil, false, nil
}
held := maps.Clone(c.Held)
if held == nil {
held = map[string]string{}
}
out, _, _, err := composeEach(in, node, c.Builds, false, c.Running, held, nil)
return out, true, err
}
// Plans is the provisioning plan of each module handed to the provisioner on the scope's node, composed from the bus
// alone (novox/hq ADR 0570 clause 2): the controller's handedPlans over the same input (mesh-control
// cmd/mesh-controller/compose_sdk.go), its rendering in the Foreseeing mode with the mesh's generators. A plan names
+20
View File
@@ -73,6 +73,13 @@ type Input struct {
// issue 1126, C6). Used only while OperatorExport is nil.
KeptRuns map[string]map[string]map[string]any
// ComposedHow is how the node-engine that read this input composes its node from it (frombus.Compose): the builds
// it composes each module at, the modules it holds and what runs of each held one. A node-engine answers it with the
// input it sends a check's sandbox (composer.SentInput), so the sandbox composes the node through the same path, a
// module folded in the node's own composition kept as it runs rather than resolved from its manifest (issue 774, the
// check of the bus want: novox's nats). Nil from every other reader and from a node-engine before it.
ComposedHow *ComposedHow
// Issued is the credentials made for the consumers of each provider (inv.SecretsFrom): one row for each pair credential
// that exists, by the provider node it is for. The grants a provider must create are read from these, so a credential not
// made yet has no grant until the send that makes it. An assigner fills it from the facts snapshot's Grants of each
@@ -231,6 +238,19 @@ type Node struct {
RunsTools map[string][]catalogue.Bundle
}
// ComposedHow is how a node-engine composes its node from an Input (Input.ComposedHow).
type ComposedHow struct {
// Node is the node composed.
Node string
// Builds is the build each module is composed at, by module: a module the node runs folded in its own composition
// at a build of the old form has none, and is composed there as it runs (Node.InOwn, Node.RunsOwn).
Builds map[string]string
// Held is each module the read held, with why: composed as what runs of it, and the rest without it.
Held map[string]string
// Running is what runs of each held module, its own composition, by which it is carried as it runs.
Running []delivery.Composed
}
// Place is where a node is, as its placement says (inv.Overlay).
type Place struct {
Key string
+44
View File
@@ -12,6 +12,7 @@ import (
"fmt"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
sdk "git.novox.be/novox/mesh-sdk/go/composer"
@@ -205,8 +206,25 @@ func machine(in compose.Input, node, depth string, declarations bool) Machine {
}
return Machine{Composes: true}
}
// Composed as the node-engine composes it, when its input says how (compose.Input.ComposedHow, issue 774): the same
// path, at the same builds, a module folded in its own composition kept as it runs.
if !declarations {
if _, how, err := frombus.ComposeSent(in, node); how {
if err != nil {
return Machine{Reason: err.Error()}
}
return Machine{Composes: true}
}
}
d, problems, err := compose.Compose(in, node)
if err != nil {
// An input from a node-engine before ComposedHow says nothing of what runs of the modules folded in the node's
// own composition: the sandbox cannot compose them as the node does, so it does not judge the node, and names
// it (ADR 0593 §9), never a failure of the change and never a silent pass.
if folded := foldedUnsaid(in, node); len(folded) > 0 && in.ComposedHow == nil {
return Machine{Composes: true, Reason: NotJudged + strings.Join(folded, ", ") +
" (its own composition carries them as they run, and the sandbox cannot): " + err.Error()}
}
return Machine{Reason: err.Error()}
}
m := Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()}
@@ -219,6 +237,32 @@ func machine(in compose.Input, node, depth string, declarations bool) Machine {
return m
}
// NotJudged opens the reason of a machine the sandbox does not judge: its node-engine sent an input that does not say
// how it composes (compose.Input.ComposedHow), and the node runs modules folded in its own composition.
const NotJudged = "not judged: its node-engine sends no running state for "
// foldedUnsaid is each module node runs folded in its own composition (Node.InOwn) whose manifest names a build: what
// the node composes as it runs, and an input without ComposedHow cannot.
func foldedUnsaid(in compose.Input, node string) []string {
var out []string
for _, n := range in.Nodes {
if n.Name != node {
continue
}
for m, ok := range n.InOwn {
man, known := n.Kept[m]
if !known {
man, known = in.Catalogue[m]
}
if ok && known && compose.NamesBuild(man) {
out = append(out, m)
}
}
}
sort.Strings(out)
return out
}
// pathsOf is every resource of a declaration that names a path, with its owner, group and mode as declared, sorted by
// path.
func pathsOf(d compose.Declared) []Path {
+36
View File
@@ -108,6 +108,42 @@ func TestARequestItCannotRunIsRefused(t *testing.T) {
t.Errorf("the sandbox composed n1 %+v; the bus reader declared %+v", got, want)
}
})
// Issue 774 (the check of the bus want, the third of a kind: the sandbox's input differs from what the bus reader
// composes): a node like the bus node, its bus module folded in its own composition at a build of the old form and no
// build published for the module's server artifact, composes in the sandbox as its node-engine composes it, from the
// input that says how (compose.Input.ComposedHow); from an older engine's input, which does not, it is named not
// judged, never failed and never passed in silence.
t.Run("a module folded in the node's own composition", func(t *testing.T) {
bus := `{"module": "bus", "version": "1", "claims": [{"name": "node-files"}], ` +
`"resources": [{"id": "server", "type": "container", "name": "bus-server", "artifact": "server"}], ` +
`"build": {"artifacts": [{"name": "server", "kind": "upstream", "from": "nats:2.11@sha256:` + strings.Repeat("a", 64) + `"}]}}`
man, err := catalogue.Decode([]byte(bus))
if err != nil {
t.Fatal(err)
}
node := compose.Node{Name: "a", Assigned: []string{"bus"}, InOwn: map[string]bool{"bus": true},
RunsOwn: &delivery.Composed{Node: "a"}}
in := compose.Input{Catalogue: map[string]catalogue.Manifest{"bus": man}, Nodes: []compose.Node{node}}
request := func(in compose.Input) Request {
return withInput(t, Request{V: Version, Machines: []string{"a"}, Depth: DepthCompose,
Cases: []Case{{Name: "change", Manifests: map[string]Manifest{}}}}, in)
}
older, err := Run(request(in))
if err != nil {
t.Fatal(err)
}
if m := older.Cases[0].Machines["a"]; !m.Composes || !strings.HasPrefix(m.Reason, NotJudged+"bus") {
t.Errorf("an older engine's input of a node with a folded module: %+v", m)
}
in.ComposedHow = &compose.ComposedHow{Node: "a", Builds: map[string]string{}}
sent, err := Run(request(in))
if err != nil {
t.Fatal(err)
}
if m := sent.Cases[0].Machines["a"]; !m.Composes || m.Reason != "" {
t.Errorf("the input that says how the node composes, its bus folded: %+v", m)
}
})
v, err := Run(Request{V: Version, Cases: []Case{{Name: "only-read", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}})
if err != nil || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted || v.Cases[0].Machines != nil || v.SDK == "" {
t.Fatalf("a request with no machines: %+v, %v", v, err)
+6 -4
View File
@@ -181,11 +181,13 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask delivery
if err != nil {
return unready(err.Error())
}
laidComps, laidHeld, laidErr := laid.composeShadow(ctx, running, touched, func(scope frombus.Scope) {
laidComps, laidHeld, laidErr := laid.composeShadow(ctx, running, touched, func(scope frombus.Scope, fps map[string]string) {
if !ask.Input {
return
}
in, err := frombus.Input(ctx, reader{c: laid}, laid.env.Mesh, scope)
// Read as the composition reads it, at the builds it composes at, with how it composes (compose.Input.ComposedHow),
// so the sandbox composes this node through the same path (issue 774).
in, err := frombus.ComposingInput(ctx, reader{c: laid}, laid.env.Mesh, scope, fps)
if err != nil {
return
}
@@ -411,7 +413,7 @@ func (c *Composer) layCandidate(ctx context.Context, cand delivery.Candidate) (m
// composeShadow composes this node from what c holds, as the shadow composes it (shadowBuilds): each module at the build
// it runs, one placed and not run at the indexer's, and one the candidate laid (laid) at the candidate's. input, when
// given, is handed the scope.
func (c *Composer) composeShadow(ctx context.Context, running []delivery.Composed, laid map[string]bool, input func(frombus.Scope)) (map[string]delivery.Composed, map[string]string, error) {
func (c *Composer) composeShadow(ctx context.Context, running []delivery.Composed, laid map[string]bool, input func(frombus.Scope, map[string]string)) (map[string]delivery.Composed, map[string]string, error) {
node := c.env.Node
c.mu.Lock()
placement, placed := c.placements[node]
@@ -444,7 +446,7 @@ func (c *Composer) composeShadow(ctx context.Context, running []delivery.Compose
c.mu.Unlock()
scope := frombus.Scope{Node: node, KeptAt: kept, InOwn: inOwn, Running: running, SameValue: c.sameValue}
if input != nil {
input(scope)
input(scope, fps)
}
comps, _, held, err := frombus.Compose(ctx, reader{c: c}, c.env.Mesh, scope, fps)
if err != nil {
+10
View File
@@ -2,6 +2,7 @@ package selfcompose
import (
"context"
"encoding/json"
"strings"
"sync/atomic"
"testing"
@@ -10,6 +11,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/agent"
"github.com/novox/mesh-host/internal/compose"
)
// novox/hq ADR 0593 §7: the verb compose-candidate composes a check's candidate on this node in shadow. A candidate that
@@ -105,6 +107,14 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) {
a.Own.Module != delivery.HaveNodeWord || a.Took > delivery.ComposeAnswerBound.Milliseconds() {
t.Errorf("an accepted candidate answered %+v", a)
}
// Issue 774: the input it sends the sandbox says how it composed the node (the builds), so the sandbox composes it
// through the same path.
sent := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: accepted, Input: true})
var in compose.Input
if err := json.Unmarshal(sent.Input, &in); err != nil || in.ComposedHow == nil || in.ComposedHow.Node != "n1" ||
in.ComposedHow.Builds["lemurs"] != fpNew {
t.Errorf("the input sent for the sandbox does not say how the node composes: %+v, %v", in.ComposedHow, err)
}
r := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: refusedKey})
if r.Verdict != delivery.ComposeCandidateRefused || len(r.Modules) != 1 || r.Modules[0].State != delivery.ModuleRefusedState ||
!strings.Contains(r.Modules[0].Words, "a field the judge refuses") {