Files
mesh-sdk/go/delivery/placement_test.go
T

287 lines
14 KiB
Go

package delivery
import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// ADR 0549 §1, §2: the placement key and file, the value's refusals, its reading as the composition reads a node, what
// a change says, and the catalogue's check of every file.
func TestPlacement(t *testing.T) {
t.Run("key and file", func(t *testing.T) {
key, err := PlacementKey("g14")
if err != nil || key != "placement.g14" {
t.Fatalf("PlacementKey = %q, %v", key, err)
}
if n, err := ParsePlacementKey(key); err != nil || n != "g14" {
t.Fatalf("ParsePlacementKey = %q, %v", n, err)
}
for _, bad := range []string{"want.g14.x", "placement.", "placement.a.b"} {
if _, err := ParsePlacementKey(bad); err == nil {
t.Errorf("ParsePlacementKey(%q) took it", bad)
}
}
if _, err := PlacementKey("a.b"); err == nil {
t.Error("PlacementKey took a dotted node")
}
if PlacementBucket != "mesh-delivery-indexer_placement" || PlacementFile("ace") != "placement/ace.json" {
t.Fatalf("bucket %q, file %q", PlacementBucket, PlacementFile("ace"))
}
for p, want := range map[string]string{"placement/ace.json": "ace", "./placement/ace.json": "ace",
"placement/x/ace.json": "", "modules/ace.json": "", "placement/ace.yaml": ""} {
if n, ok := PlacementFileNode(p); n != want || ok != (want != "") {
t.Errorf("PlacementFileNode(%q) = %q, %v", p, n, ok)
}
}
})
g14 := Placement{Node: "g14", Revision: "abc",
Decided: NodeDecided{Endpoint: "g14.example:51820", Site: "home", Address: "10.77.0.2", Account: "op", Taken: []string{"zsh"}},
Modules: []PlacedModule{
{Module: "zsh"},
{Module: "app", Ports: map[string]int{"8080": 18080}, Bindings: map[string]Provider{"postgres": {Node: "novox", Module: "postgres"}}},
},
Pins: map[string]Provider{"shell": {Node: "g14", Module: "zsh"}},
Holds: []Holding{{Claim: "node-shell", Scope: "node", Module: "zsh"}},
}
novox := Placement{Node: "novox", Decided: NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []PlacedModule{{Module: "postgres", Ports: map[string]int{"5432": 5432}}},
Holds: []Holding{{Claim: "mesh-store", Scope: "mesh", Module: "postgres"}}}
t.Run("value", func(t *testing.T) {
raw, err := EncodePlacement(g14)
if err != nil {
t.Fatal(err)
}
back, err := DecodePlacement(raw)
if err != nil || back.V != Version || !SamePlacement(back, g14) {
t.Fatalf("round trip: %+v, %v", back, err)
}
moved := g14
moved.Revision = "def"
if !SamePlacement(g14, moved) {
t.Error("a new revision alone counts as a change")
}
if _, err := DecodePlacement([]byte(`{"v":9,"node":"g14","modules":[]}`)); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("version 9: %v", err)
}
for name, bad := range map[string]string{
"module twice": `{"v":1,"node":"g14","modules":[{"module":"a"},{"module":"a"}]}`,
"port not number": `{"v":1,"node":"g14","modules":[{"module":"a","ports":{"http":80}}]}`,
"host port zero": `{"v":1,"node":"g14","modules":[{"module":"a","ports":{"80":0}}]}`,
"binding no node": `{"v":1,"node":"g14","modules":[{"module":"a","bindings":{"pg":{"module":"pg"}}}]}`,
"pin no module": `{"v":1,"node":"g14","modules":[],"pins":{"pg":{"node":"x"}}}`,
"hold not placed": `{"v":1,"node":"g14","modules":[],"holds":[{"claim":"s","module":"a"}]}`,
"node not a name": `{"v":1,"node":"g.14","modules":[]}`,
"value not shaped": `{"v":1,"node":"g14","modules":{}}`,
} {
if _, err := DecodePlacement([]byte(bad)); err == nil {
t.Errorf("%s: taken", name)
}
}
})
t.Run("as the composition reads it", func(t *testing.T) {
if got := g14.Assigned(); !reflect.DeepEqual(got, []string{"zsh", "app"}) {
t.Errorf("Assigned = %v, want the file's order", got)
}
if got := g14.ChosenPins(); !reflect.DeepEqual(got, map[string]catalogue.Chosen{"shell": {Node: "g14", Module: "zsh"}}) {
t.Errorf("ChosenPins = %v", got)
}
if got := g14.Bound(); !reflect.DeepEqual(got, map[string]map[string]catalogue.Chosen{"app": {"postgres": {Node: "novox", Module: "postgres"}}}) {
t.Errorf("Bound = %v", got)
}
if got := g14.HostPorts(); !reflect.DeepEqual(got, map[string]map[int]int{"app": {8080: 18080}}) {
t.Errorf("HostPorts = %v", got)
}
if got := g14.Held(); !reflect.DeepEqual(got, []catalogue.Held{{Claim: "node-shell", Scope: "node", Node: "g14", Module: "zsh"}}) {
t.Errorf("Held = %v", got)
}
layered := PlacedModule{Module: "app", HasMesh: true, Layer: map[string]any{"expose": map[string]any{"80": "mesh"}}}
want := []catalogue.Layer{
{From: catalogue.MeshWideLayer, Values: map[string]any{}},
{From: "g14", Values: map[string]any{"mode": "x"}, Placement: map[string]any{"expose": map[string]any{"80": "mesh"}}},
}
if got := layered.Layers("g14", map[string]any{"mode": "x"}); !reflect.DeepEqual(got, want) {
t.Errorf("Layers = %#v, want %#v", got, want)
}
if got := (PlacedModule{Module: "a"}).Layers("g14", map[string]any{"k": 1}); len(got) != 1 || got[0].From != "g14" {
t.Errorf("values alone make the node's layer: %#v", got)
}
if got := (PlacedModule{Module: "a", HasLayer: true}).Layers("g14", nil); len(got) != 1 || got[0].Values == nil {
t.Errorf("an empty layer of the node's own is kept, with empty values: %#v", got)
}
if got := (PlacedModule{Module: "a"}).Layers("g14", nil); got != nil {
t.Errorf("no layer and no value is no layer: %#v", got)
}
empty := Placement{Node: "x"}
if empty.ChosenPins() != nil || empty.Bound() != nil || empty.HostPorts() != nil || len(empty.Held()) != 0 {
t.Error("an empty placement reads as something")
}
})
t.Run("what a change says", func(t *testing.T) {
after := g14
after.Revision = "def"
after.Modules = []PlacedModule{{Module: "app", Ports: map[string]int{"8080": 18081}}, {Module: "new"}}
got := ChangeOf(g14, after)
want := PlacementChange{Node: "g14", Revision: "def", Added: []string{"new"}, Removed: []string{"zsh"}, Changed: []string{"app"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("ChangeOf = %+v, want %+v", got, want)
}
pinned := g14
pinned.Pins = nil
if got := ChangeOf(g14, pinned); !reflect.DeepEqual(got.Changed, []string{"app", "zsh"}) {
t.Errorf("a pin change changes every module on the node: %+v", got)
}
if got := ChangeOf(Placement{}, novox); !reflect.DeepEqual(got.Added, []string{"postgres"}) || got.Node != "novox" {
t.Errorf("a new node: %+v", got)
}
})
t.Run("the catalogue's check", func(t *testing.T) {
held := func(m string) bool { return m != "ghost" }
enrolled := func(n string) bool { return n != "stranger" }
if r := CheckPlacements(map[string]Placement{"g14": g14, "novox": novox}, held, enrolled); len(r) != 0 {
t.Fatalf("a sound placement is refused: %v", r)
}
// A replicated mesh seat (ADR 0223) is held on two nodes; a seat that is not replicated is refused there.
for seat, refused := range map[string]bool{"mesh-dns-resolver": false, "mesh-broker": true} {
a := Placement{Node: "ace", Modules: []PlacedModule{{Module: "dns"}}, Holds: []Holding{{Claim: seat, Scope: "mesh", Module: "dns"}}}
b := a
b.Node = "novox"
r := CheckPlacements(map[string]Placement{"ace": a, "novox": b}, held, enrolled)
if (len(r) != 0) != refused {
t.Errorf("%s on two nodes: refusals %v, want refused %v", seat, r, refused)
}
}
clash := g14
clash.Modules = append([]PlacedModule{{Module: "ghost", Ports: map[string]int{"80": 18080}}}, g14.Modules...)
clash.Modules[2] = PlacedModule{Module: "app", Ports: map[string]int{"8080": 18080}, Bindings: map[string]Provider{"postgres": {Node: "ace", Module: "postgres"}}}
clash.Pins = map[string]Provider{"shell": {Node: "g14", Module: "bash"}}
twice := novox
twice.Node = "stranger"
twice.Decided.Address = "10.77.0.2"
r := CheckPlacements(map[string]Placement{"g14": clash, "novox": novox, "stranger": twice}, held, enrolled)
for _, want := range []string{"holds no module ghost", "ghost and app are both published on host port 18080",
"bound to ace/postgres", "pin of shell names g14/bash", "stranger is not an enrolled node",
"mesh-store, held once for the mesh, is held on novox and stranger",
"g14 and stranger both have the address 10.77.0.2", "novox, stranger are each placed as it"} {
found := false
for _, line := range r {
found = found || strings.Contains(line, want)
}
if !found {
t.Errorf("no refusal says %q; got %v", want, r)
}
}
})
// Issue 1126: a catalogue pull request's placement, one candidate key per node, and each node's answer in its have.
t.Run("candidates", func(t *testing.T) {
key, err := PlacementCandidateKey("feat/1.2", "g14")
if err != nil || key != "feat/1=2e2.g14" {
t.Fatalf("PlacementCandidateKey = %q, %v", key, err)
}
if g, n, err := ParsePlacementCandidateKey(key); err != nil || g != "feat/1.2" || n != "g14" {
t.Fatalf("ParsePlacementCandidateKey = %q, %q, %v", g, n, err)
}
for _, bad := range []string{"g14", ".g14", "feat.g.14", "feat.", "fe=t.g14"} {
if _, _, err := ParsePlacementCandidateKey(bad); err == nil {
t.Errorf("ParsePlacementCandidateKey(%q) took it", bad)
}
}
if PlacementCandidatesBucket != "mesh-delivery-indexer_placement-candidates" {
t.Errorf("bucket %q", PlacementCandidatesBucket)
}
pull := Head{Repository: CatalogueRepository, Number: 7, Head: "h1"}
for name, c := range map[string]PlacementCandidate{
"placed": {Group: "feat/x", Pull: pull, Placement: &g14},
"removed": {Group: "feat/x", Pull: pull},
} {
raw, err := EncodePlacementCandidate(c)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
back, err := DecodePlacementCandidate(raw)
if err != nil || back.Group != c.Group || back.Pull != pull || (back.Placement == nil) != (c.Placement == nil) ||
(c.Placement != nil && !SamePlacement(*back.Placement, g14)) {
t.Errorf("%s: round trip %+v, %v", name, back, err)
}
}
for name, c := range map[string]PlacementCandidate{
"no group": {Pull: pull},
"no head": {Group: "g", Pull: Head{Repository: CatalogueRepository, Number: 7}},
"bad place": {Group: "g", Pull: pull, Placement: &Placement{Node: "a.b"}},
"no repo form": {Group: "g", Pull: Head{Repository: "mesh-catalog", Number: 7, Head: "h"}},
} {
if _, err := EncodePlacementCandidate(c); err == nil {
t.Errorf("%s: taken", name)
}
}
answers := map[string]CandidateAnswer{
"same": {Head: "h1", Revision: 3, Verdict: CandidateSame, Same: []string{"app", "node"}},
"differs": {Head: "h1", Revision: 3, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}},
"refused": {Head: "h1", Revision: 3, Verdict: CandidateRefused, Refused: "unbound postgres"},
}
n := HaveNode{Composed: "sha256:" + strings.Repeat("a", 64), Sealed: []string{"box"}, Candidates: answers}
raw, err := EncodeHaveNode(n)
if err != nil {
t.Fatal(err)
}
back, err := DecodeHaveNode(raw)
if err != nil || !SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Candidates: answers}) {
t.Fatalf("a node's answers changed on the way: %+v, %v", back, err)
}
if SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed}) {
t.Error("a node's have without its answers counts as the same")
}
// CompareComposed: the bus's user list is left out on both sides; anything else that differs is named.
file := func(id, content string) json.RawMessage {
return json.RawMessage(`{"id":"` + id + `","type":"file","module":"nats","path":"/etc/` + id + `","mode":"0600","content":"` + content + `"}`)
}
fp := "src2:" + strings.Repeat("a", 64)
comp := func(rs ...json.RawMessage) Composed {
return Composed{Node: "novox", Module: "nats", FP: fp, Resources: rs}
}
for name, tc := range map[string]struct {
composed, running Composed
resource, why string
}{
"user list alone": {comp(file("conf", "a"), file("nats.bus-users", "stand-in")), comp(file("conf", "a"), file("nats.bus-users", "real")), "", ""},
"a resource": {comp(file("conf", "a")), comp(file("conf", "b")), "conf", "the resource differs"},
"one side": {comp(file("conf", "a"), file("more", "x")), comp(file("conf", "a")), "more", "composed, and not in what runs"},
"order": {comp(file("conf", "a"), file("more", "x")), comp(file("more", "x"), file("conf", "a")), "", "the same resources in another order"},
} {
same, differs := CompareComposed(map[string]Composed{"novox.nats." + fp: tc.composed}, []Composed{tc.running})
if tc.why == "" {
if len(differs) != 0 || len(same) != 1 {
t.Errorf("%s: same %v, differs %+v", name, same, differs)
}
} else if len(differs) != 1 || differs[0].Resource != tc.resource || differs[0].Why != tc.why {
t.Errorf("%s: differs %+v, want %s: %s", name, differs, tc.resource, tc.why)
}
}
if _, differs := CompareComposed(nil, []Composed{{Node: "novox", Resources: []json.RawMessage{}}}); len(differs) != 1 || differs[0].Module != "node" {
t.Errorf("the node's own composition run and not composed: %+v", differs)
}
for name, a := range map[string]CandidateAnswer{
"no head": {Verdict: CandidateSame},
"same with a diff": {Head: "h", Verdict: CandidateSame, Differs: answers["differs"].Differs},
"differs with none": {Head: "h", Verdict: CandidateDiffers},
"refused no reason": {Head: "h", Verdict: CandidateRefused},
"unknown verdict": {Head: "h", Verdict: "maybe"},
"diff with no module": {Head: "h", Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Why: "x"}}},
} {
if _, err := EncodeHaveNode(HaveNode{Candidates: map[string]CandidateAnswer{"g": a}}); err == nil {
t.Errorf("%s: taken", name)
}
}
})
}