package delivery import ( "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") } 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) } } }) }