diff --git a/internal/compose/input_held.go b/internal/compose/input_held.go new file mode 100644 index 0000000..2d0befd --- /dev/null +++ b/internal/compose/input_held.go @@ -0,0 +1,97 @@ +package compose + +import ( + "encoding/json" + "sort" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// What an input says of its held modules on the wire (issue 774, the group check of feat/l3-final 278aa5f0ccbe). A held +// module stands in the input as its stub (Stub), and the stub's mark (catalogue.Manifest.Stubbed) is no field of a +// manifest's JSON: an input a node-engine sent the check's sandbox lost it, so the sandbox read node-tools, held by its +// manifest's verdict, as a tool runner with no bundle and refused every module of ace, g14 and shanks for "its build +// produced 0 bundle(s)" instead of naming the verdict. So the input carries the mark itself, by module, for its catalogue +// and for each node's kept manifests, and a reader marks each again. An input from a node-engine before the mark (no +// Held) is read by its contract: a module Refused names stands in Catalogue as its stub. + +// heldMarks are the modules an input holds as their stubs: those of its catalogue, and those each node keeps, by node. +type heldMarks struct { + Catalogue []string `json:",omitempty"` + Kept map[string][]string `json:",omitempty"` +} + +// inputFields is Input without its JSON methods. +type inputFields Input + +// inputWire is an input as it crosses the bus: its fields, and the modules it holds as their stubs. +type inputWire struct { + inputFields + Held *heldMarks `json:",omitempty"` +} + +// MarshalJSON writes the input with the modules it holds as their stubs named beside it (heldMarks). +func (in Input) MarshalJSON() ([]byte, error) { + w := inputWire{inputFields: inputFields(in)} + marks := heldMarks{Catalogue: stubbedOf(in.Catalogue)} + for _, n := range in.Nodes { + if kept := stubbedOf(n.Kept); len(kept) > 0 { + if marks.Kept == nil { + marks.Kept = map[string][]string{} + } + marks.Kept[n.Name] = kept + } + } + if len(marks.Catalogue) > 0 || len(marks.Kept) > 0 { + w.Held = &marks + } + return json.Marshal(w) +} + +// UnmarshalJSON reads an input and marks again each module it holds as its stub: those it names held, and, by the +// input's contract, every module Refused names that its catalogue holds. +func (in *Input) UnmarshalJSON(raw []byte) error { + var w inputWire + if err := json.Unmarshal(raw, &w); err != nil { + return err + } + *in = Input(w.inputFields) + held := map[string]bool{} + for m := range in.Refused { + held[m] = true + } + if w.Held != nil { + for _, m := range w.Held.Catalogue { + held[m] = true + } + } + for m := range held { + if man, ok := in.Catalogue[m]; ok { + man.Stubbed = true + in.Catalogue[m] = man + } + } + if w.Held != nil { + for i := range in.Nodes { + for _, m := range w.Held.Kept[in.Nodes[i].Name] { + if man, ok := in.Nodes[i].Kept[m]; ok { + man.Stubbed = true + in.Nodes[i].Kept[m] = man + } + } + } + } + return nil +} + +// stubbedOf is each module of manifests held as its stub, sorted. +func stubbedOf(manifests map[string]catalogue.Manifest) []string { + var out []string + for name, m := range manifests { + if m.Stubbed { + out = append(out, name) + } + } + sort.Strings(out) + return out +} diff --git a/internal/composer/composer.go b/internal/composer/composer.go index 61aad6c..b5a8898 100644 --- a/internal/composer/composer.go +++ b/internal/composer/composer.go @@ -150,6 +150,14 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) { for name, m := range base.Catalogue { shelf[name] = m } + // A module the input's node-engine holds as refused (compose.Input.Refused: its verdict, or bytes that do not decode) is + // held here as there, and its read says why, whatever the case's own verdicts: the check then names the cause the node + // holds it for, never a pass the node would not compose, nor a rule about its stub (issue 774, group check 278aa5f0ccbe). + for name, why := range base.Refused { + if read, carried := cv.Manifests[name]; carried && read.Accepted { + cv.Manifests[name] = ManifestRead{Refused: "its node-engine holds it: " + why} + } + } in.Catalogue = shelf in.Nodes = slices.Clone(in.Nodes) in.Holdings = slices.Clone(in.Holdings) diff --git a/internal/composer/composer_test.go b/internal/composer/composer_test.go index 73ecf12..18c5309 100644 --- a/internal/composer/composer_test.go +++ b/internal/composer/composer_test.go @@ -179,6 +179,47 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { t.Errorf("a module the node-engine holds too: %+v", m) } }) + // Issue 774 (the group check of feat/l3-final 278aa5f0ccbe): a tool runner its node-engine holds by its manifest's + // verdict is sent as its stub, and the input says it holds it: the sandbox composes the node with it held, never refuses + // it for its stub's missing bundle. An input from an engine before the mark is read by Refused alone, the same way. + t.Run("a refused tool runner the engine sends as its stub", func(t *testing.T) { + raw := `{"module": "runner", "version": "1", "tool-runner": true, ` + + `"build": {"artifacts": [{"name": "runtime", "kind": "bundle", "language": "go", "from": "cmd/runner", "binary": "runner"}]}}` + man, err := catalogue.Decode([]byte(raw)) + if err != nil { + t.Fatal(err) + } + in := compose.Input{Catalogue: map[string]catalogue.Manifest{"runner": compose.Stub(man)}, + Refused: map[string]string{"runner": "the indexer's judge refused the manifest of runner"}, + Nodes: []compose.Node{{Name: "a", Assigned: []string{"runner"}}}} + wire, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + var back compose.Input + if err := json.Unmarshal(wire, &back); err != nil || !back.Catalogue["runner"].Stubbed || !strings.Contains(string(wire), `"Held"`) { + t.Fatalf("the input's held mark across the wire: %s, %v", wire, err) + } + older := strings.Replace(string(wire), `"Held":{"Catalogue":["runner"]}`, `"Unsaid":null`, 1) + for name, sent := range map[string]string{"with the mark": string(wire), "from an engine before it": older} { + var r Request + r = Request{V: Version, Machines: []string{"a"}, Depth: DepthCompose, + Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"runner": {Raw: json.RawMessage(raw)}}}}} + if err := json.Unmarshal([]byte(sent), &r.Input); err != nil { + t.Fatal(err) + } + v, err := Run(r) + if err != nil { + t.Fatal(err) + } + if m := v.Cases[0].Machines["a"]; !m.Composes || strings.Contains(m.Reason, "bundle") { + t.Errorf("%s: the node with its tool runner held: %+v", name, m) + } + if read := v.Cases[0].Manifests["runner"]; read.Accepted || !strings.Contains(read.Refused, "the indexer's judge refused the manifest of runner") { + t.Errorf("%s: the runner's read names not why its node-engine holds it: %+v", name, read) + } + } + }) 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)