From df13a83a91176735bddf0883e9bd1ae23a950f0b Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 01:55:08 +0100 Subject: [PATCH] The sandbox composes a node from the input its engine took in, through one shape The second sandbox fault of the same kind on mesh-host #200 (postgres's secret reader handed, the module not): the engine stripped the whole catalogue from the input it answers, so the sandbox composed the node's own modules from the indexer's source manifests while the bus reader composes them taken in from their builds (artifacts, templates archive texts, what is handed). Both sides now go through composer.SentInput: the node's own taken-in manifests and kept builds stay in the input, and the sandbox composes them over the cases'. An input past what one bus answer carries is refused in words. A conformance row composes one node through the bus reader and through the sandbox and requires the same declaration. Issue 1126. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- internal/composer/composer.go | 43 ++++++++++++++++++++++++++++++ internal/composer/composer_test.go | 38 ++++++++++++++++++++++++++ internal/selfcompose/candidate.go | 20 +++++++++----- 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/internal/composer/composer.go b/internal/composer/composer.go index f32a233..9204bc1 100644 --- a/internal/composer/composer.go +++ b/internal/composer/composer.go @@ -144,10 +144,18 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) { } shelf[name] = m } + // A module the input carries as its node-engine took it in from its build (SentInput) is composed so: the bus + // reader composes it from its build, never from the indexer's manifest a case gives. + for name, m := range base.Catalogue { + shelf[name] = m + } in.Catalogue = shelf in.Nodes = slices.Clone(in.Nodes) in.Holdings = slices.Clone(in.Holdings) for i := range in.Nodes { + if len(c.Kept[in.Nodes[i].Name]) == 0 { + continue // what the input keeps of this node, as its engine took it in, stands + } in.Nodes[i].Kept = nil if kept := c.Kept[in.Nodes[i].Name]; len(kept) > 0 { k, err := parse(kept, nil, nil) @@ -244,3 +252,38 @@ const Package = "github.com/novox/mesh-host/cmd/mesh-compose" // Decode reads a request (sdk.Decode); it refuses one that is not JSON or of another version. func Decode(data []byte) (Request, error) { return sdk.Decode(data) } + +// SentInput is the composition input a node-engine answers for the sandbox (delivery.ComposeCandidateAsk.Input), from the +// input it composed node from: the one shape both read (issue 1126, checks of mesh-host #200). Every other node's modules +// are left to the cases (the indexer's manifests, as the bus reader reads them too); node's own placed modules stay as the +// bus reader took them in from their builds (their artifacts, the texts of their templates archive, what is handed to the +// provisioner), with what node keeps at another build, which no case can give: the sandbox has no build or archive to +// read. Without them the sandbox composed another node than the engine (restic's templates and artifacts unread, postgres's +// secret reader handed and the module not). +func SentInput(in compose.Input, node string) compose.Input { + own := map[string]bool{} + for _, n := range in.Nodes { + if n.Name == node { + for _, m := range n.Assigned { + own[m] = true + } + } + } + taken := in.Catalogue + in.Catalogue = nil + for name, m := range taken { + if own[name] { + if in.Catalogue == nil { + in.Catalogue = map[string]catalogue.Manifest{} + } + in.Catalogue[name] = m + } + } + in.Nodes = slices.Clone(in.Nodes) + for i := range in.Nodes { + if in.Nodes[i].Name != node { + in.Nodes[i].Kept = nil + } + } + return in +} diff --git a/internal/composer/composer_test.go b/internal/composer/composer_test.go index 0e3e7cf..281c310 100644 --- a/internal/composer/composer_test.go +++ b/internal/composer/composer_test.go @@ -1,14 +1,18 @@ package composer import ( + "context" "encoding/json" "os" + "reflect" "strings" "testing" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/frombus" + "github.com/novox/mesh-host/internal/compose/frombus/frombustest" ) const demo = `{"module": "demo", "version": "1", "claims": [{"name": "node-files"}]}` @@ -70,6 +74,40 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { if v, err := Run(provided); err != nil || !v.Cases[0].Machines["a"].Composes { t.Errorf("a node assigned the mesh's own module: %+v, %v", v, err) } + // Issue 1126 (checks of mesh-host #200, fix-a-bug-met-twice): the sandbox composes one node from the same inputs as + // the bus reader, through one path. A mesh whose restic is built (its files from an archive) and handed to the + // provisioner composes n1 through frombus, and the request a node-engine answers for it (the input as the engine + // sends it, the indexer's manifests in the case) declares the same paths for n1. + t.Run("the sandbox composes a node as the bus reader does", func(t *testing.T) { + b, err := frombustest.Provisioned() + if err != nil { + t.Fatal(err) + } + ctx, scope := context.Background(), frombus.Scope{Node: frombustest.Node} + read, err := frombus.Input(ctx, b, frombus.Mesh{}, scope) + if err != nil { + t.Fatal(err) + } + d, problems, err := compose.Compose(read, frombustest.Node) + if err != nil || len(problems) > 0 { + t.Fatalf("the bus reader's input composes n1: %v %v", err, problems) + } + want := pathsOf(d) + sent := SentInput(read, frombustest.Node) + cases := Case{Name: "change", Manifests: map[string]Manifest{}} + for name, m := range b.Module { + cases.Manifests[name] = Manifest{Raw: b.Manifests[m.Manifest]} + } + r := withInput(t, Request{V: Version, Machines: []string{frombustest.Node}, Declarations: true, Cases: []Case{cases}}, sent) + v, err := Run(r) + if err != nil { + t.Fatal(err) + } + got := v.Cases[0].Machines[frombustest.Node] + if !got.Composes || !reflect.DeepEqual(got.Declared, want) { + t.Errorf("the sandbox composed n1 %+v; the bus reader declared %+v", got, want) + } + }) 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 491e43c..9d8b091 100644 --- a/internal/selfcompose/candidate.go +++ b/internal/selfcompose/candidate.go @@ -15,6 +15,7 @@ import ( "github.com/novox/mesh-host/internal/agent" "github.com/novox/mesh-host/internal/compose/frombus" + "github.com/novox/mesh-host/internal/composer" ) // **A check's candidate, composed in shadow on a call** (novox/hq ADR 0593 ยง7): the node seat's verb compose-candidate. @@ -37,6 +38,10 @@ var errNotReady = errors.New("this node's shadow has not read every input yet") // nothing at all. A node not done by then answers unready, naming what it waits on. var candidateWithin = delivery.ComposeAnswerBound - 10*time.Second +// maxSentInput is the most composition input an answer carries: the bus carries 1 MiB in one message (its max_payload), +// and the answer's own fields and envelope come beside it. +const maxSentInput = 900 << 10 + // warmAtOnce is how many of the indexer's manifests the shadow reads at once before it composes a candidate. const warmAtOnce = 8 @@ -184,15 +189,18 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask delivery if err != nil { return } - // The request's input carries no catalogue (composer.Request.Input): the cases give the manifests. - in.Catalogue = nil - for i := range in.Nodes { - in.Nodes[i].Kept = nil - } - if body, err := json.Marshal(in); err == nil { + // As the sandbox reads it (composer.SentInput, the one shape): this node's own modules as taken in from their + // builds, every other module's manifest left to the cases. + if body, err := json.Marshal(composer.SentInput(in, laid.env.Node)); err == nil { a.Input = body } }) + if len(a.Input) > maxSentInput { + // Past what the bus carries in one answer, the checker would hear nothing: refused, saying so. + size := len(a.Input) + a.Input = nil + return refuse(fmt.Sprintf("the composition input this node composed from is %d bytes, more than the %d an answer on the bus carries", size, maxSentInput)) + } if ctx.Err() != nil { _, on := run.said(c.now()) return unready(fmt.Sprintf("the node did not compose the candidate within %s: it waits on %s", candidateWithin, on))