package composer import ( "context" "encoding/json" "errors" "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "github.com/novox/mesh-host/internal/buckets/bucketstest" engcomp "github.com/novox/mesh-host/internal/compositions" "os" "reflect" "strings" "testing" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/keys" "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"}]}` // withInput is r with in as its input, as a caller puts it on the wire: the request's shape carries the input as JSON. func withInput(t *testing.T, r Request, in compose.Input) Request { t.Helper() raw, err := json.Marshal(in) if err != nil { t.Fatal(err) } if err := json.Unmarshal(raw, &r.Input); err != nil { t.Fatal(err) } return r } // The seats a node's assigned modules claim are held there when the request says the holdings come from the claims. func TestHoldingsComeFromTheClaims(t *testing.T) { m, err := catalogue.Decode([]byte(demo)) if err != nil { t.Fatal(err) } got := HoldingsOf(map[string]catalogue.Manifest{"demo": m}, []compose.Node{{Name: "b", Assigned: []string{"demo"}}, {Name: "a", Assigned: []string{"demo"}}}) if len(got) != 2 || got[0].Node != "a" || got[0].Claim != "node-files" || got[0].Scope != "node" { t.Fatalf("holdings = %+v", got) } } // A request the command cannot run is refused, never answered as a verdict: a case named twice, an unknown depth. func TestARequestItCannotRunIsRefused(t *testing.T) { if _, err := Run(Request{V: Version, Cases: []Case{{Name: "a"}, {Name: "a"}}}); err == nil { t.Error("a case named twice runs") } if _, err := Run(Request{V: Version, Depth: "deep"}); err == nil { t.Error("an unknown depth runs") } // A case's own assignment (issue 1126) composes over a node the input holds, and is refused for one it does not. placed := withInput(t, Request{V: Version, Machines: []string{"a"}, Depth: DepthResolve, Cases: []Case{{Name: "trunk", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}, {Name: "change", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}, Assignments: bucketstest.Raw(map[string]meshmoduleassigner.Assignments{"a": {V: 1, Node: "a", Modules: []meshmoduleassigner.PlacedModule{{Module: "ghost"}}}})}}}, compose.Input{Nodes: []compose.Node{{Name: "a"}}}) if v, err := Run(placed); err != nil || !v.Cases[0].Machines["a"].Composes || v.Cases[1].Machines["a"].Composes || !strings.Contains(v.Cases[1].Machines["a"].Reason, "ghost") { t.Errorf("a case composes its own assignment: %+v, %v", v, err) } placed.Cases[1].Assignments = bucketstest.Raw(map[string]meshmoduleassigner.Assignments{"z": {V: 1, Node: "z"}}) if _, err := Run(placed); err == nil || !strings.Contains(err.Error(), "holds no node for") { t.Errorf("a case placing a node the input lacks: %v", err) } // Issue 1126 (check of mesh-host #200): the module the mesh provides itself (the private network) comes from mesh-sdk, // a second source beside the indexer's manifests the cases carry, and the input carries no catalogue: a node it is // assigned to still composes, and what it provides is provided. wg := overlay.ProvidedModules()[0] provided := withInput(t, Request{V: Version, Machines: []string{"a"}, Depth: DepthResolve, Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}}, compose.Input{Nodes: []compose.Node{{Name: "a", Assigned: []string{wg, "demo"}}}}) 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) } }) // 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: &engcomp.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.NotJudged, NotJudged+"bus") || m.Reason != "" { 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) } // A module the change refuses beside the folded one fails the node in its words, never passed with the rest // composed (review of the sandbox input); held already where the node-engine composed, it stays held as there. app, err := catalogue.Decode([]byte(`{"module": "app", "version": "1", ` + `"resources": [{"id": "env", "type": "file", "path": "/var/lib/app/env", "mode": "0600", "content": "N=${secret:repository}\n"}]}`)) if err != nil { t.Fatal(err) } in.Catalogue["app"] = app in.Nodes[0].Assigned = []string{"bus", "app"} refused, err := Run(request(in)) if err != nil { t.Fatal(err) } if m := refused.Cases[0].Machines["a"]; m.Composes || !strings.Contains(m.Reason, "app: ") || !strings.Contains(m.Reason, "repository") { t.Errorf("a module the change refuses on a node composed as its node-engine does: %+v", m) } // Held composing without the change, blamed for a refusal beyond the read (issue 1960's unset names): no refusal of // the change either (review round 1). in.ComposedHow.HeldBefore = map[string]string{"app": "its route names are unset"} before, err := Run(request(in)) if err != nil { t.Fatal(err) } if m := before.Cases[0].Machines["a"]; !m.Composes { t.Errorf("a module the node holds composing without the change: %+v", m) } in.ComposedHow.HeldBefore = nil in.ComposedHow.Held = map[string]string{"app": "held on the node"} held, err := Run(request(in)) if err != nil { t.Fatal(err) } if m := held.Cases[0].Machines["a"]; !m.Composes { 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. The // case gives the runner its refused verdict, as the checker does for a manifest the change does not bring. 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)}}, Verdicts: bucketstest.Raw(map[string]meshcatalog.VerdictsByDigestValue{"runner": {V: 1, Module: "runner", State: meshcatalogclient.VerdictRefused, Problems: []string{"p"}}})}}} 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) } } }) // ADR 0578 §2, §3 (issue 1921): a module its node-engine holds by the running indexer's verdict gives way when the case, // judging the change's manifest itself, gives it an accepted verdict or none (the running checker drops the change's // own), and this composer reads its bytes whole. It is composed at the build the input carries for it (Input.Holds); // without one it is composed from the case's manifest and the node is not judged, naming it (never a silent pass), or, // where it cannot compose without its build (a tool runner's bundle), composed held as its node-engine holds it, named // so. Bytes that do not decode here, and a case's own refusal, stay held. t.Run("a held module the case judges itself", func(t *testing.T) { app := `{"module": "app", "version": "1", "resources": [{"id": "server", "type": "container", "name": "app-server", "artifact": "server"}], ` + `"build": {"artifacts": [{"name": "server", "kind": "upstream", "from": "nats:2.11@sha256:` + strings.Repeat("a", 64) + `"}]}}` // A module that names a build and composes from its manifest without it. plain := `{"module": "app", "version": "1", "resources": [{"id": "d", "type": "directory", "path": "/var/lib/app"}], ` + `"build": {"artifacts": [{"name": "server", "kind": "upstream", "from": "nats:2.11@sha256:` + strings.Repeat("a", 64) + `"}]}}` runner := `{"module": "app", "version": "1", "tool-runner": true, ` + `"build": {"artifacts": [{"name": "runtime", "kind": "bundle", "language": "go", "from": "cmd/runner", "binary": "runner"}]}}` built, err := catalogue.Decode([]byte(`{"module": "app", "version": "1", "resources": [{"id": "built", "type": "directory", "path": "/var/lib/app-built"}]}`)) if err != nil { t.Fatal(err) } verdict := func(state string) map[string]json.RawMessage { return bucketstest.Raw(map[string]meshcatalog.VerdictsByDigestValue{"app": {V: 1, Module: "app", State: state, Problems: []string{"p"}}}) } const why = "the indexer's judge refused the manifest of app" for _, row := range []struct { name string raw string verdicts map[string]json.RawMessage hold *compose.Hold composes bool // the machine composes, judged path string // a path declared on it words string // in NotJudged, else in the read's refusal }{ {"an accepted verdict, its build carried", app, verdict(meshcatalogclient.VerdictAccepted), &compose.Hold{By: compose.HeldByVerdict, Build: "fp1", Built: &built}, true, "/var/lib/app-built", ""}, {"no verdict, its build carried", app, nil, &compose.Hold{By: compose.HeldByVerdict, Build: "fp1", Built: &built}, true, "/var/lib/app-built", ""}, {"an accepted verdict, its build awaited", plain, verdict(meshcatalogclient.VerdictAccepted), &compose.Hold{By: compose.HeldByVerdict, Build: "fp1"}, false, "", "composed from the change's manifest without its build: app is refused only by the running indexer (" + why + ") and accepted by the check's own validator, and its build fp1 is awaited"}, {"no verdict, from an engine before holds", plain, nil, nil, false, "", "app is held by the running node-engine (" + why + ") and given no verdict by the check, so taken as accepted"}, {"a container, its image awaited", app, nil, &compose.Hold{By: compose.HeldByVerdict}, false, "", "composed with it held as its node-engine holds it, since it does not compose without its build: app is refused only by the running indexer"}, {"a tool runner, its bundle awaited", runner, nil, &compose.Hold{By: compose.HeldByVerdict}, false, "", "composed with it held as its node-engine holds it, since it does not compose without its build: app is refused only by the running indexer"}, {"bytes that do not decode", `{"module": "app", "version": "1", "resources": "none"}`, nil, &compose.Hold{By: compose.HeldByVerdict, Built: &built}, true, "", "does not decode"}, {"the case's own refusal", app, verdict(meshcatalogclient.VerdictRefused), &compose.Hold{By: compose.HeldByVerdict, Built: &built}, true, "", "refused the manifest of app"}, } { man, _ := catalogue.Decode([]byte(app)) in := compose.Input{Catalogue: map[string]catalogue.Manifest{"app": compose.Stub(man)}, Refused: map[string]string{"app": why}, Nodes: []compose.Node{{Name: "a", Assigned: []string{"app"}}}} if row.hold != nil { in.Holds = map[string]compose.Hold{"app": *row.hold} } v, err := Run(withInput(t, Request{V: Version, Machines: []string{"a"}, Depth: DepthCompose, Declarations: true, Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"app": {Raw: json.RawMessage(row.raw)}}, Verdicts: row.verdicts}}}, in)) if err != nil { t.Fatal(err) } m, read := v.Cases[0].Machines["a"], v.Cases[0].Manifests["app"] declared := false for _, p := range m.Declared { declared = declared || p.Path == row.path } switch { case m.Composes != row.composes || (row.path != "" && !declared): t.Errorf("%s: machine %+v", row.name, m) case !row.composes && (m.NotJudged == "" || !strings.Contains(m.NotJudged, row.words)): t.Errorf("%s: not judged %q, want it naming %q", row.name, m.NotJudged, row.words) case row.composes && row.path == "" && (read.Accepted || !strings.Contains(read.Refused, row.words)): t.Errorf("%s: read %+v, want held naming %q", row.name, read, row.words) } } }) 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) } } // The composer judges no manifest (novox/hq ADR 0578 §5): xterm's module.json from mesh-catalog at 835bf22b, whose // config value falls back to "font-size", is read whatever list of predefined keys a request carries (a group touching // xterm failed its composition on this, group check 18df81380db3); a module whose verdict in the input refuses it is // held, composed as its stub, and named in the verdict's words. func TestTheComposerTakesTheVerdictsOfItsInput(t *testing.T) { raw, err := os.ReadFile("testdata/catalogue-835bf22b-xterm.module.json") if err != nil { t.Fatal(err) } r := Request{V: Version, Predefined: catalogue.PredefinedList{"something-else": {Type: "string", Description: "x"}}, Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"xterm": {Raw: raw}}}}} v, err := Run(r) if err != nil { t.Fatal(err) } if got := v.Cases[0].Manifests["xterm"]; !got.Accepted { t.Fatalf("xterm: %+v, want accepted", got) } r.Cases[0].Verdicts = bucketstest.Raw(map[string]meshcatalog.VerdictsByDigestValue{"xterm": {V: 1, Module: "xterm", State: meshcatalogclient.VerdictRefused, Problems: []string{"falls back to a key not listed"}, Judge: "src2:j"}}) shelf, err := parse(r.Cases[0].Manifests, map[string]ManifestRead{}, r.Cases[0].Verdicts) if err != nil || !shelf["xterm"].Stubbed { t.Fatalf("the refused module is not held as its stub: %+v %v", shelf["xterm"], err) } if v, err = Run(r); err != nil { t.Fatal(err) } if got := v.Cases[0].Manifests["xterm"]; got.Accepted || !strings.Contains(got.Refused, "the indexer's judge (src2:j) refused the manifest of xterm: falls back to a key not listed") { t.Fatalf("xterm refused by its verdict: %+v, want refused in the verdict's words", got) } // The verdict as the trunk's checker hands it, version 0 and no judge (group try 1793952957648659842 failed on all // four nodes: "the verdict of f1-circuits is not one: ... the value is version 0"), holds the module too; a later // version is refused. r.Cases[0].Verdicts = map[string]json.RawMessage{"xterm": json.RawMessage(`{"v":0,"module":"xterm","state":"refused","problems":["falls back to a key not listed"],"judge":"","at":"0001-01-01T00:00:00Z"}`)} if v, err = Run(r); err != nil { t.Fatalf("a version-0 verdict of the trunk's checker: %v", err) } if got := v.Cases[0].Manifests["xterm"]; got.Accepted || !strings.Contains(got.Refused, "refused the manifest of xterm: falls back") { t.Fatalf("xterm refused by a version-0 verdict: %+v, want refused", got) } r.Cases[0].Verdicts["xterm"] = json.RawMessage(`{"v":2,"module":"xterm","state":"refused","problems":["p"]}`) if _, err = Run(r); !errors.Is(err, keys.ErrUnknownVersion) { t.Fatalf("a version-2 verdict: %v, want keys.ErrUnknownVersion", err) } }