From 57d852e7daef379aa8d9241e9099e2369f0a46bf Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 06:49:28 +0000 Subject: [PATCH] Test the whole-node composition: Compose accepted by the validator, refused manifests as problems, an unresolvable set as an error, determinism, foreseen outputs, the envelope's order and builds, adoption, the overflow refusal (issue 1298) --- go/compose/node_test.go | 325 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 go/compose/node_test.go diff --git a/go/compose/node_test.go b/go/compose/node_test.go new file mode 100644 index 0000000..a0b82bf --- /dev/null +++ b/go/compose/node_test.go @@ -0,0 +1,325 @@ +package compose + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func resourceIDs(d Declared) []string { + var ids []string + for _, r := range d.Resources { + ids = append(ids, r["type"].(string)+":"+r["id"].(string)) + } + return ids +} + +func has(ids []string, want string) bool { + for _, id := range ids { + if id == want { + return true + } + } + return false +} + +// aSmallMesh is three nodes, a provider and its consumer, a seat and the private network: aMesh with `ace` running a database and +// a resolver holding the mesh's resolver seat on record (the providers and consumer of mesh-controller's facts_test.go +// aMeshWithSecrets, the seat of bindings_test.go storeManifests). +func aSmallMesh(t *testing.T) Input { + t.Helper() + in := threeNodes(t) + for _, m := range storeManifests() { + if m.Module == "resolver" { + in.Catalogue[m.Module] = m + } + } + ace := nodeOf(t, &in, "ace") + ace.Assigned = append(ace.Assigned, "resolver") + in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "ace", Module: "resolver", Site: "there"}} + return in +} + +// 1298/AC1: each node of a small mesh composes, its body is accepted by the node-engine's validator, and it names the resources the +// controller composes for the same case: the private network's files on the nodes that run it, the provider's received file, the +// consumer's file. +func TestEachNodeOfASmallMeshComposesAndItsBodyIsAcceptedByTheValidator(t *testing.T) { + in := aSmallMesh(t) + want := map[string][]string{ + "anchor": {"package:mesh-wireguard.overlay-tools", "file:mesh-wireguard.overlay-config", "service:mesh-wireguard.overlay-up", "file:objects.received-s3-bucket"}, + "laptop": {"package:mesh-wireguard.overlay-tools", "file:mesh-wireguard.overlay-config", "service:mesh-wireguard.overlay-up", "file:files.config"}, + "ace": nil, + } + for node, ids := range want { + d, problems, err := Compose(in, node) + if err != nil || len(problems) != 0 { + t.Fatalf("%s: %v %v", node, err, problems) + } + for _, id := range ids { + if !has(resourceIDs(d), id) { + t.Errorf("%s does not name %s: %v", node, id, resourceIDs(d)) + } + } + body, err := d.Body() + if err != nil { + t.Fatal(err) + } + var parsed struct { + Declaration int `json:"declaration"` + Resources []map[string]any `json:"resources"` + } + if err := json.Unmarshal(body, &parsed); err != nil || parsed.Declaration != 1 || len(parsed.Resources) != len(d.Resources) { + t.Errorf("%s: body %s %v", node, body, err) + } + } + // ace runs a resolver and a database and nothing the mesh declares of its own: an empty declaration says it owns nothing. + d, _, _ := Compose(in, "ace") + if body, _ := d.Body(); !strings.Contains(string(body), `"owns_nothing":true`) { + t.Errorf("an empty declaration is not said to be deliberate: %s", body) + } +} + +// 1298/AC2 (mesh-controller merge_gate_integration_test.go TestIssue236AManifestTheNodeEngineRefusesFailsThePullRequest, the manifest): +// a service that omits its state is accepted by the catalogue's check and refused by the node-engine; Compose returns the validator's +// problems in the node-engine's words and no error. +func TestAManifestTheNodeEngineRefusesComesBackAsProblemsAndNoError(t *testing.T) { + in := aMesh(t) + var lemurs catalogue.Manifest + if err := json.Unmarshal([]byte(`{"module":"lemurs","version":"1","capabilities":["systemd"], + "resources":[{"id":"service","type":"service","unit":"lemurs.service","boot":"enabled"}]}`), &lemurs); err != nil { + t.Fatal(err) + } + in.Catalogue["lemurs"] = lemurs + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "lemurs") + d, problems, err := Compose(in, "laptop") + if err != nil { + t.Fatalf("a refusal of the validator is an error: %v", err) + } + if len(problems) == 0 || !strings.Contains(strings.Join(problems, "\n"), "lemurs.service") { + t.Fatalf("problems are %v", problems) + } + if !has(resourceIDs(d), "service:lemurs.service") { + t.Errorf("the declaration does not carry the resource: %v", resourceIDs(d)) + } + // And the same, as the validator says it of the body. + body, _ := d.Body() + if again := strings.Join(problems, "\n"); again == "" || !strings.Contains(string(body), "lemurs.service") { + t.Errorf("the body does not name the service: %s", body) + } +} + +// 1298/AC3: a node whose own set does not compose returns an error and no declaration, told apart from an unreadable Input. +func TestANodeWhoseSetDoesNotComposeReturnsAnErrorAndNoDeclaration(t *testing.T) { + in := aMesh(t) + in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two") + d, problems, err := Compose(in, "laptop") + if err == nil || !IsUnresolvable(err) || d.Resources != nil || problems != nil { + t.Fatalf("a set that does not compose: %v %v %v", d.Resources, problems, err) + } + if _, _, err := Compose(in, "nowhere"); err == nil || IsUnresolvable(err) { + t.Fatalf("a node the Input does not have: %v", err) + } +} + +// 1298/AC4: composing the same Input twice gives byte-identical bodies, and the Input is unchanged. +func TestComposingTwiceGivesTheSameBytesAndChangesNothing(t *testing.T) { + mk := func() Input { + in := aSmallMesh(t) + nodeOf(t, &in, "anchor").Account = "jochens" + nodeOf(t, &in, "laptop").Sequence = 7 + return in + } + in := mk() + for _, node := range []string{"anchor", "laptop", "ace"} { + a, _, err := Compose(in, node) + if err != nil { + t.Fatal(err) + } + b, _, err := Compose(in, node) + if err != nil { + t.Fatal(err) + } + ab, _ := a.Body() + bb, _ := b.Body() + if string(ab) != string(bb) { + t.Errorf("%s: two compositions differ:\n%s\n%s", node, ab, bb) + } + } + unchanged(t, mk, func(in Input) { + for _, node := range []string{"anchor", "laptop", "ace"} { + if _, _, err := Compose(in, node); err != nil { + t.Fatal(err) + } + } + }) +} + +// 1298/AC5: what a send would make is named in the result as foreseen and never invented: an own secret, a port, a core box, a grant's +// sealed copy. +func TestWhatASendWouldMakeIsNamedAsForeseenAndNeverInvented(t *testing.T) { + in := aGrantMesh(t) + in.Issued[0].ForProvider = "" + in.Catalogue["assistant"] = anAssistant() + in.Catalogue["web"] = aWebModule() + in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", + OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}} + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "assistant", "web", "nats") + anchor.SealingKey = keyOf(t, "anchor") + anchor.Stated, anchor.Contract = true, VaultBoxContract + in.BusMinted = []string{"anchor.nats"} + d, problems, err := Compose(in, "anchor") + if err != nil || len(problems) != 0 { + t.Fatalf("%v %v", err, problems) + } + if !has(d.Foreseen, "assistant/password") || !has(d.Foreseen, "nats/broker") { + t.Errorf("own secrets foreseen: %v", d.Foreseen) + } + if len(d.ForeseenPorts) != 1 || d.ForeseenPorts[0] != "web:8080" { + t.Errorf("foreseen ports: %v", d.ForeseenPorts) + } + if len(d.CoreBoxesWanted) != 1 || d.CoreBoxesWanted[0].Module != "nats" { + t.Errorf("core boxes wanted: %v", d.CoreBoxesWanted) + } + if !has(d.StoodIn, "the grant of s3-bucket for files on laptop") { + t.Errorf("stood in for: %v", d.StoodIn) + } + body, _ := d.Body() + if strings.Contains(string(body), secretValue) { + t.Error("a secret value is in the body") + } +} + +// 1298/AC6: the body carries the sequence, epoch and generation the Input gives, and the module of each resource and the carried builds +// only to a node whose Input says it reads modules (novox/hq issue 1145). +func TestTheBodyCarriesTheOrderTheInputGivesAndTheBuildsOnlyToAnEngineThatReadsThem(t *testing.T) { + in := aSmallMesh(t) + laptop := nodeOf(t, &in, "laptop") + laptop.Sequence, laptop.Epoch, laptop.Generation = 42, 57, 9 + in.CurrentBuilds = map[string]string{"files": "commit-files", "mesh-wireguard": "commit-wg"} + in.BuildIDs = map[string]map[string]string{"files": {"commit-files": "build-7"}} + // An assignment of another commit than the one carried does not name the build; the build made from the commit carried does. + laptop.Runs = map[string]Run{"mesh-wireguard": {Commit: "commit-wg", Build: "build-9"}, "files": {Commit: "an-older-commit", Build: "build-6"}} + d, _, err := Compose(in, "laptop") + if err != nil { + t.Fatal(err) + } + body, _ := d.Body() + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatal(err) + } + if got["sequence"] != float64(42) || got["epoch"] != float64(57) || got["generation"] != float64(9) { + t.Errorf("the order is %v %v %v", got["sequence"], got["epoch"], got["generation"]) + } + if _, there := got["builds"]; there { + t.Errorf("builds are sent to an engine that does not read them: %s", body) + } + for _, r := range got["resources"].([]any) { + if _, there := r.(map[string]any)["module"]; there { + t.Errorf("a module is named on a resource of an engine that does not read them: %v", r) + } + } + laptop.ReadsModules = true + d, _, err = Compose(in, "laptop") + if err != nil { + t.Fatal(err) + } + body, _ = d.Body() + got = nil + if err := json.Unmarshal(body, &got); err != nil { + t.Fatal(err) + } + builds, _ := got["builds"].(map[string]any) + if builds["files"] != "build-7" || builds["mesh-wireguard"] != "build-9" { + t.Errorf("builds are %v", builds) + } + named := false + for _, r := range got["resources"].([]any) { + if r.(map[string]any)["id"] == "files.config" && r.(map[string]any)["module"] == "files" { + named = true + } + } + if !named { + t.Errorf("a resource is not named for its module: %s", body) + } + // Zero is not sent at all. + laptop.Sequence, laptop.Epoch, laptop.Generation = 0, 0, 0 + d, _, _ = Compose(in, "laptop") + body, _ = d.Body() + for _, key := range []string{`"sequence"`, `"epoch"`, `"generation"`} { + if strings.Contains(string(body), key) { + t.Errorf("%s is sent as zero: %s", key, body) + } + } +} + +// A module left out of a build set keeps the build it was last sent (novox/hq issue 259): the machine keeps that one. +func TestAModuleLeftOutKeepsTheBuildItWasLastSent(t *testing.T) { + c := newComposer(Input{CurrentBuilds: map[string]string{"a": "new-a", "b": "new-b"}}) + plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "a"}, {Module: "b"}, {Module: "c"}}} + node := Node{SentBuilds: map[string]string{"b": "old-b", "c": "old-c"}, SentKnown: true} + got := c.carriedOn(node, plan, map[string]string{"b": "its settings cannot compose"}) + if got["a"] != "new-a" || got["b"] != "old-b" || got["c"] != "" { + t.Errorf("carried builds are %v", got) + } + node.SentKnown = false + if got := c.carriedOn(node, plan, map[string]string{"b": "x"}); len(got) != 2 || got["b"] != "" { + t.Errorf("when what was sent is not known: %v", got) + } +} + +// An adopted node is told which modules were taken and, for every module it runs that is not, the ids of its resources +// (novox/hq ADR 0100 and 0103); a converged node is told nothing, and its body is as it was before adoption. +func TestAnAdoptedNodeIsToldWhatWasTakenAndWhatIsKept(t *testing.T) { + in := aSmallMesh(t) + d, _, err := Compose(in, "laptop") + if err != nil || d.Adoption != nil { + t.Fatalf("a converged node: %v %v", d.Adoption, err) + } + laptop := nodeOf(t, &in, "laptop") + laptop.Adopted, laptop.Taken = true, []string{"mesh-wireguard"} + d, problems, err := Compose(in, "laptop") + if err != nil || len(problems) != 0 { + t.Fatalf("%v %v", err, problems) + } + if d.Adoption == nil || len(d.Adoption.Taken) != 1 || d.Adoption.Taken[0] != "mesh-wireguard" || + len(d.Adoption.Untaken["files"]) == 0 || d.Adoption.Untaken["mesh-wireguard"] != nil { + t.Fatalf("adoption is %+v", d.Adoption) + } + if body, _ := d.Body(); !strings.Contains(string(body), `"adoption"`) { + t.Errorf("the adoption is not on the wire: %s", body) + } +} + +// Issue 263 (merge_gate_integration_test.go TestIssue263AnIdentityARealMachineNameOverflows...): a change makes the network manager +// require the object store's provision; on a machine whose name is six characters its identity is 26 against a bound of 20. The +// composition of the provider returns the refusal, naming the module and the provision. +func TestIssue263AnIdentityARealMachineNameOverflowsIsARefusal(t *testing.T) { + in := aGrantMesh(t) + in.Catalogue["resolver"] = storeManifests()[1] + in.Catalogue["networkmanager"] = catalogue.Manifest{Module: "networkmanager", Version: "1", Requires: []string{"wildcard-resolution", "s3-bucket"}} + laptop, anchor := nodeOf(t, &in, "laptop"), nodeOf(t, &in, "anchor") + laptop.Assigned = append(laptop.Assigned, "networkmanager") + anchor.Assigned = append(anchor.Assigned, "resolver") + in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}} + in.Issued = append(in.Issued, Issued{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "networkmanager", Provider: "anchor", ForProvider: "sealed"}) + d, problems, err := Compose(in, "anchor") + if err != nil || len(problems) != 0 { + t.Fatalf("%v %v", err, problems) + } + refusals := strings.Join(d.Refusals(), "\n") + if !strings.Contains(refusals, "networkmanager") || !strings.Contains(refusals, "s3-bucket") || !strings.Contains(refusals, "laptop") { + t.Fatalf("the refusal does not name the module, the provision and the machine: %q", refusals) + } + // And the consumer that fits is granted. + if len(d.Withheld) != 1 || d.Withheld[0].Module != "networkmanager" { + t.Errorf("withheld %+v", d.Withheld) + } +}