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)

This commit is contained in:
2026-10-20 06:49:28 +00:00
parent 6be65acb57
commit 57d852e7da
+325
View File
@@ -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)
}
}