diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index f063f49..164baf2 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -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 diff --git a/internal/compose/input.go b/internal/compose/input.go index 72aecf7..4471e5e 100644 --- a/internal/compose/input.go +++ b/internal/compose/input.go @@ -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 diff --git a/internal/composer/composer.go b/internal/composer/composer.go index 9204bc1..92961f1 100644 --- a/internal/composer/composer.go +++ b/internal/composer/composer.go @@ -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 { diff --git a/internal/composer/composer_test.go b/internal/composer/composer_test.go index 281c310..991b0ba 100644 --- a/internal/composer/composer_test.go +++ b/internal/composer/composer_test.go @@ -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) diff --git a/internal/selfcompose/candidate.go b/internal/selfcompose/candidate.go index 9d8b091..4e00674 100644 --- a/internal/selfcompose/candidate.go +++ b/internal/selfcompose/candidate.go @@ -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 { diff --git a/internal/selfcompose/candidate_test.go b/internal/selfcompose/candidate_test.go index 2efbfbb..ea78e81 100644 --- a/internal/selfcompose/candidate_test.go +++ b/internal/selfcompose/candidate_test.go @@ -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") {