Files
mesh-sdk/go/compose/node_test.go
T

385 lines
16 KiB
Go

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)
}
}
// Round 1, finding 1 (plan.go keepRecorded): a recorded module kept at the build a machine runs is that machine's own resolution
// and no other's; the commit carried for it is the kept one.
func TestAKeptModuleChangesOnlyTheResolutionOfTheNodeThatKeepsIt(t *testing.T) {
in := aSmallMesh(t)
in.Catalogue["tool"] = catalogue.Manifest{Module: "tool", Version: "2"}
for _, name := range []string{"anchor", "laptop"} {
n := nodeOf(t, &in, name)
n.Assigned = append(n.Assigned, "tool")
}
anchor := nodeOf(t, &in, "anchor")
anchor.Kept = map[string]catalogue.Manifest{"tool": {Module: "tool", Version: "1"}}
versionOn := func(in Input, node string) string {
t.Helper()
got, err := Resolve(in, node)
if err != nil {
t.Fatal(err)
}
for _, m := range got.Plan.Modules {
if m.Module == "tool" {
return m.Version
}
}
t.Fatalf("no tool on %s", node)
return ""
}
if v := versionOn(in, "anchor"); v != "1" {
t.Errorf("the node that keeps it runs version %s", v)
}
if v := versionOn(in, "laptop"); v != "2" {
t.Errorf("another node runs version %s: a kept build leaked", v)
}
if got := in.Catalogue["tool"].Version; got != "2" {
t.Errorf("the Input's catalogue was changed: %s", got)
}
// And the commit carried for it is the kept one.
c := newComposer(in)
plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "tool"}, {Module: "other"}}}
in.CurrentBuilds = map[string]string{"tool": "current", "other": "current-other"}
c = newComposer(in)
node := Node{KeptBuilds: map[string]string{"tool": "kept-commit"}}
if got := c.carriedOn(node, plan, nil); got["tool"] != "kept-commit" || got["other"] != "current-other" {
t.Errorf("carried builds are %v", got)
}
}
// And the rest of the mesh, as a node composes against it, offers what each other node's kept build offers, not the current one.
func TestTheRestOfTheMeshReadsEachOtherNodesKeptBuild(t *testing.T) {
in := threeNodes(t)
w, _, _, err := TheRestOfTheMesh(in, "laptop")
if err != nil || len(providers(w, "s3-bucket")) != 1 {
t.Fatalf("before: %v %v", providers(w, "s3-bucket"), err)
}
nodeOf(t, &in, "anchor").Kept = map[string]catalogue.Manifest{"objects": {Module: "objects", Version: "0"}}
w, _, _, err = TheRestOfTheMesh(in, "laptop")
if err != nil || len(providers(w, "s3-bucket")) != 0 {
t.Errorf("a node keeping a build that offers nothing still offers: %v %v", providers(w, "s3-bucket"), err)
}
}