574 lines
32 KiB
Go
574 lines
32 KiB
Go
package delivery
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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")
|
|
}
|
|
// ADR 0567 §1: the seat's three states are named after the holding module, and every reader takes the names here.
|
|
for got, want := range map[string]string{PlacementBucket: "mesh-delivery-assigner_placement",
|
|
PlacementProposalsBucket: "mesh-delivery-assigner_proposals", PlacementCandidatesBucket: "mesh-delivery-assigner_candidates",
|
|
PlacementRead: "mesh-delivery-assigner.placement", PlacementCandidatesRead: "mesh-delivery-assigner.candidates",
|
|
PlacementProposalsRead: "mesh-delivery-assigner.proposals"} {
|
|
if got != want {
|
|
t.Errorf("a bucket is named %q, want %q", got, want)
|
|
}
|
|
}
|
|
if PlacementHistory != 64 || PlacementStreamDays != 30 {
|
|
t.Errorf("history %d, stream %d days", PlacementHistory, PlacementStreamDays)
|
|
}
|
|
})
|
|
|
|
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.Modules = []PlacedModule{{Module: "app", Ports: map[string]int{"8080": 18081}}, {Module: "new"}}
|
|
got := ChangeOf(g14, after)
|
|
want := PlacementChange{Node: "g14", 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 a node with no record",
|
|
"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)
|
|
}
|
|
}
|
|
})
|
|
|
|
// ADR 0567 §3: a proposal's candidates, one key `<proposal>.<node>` per node it must hear from, and each node's answer
|
|
// in its have.
|
|
t.Run("candidates", func(t *testing.T) {
|
|
const id = "p-20261028t183000-1a2b3c"
|
|
key, err := PlacementCandidateKey(id, "g14")
|
|
if err != nil || key != id+".g14" {
|
|
t.Fatalf("PlacementCandidateKey = %q, %v", key, err)
|
|
}
|
|
if p, n, err := ParsePlacementCandidateKey(key); err != nil || p != id || n != "g14" {
|
|
t.Fatalf("ParsePlacementCandidateKey = %q, %q, %v", p, n, err)
|
|
}
|
|
for _, bad := range []string{"g14", ".g14", "p-x.g.14", "p-x.", "P-X.g14", "feat/1.g14"} {
|
|
if _, _, err := ParsePlacementCandidateKey(bad); err == nil {
|
|
t.Errorf("ParsePlacementCandidateKey(%q) took it", bad)
|
|
}
|
|
}
|
|
for name, c := range map[string]PlacementCandidate{
|
|
"placed": {Proposal: id, Placement: &g14},
|
|
"removed": {Proposal: id},
|
|
"reached": {Proposal: id, Reached: true, Placement: &g14},
|
|
} {
|
|
raw, err := EncodePlacementCandidate(c)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
back, err := DecodePlacementCandidate(raw)
|
|
if err != nil || back.Proposal != id || back.Reached != c.Reached || (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 proposal": {},
|
|
"a group": {Proposal: "feat/x"},
|
|
"bad place": {Proposal: id, Placement: &Placement{Node: "a.b"}},
|
|
} {
|
|
if _, err := EncodePlacementCandidate(c); err == nil {
|
|
t.Errorf("%s: taken", name)
|
|
}
|
|
}
|
|
answers := map[string]CandidateAnswer{
|
|
"same": {Proposal: id, Revision: 3, Verdict: CandidateSame, Same: []string{"app", "node"}},
|
|
"differs": {Proposal: id, Revision: 3, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}},
|
|
"refused": {Proposal: id, Revision: 3, Verdict: CandidateRefused, Refused: "unbound postgres"},
|
|
"added": {Proposal: id, Revision: 1, Verdict: CandidateDiffers, Same: []string{"node"}, Added: []ModuleAdded{{Module: "app", Unissued: []string{"app/token"}}}, Removed: []string{"old"}},
|
|
"a value": {Proposal: id, Revision: 1, Verdict: CandidateRefused, Same: []string{"node"}, Refusals: []ModuleRefused{{Module: "db", Why: "stands in for db/password"}}},
|
|
// A head that deletes the node's placement file: the node returns to the controller's compositions (issue 1126,
|
|
// the seed's revert, mesh-catalog #526).
|
|
"returns": {Proposal: id, Revision: 1, Verdict: CandidateSame, Returns: ReturnsToController, Same: []string{"app", "node"}},
|
|
"returns, the controller's differs": {Proposal: id, Revision: 1, Verdict: CandidateDiffers, Returns: ReturnsToController, Same: []string{"node"},
|
|
Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}, Removed: []string{"old"}},
|
|
}
|
|
n := HaveNode{Composed: "sha256:" + strings.Repeat("a", 64), Sealed: []string{"box"}, Candidates: answers,
|
|
Composes: ComposesOwn, ComposesWhy: "the catalogue places it (placement.n1)"}
|
|
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,
|
|
Composes: ComposesOwn, ComposesWhy: n.ComposesWhy}) {
|
|
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, Composes: ComposesOwn, ComposesWhy: n.ComposesWhy}) {
|
|
t.Error("a node's have without its answers counts as the same")
|
|
}
|
|
if SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Candidates: answers,
|
|
Composes: ComposesController, ComposesWhy: n.ComposesWhy}) {
|
|
t.Error("a node's have composing otherwise counts as the same")
|
|
}
|
|
if _, err := EncodeHaveNode(HaveNode{Composes: "both"}); err == nil {
|
|
t.Error("a node composing neither its own nor the controller's was taken")
|
|
}
|
|
// 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"},
|
|
} {
|
|
c := CompareComposed(map[string]Composed{"novox.nats." + fp: tc.composed}, []Composed{tc.running})
|
|
if tc.why == "" {
|
|
if len(c.Differs) != 0 || len(c.Same) != 1 {
|
|
t.Errorf("%s: %+v", name, c)
|
|
}
|
|
} else if len(c.Differs) != 1 || c.Differs[0].Resource != tc.resource || c.Differs[0].Why != tc.why ||
|
|
c.Differs[0].Composed == "" || c.Differs[0].Running == "" || c.Differs[0].Composed == c.Differs[0].Running {
|
|
t.Errorf("%s: differs %+v, want %s: %s with both digests", name, c.Differs, tc.resource, tc.why)
|
|
}
|
|
}
|
|
// Issue 1126, the shadow shows its work: every differing resource with both bodies, each value a secret could be
|
|
// in masked (never a secret in an answer), saying whether the two sides hold the same value.
|
|
sealedFile := func(id, sealed, mode string) json.RawMessage {
|
|
return json.RawMessage(`{"id":"` + id + `","type":"file","module":"nats","path":"/etc/` + id + `","mode":"` + mode + `","sealed":"` + sealed + `"}`)
|
|
}
|
|
// Issue 1126, C1: a sealed value is judged by the value it opens to at its own slot: b sealed again (box-2 and
|
|
// box-3 open to one value) is no difference; d holds at its slot a box of another value, though that value is
|
|
// the one b holds, and differs at its path `sealed`.
|
|
sameBox := func(a, b string) (same, known bool) {
|
|
return (a == "box-2" && b == "box-3") || a == b, true
|
|
}
|
|
shown := CompareComposedWith(map[string]Composed{"k": comp(sealedFile("a", "box-1", "0600"), sealedFile("b", "box-2", "0600"),
|
|
file("c", "x"), sealedFile("d", "box-2", "0600"))},
|
|
[]Composed{comp(sealedFile("a", "box-1", "0644"), sealedFile("b", "box-3", "0600"), file("c", "y"), sealedFile("d", "box-4", "0600"))},
|
|
sameBox)
|
|
if len(shown.Differs) != 1 || len(shown.Differs[0].Bodies) != 3 {
|
|
t.Fatalf("every differing resource is not shown, or b sealed again is shown: %+v", shown.Differs)
|
|
}
|
|
if c := CompareComposedWith(map[string]Composed{"k": comp(sealedFile("b", "box-2", "0600"))},
|
|
[]Composed{comp(sealedFile("b", "box-3", "0600"))}, sameBox); len(c.Same) != 1 {
|
|
t.Errorf("a value sealed again at its own slot is not the same: %+v", c)
|
|
}
|
|
for _, row := range []struct {
|
|
id, has, hasNot string
|
|
paths []string
|
|
}{
|
|
{"a", `"mode":"0600"`, "box-1", []string{"mode"}},
|
|
{"c", `"content":"x"`, "", []string{"content"}},
|
|
{"d", "<masked: another value>", "box-2", []string{"sealed"}},
|
|
} {
|
|
var body ResourceBodies
|
|
for _, b := range shown.Differs[0].Bodies {
|
|
if b.ID == row.id {
|
|
body = b
|
|
}
|
|
}
|
|
both := body.Composed + body.Running
|
|
if !strings.Contains(both, row.has) || (row.hasNot != "" && strings.Contains(both, row.hasNot)) ||
|
|
!slices.Equal(body.Paths, row.paths) {
|
|
t.Errorf("%s shown as %+v, want paths %v", row.id, body, row.paths)
|
|
}
|
|
}
|
|
// Issue 1126, C1, the seed-7 probe: a file naming secrets carries each sealed value in its `secrets`, and each entry
|
|
// is a slot of its own. The shape is intoFile's output, captured from catalogue for a file naming two secrets
|
|
// (2026-10-28), with the module a composition names each resource of; only the boxes are replaced.
|
|
withSecrets := func(db, key string) json.RawMessage {
|
|
return json.RawMessage(`{"content":"[database]\npassword = ${secret:postgres-database}\n[security]\nsecret_key = ${secret:secret-key}\n",` +
|
|
`"id":"grafana.ini","mode":"0600","module":"nats","path":"/etc/grafana/grafana.ini","secrets":{"postgres-database":"` + db +
|
|
`","secret-key":"` + key + `"},"type":"file"}`)
|
|
}
|
|
if c := CompareComposedWith(map[string]Composed{"k": comp(withSecrets("box-2", "box-1"))},
|
|
[]Composed{comp(withSecrets("box-3", "box-1"))}, sameBox); len(c.Same) != 1 {
|
|
t.Errorf("a secret sealed again at its own entry is not the same: %+v", c)
|
|
}
|
|
if kept := KeepSeals(comp(withSecrets("box-2", "box-1")), SealsIn(comp(withSecrets("box-3", "box-1"))), sameBox); !bytes.Equal(kept.Resources[0], withSecrets("box-3", "box-1")) {
|
|
t.Errorf("the box that runs is not kept byte for byte: %s", kept.Resources[0])
|
|
}
|
|
if s := SealsIn(comp(withSecrets("box-2", "box-1"))); s[SealSlot("grafana.ini", SecretSlotField("postgres-database"))] != "box-2" || len(s) != 2 {
|
|
t.Errorf("the secrets' slots are %v", s)
|
|
}
|
|
// Another value at one entry differs there alone, each entry masked by what it opens to.
|
|
// box-2 and box-4 open to one value, so the box that runs is kept there; box-5 and box-6 are two values.
|
|
mixed := CompareComposedWith(map[string]Composed{"k": comp(withSecrets("box-2", "box-5"))},
|
|
[]Composed{comp(withSecrets("box-4", "box-6"))}, func(a, b string) (bool, bool) {
|
|
return (a == "box-2" && b == "box-4") || a == b, true
|
|
})
|
|
if len(mixed.Differs) != 1 || len(mixed.Differs[0].Bodies) != 1 {
|
|
t.Fatalf("a secret of another value is not shown: %+v", mixed)
|
|
}
|
|
if body := mixed.Differs[0].Bodies[0]; !slices.Equal(body.Paths, []string{"secrets.secret-key"}) ||
|
|
!strings.Contains(body.Composed, `"secret-key":"<masked: another value>"`) || strings.Contains(body.Composed+body.Running, "box-") ||
|
|
!strings.Contains(body.Running, `"postgres-database":"<masked: the same on both sides>"`) {
|
|
t.Errorf("a file's secrets shown as %+v", body)
|
|
}
|
|
if c, _ := MaskedPair(withSecrets("box-2", "box-5"), withSecrets("box-4", "box-6"), func(a, b string) (bool, bool) {
|
|
return a == "box-2", true
|
|
}); !strings.Contains(c, `"postgres-database":"<masked: another seal of the same value>"`) || !strings.Contains(c, `"secret-key":"<masked: another value>"`) {
|
|
t.Errorf("each secret is not masked by what it opens to: %s", c)
|
|
}
|
|
// Only a file's secrets are split by entry: a vault box (an object under `vault-sealed`) is one masked word and one
|
|
// path, judged whole by what it opens to (rev-seed-same-3, round 1).
|
|
box := func(ct string) json.RawMessage {
|
|
return json.RawMessage(`{"id":"core-box","type":"file","module":"nats","path":"/etc/core","mode":"0600","vault-sealed":{"v":1,"eph":"e-` +
|
|
ct + `","ct":"` + ct + `"}}`)
|
|
}
|
|
vault := CompareComposedWith(map[string]Composed{"k": comp(box("c1"))}, []Composed{comp(box("c2"))},
|
|
func(a, b string) (bool, bool) {
|
|
return false, strings.Contains(a, `"ct":"c1"`) && strings.Contains(b, `"ct":"c2"`)
|
|
})
|
|
if len(vault.Differs) != 1 || len(vault.Differs[0].Bodies) != 1 {
|
|
t.Fatalf("a box of another value is not shown: %+v", vault)
|
|
}
|
|
if body := vault.Differs[0].Bodies[0]; !slices.Equal(body.Paths, []string{"vault-sealed"}) ||
|
|
!strings.Contains(body.Composed, `"vault-sealed":"<masked: another value>"`) || strings.Contains(body.Composed+body.Running, "c1") {
|
|
t.Errorf("a vault box shown as %+v", body)
|
|
}
|
|
// Issue 1126, T1: the paths are found on the whole bodies, before they are cut; JSON in a string is walked into, a
|
|
// key a secret could be under is named and never shown.
|
|
long := strings.Repeat("x", MaxBody)
|
|
conf := func(link, password string) json.RawMessage {
|
|
inner, _ := json.Marshal(map[string]any{"pad": long, "links": []string{"a", link}, "password": password})
|
|
outer, _ := json.Marshal(map[string]any{"id": "conf", "type": "file", "module": "nats", "path": "/etc/conf",
|
|
"mode": "0600", "content": string(inner)})
|
|
return outer
|
|
}
|
|
far := CompareComposedWith(map[string]Composed{"k": comp(conf("b", "hunter2"))}, []Composed{comp(conf("c", "hunter3"))}, nil)
|
|
if len(far.Differs) != 1 || len(far.Differs[0].Bodies) != 1 ||
|
|
!slices.Equal(far.Differs[0].Bodies[0].Paths, []string{"content.links.1", "content.password"}) ||
|
|
!strings.Contains(far.Differs[0].Bodies[0].Composed, "cut at") || strings.Contains(fmt.Sprint(far), "hunter") {
|
|
t.Errorf("a difference past the cut: %+v", far.Differs)
|
|
}
|
|
// And an answer too large for the node's key is fitted: the texts go first, the verdict and the paths stay.
|
|
big := CandidateAnswer{Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Differs: far.Differs}
|
|
if fitted := FitAnswer(big, 4<<10); len(fitted.Differs[0].Bodies[0].Composed) != 0 || len(fitted.Differs[0].Bodies[0].Paths) != 2 ||
|
|
fitted.Verdict != CandidateDiffers || !strings.Contains(fitted.Note, "cut") || big.Differs[0].Bodies[0].Composed == "" {
|
|
t.Errorf("fitted as %+v", fitted)
|
|
}
|
|
if c, r := MaskedPair(json.RawMessage(`{"env":{"DB_PASSWORD":"hunter2","PORT":"80"},"list":[{"token":"t1"}]}`),
|
|
json.RawMessage(`{"env":{"DB_PASSWORD":"hunter3","PORT":"81"},"list":[{"token":"t1"}]}`), nil); strings.Contains(c+r, "hunter") ||
|
|
strings.Contains(c+r, `"t1"`) || !strings.Contains(r, `"PORT":"81"`) || !strings.Contains(c, "<masked: differs>") ||
|
|
!strings.Contains(c, "<masked: the same on both sides>") {
|
|
t.Errorf("masked as %s and %s", c, r)
|
|
}
|
|
// Issue 1126, cause C4: the own secrets whose two sides open to two values are read from the shown bodies, and
|
|
// every body so masked is counted, own or not.
|
|
// The ids are as a node runs them, its module's name first (captured from novox's shadow answer, 2026-10-28:
|
|
// `only-office.needs-jwt`); another module's need, named here, is not nats's.
|
|
drift := CompareComposedWith(map[string]Composed{"k": comp(sealedFile("nats.needs-jwt", "box-1", "0600"),
|
|
sealedFile("nats.needs-salt", "box-4", "0600"), sealedFile("nats.conf", "box-6", "0600"),
|
|
sealedFile("other.needs-key", "box-8", "0600"))},
|
|
[]Composed{comp(sealedFile("nats.needs-jwt", "box-2", "0600"), sealedFile("nats.needs-salt", "box-5", "0600"),
|
|
sealedFile("nats.conf", "box-7", "0600"), sealedFile("other.needs-key", "box-9", "0600"))},
|
|
func(a, b string) (same, known bool) { return a == "box-4", true })
|
|
got := CandidateAnswer{Proposal: "p-x1", Revision: 1, Differs: drift.Differs}
|
|
if own := OwnValuesDiffer(got); len(own) != 1 || own[0] != (OwnValueDiffers{Module: "nats", Name: "jwt"}) {
|
|
t.Errorf("the own secrets with another value are %+v, want nats jwt alone", own)
|
|
}
|
|
if n := ValuesDiffer(got); n != 3 {
|
|
t.Errorf("%d bodies hold another value, want 3 (jwt, conf and other's key)", n)
|
|
}
|
|
for id, want := range map[string]string{"nats.needs-jwt": "jwt", "needs-jwt": "jwt", "other.needs-jwt": "",
|
|
"nats.conf": "", "nats.needs-": ""} {
|
|
if name, own := OwnNeedName("nats", id); name != want && (own || want != "") {
|
|
t.Errorf("%s names nats's own secret %q (%v), want %q", id, name, own, want)
|
|
}
|
|
}
|
|
if w := WithValuesDiffer(map[string]CandidateAnswer{"g": got}); w["g"].ValuesDiffer != 3 {
|
|
t.Errorf("the answer carries values_differ %d", w["g"].ValuesDiffer)
|
|
}
|
|
if c := CompareComposed(nil, []Composed{{Node: "novox", Resources: []json.RawMessage{}}}); len(c.Removed) != 1 || c.Removed[0] != "node" {
|
|
t.Errorf("the node's own composition run and not composed is removed: %+v", c)
|
|
}
|
|
if c := CompareComposed(map[string]Composed{"k": comp(file("conf", "a"))}, nil); len(c.Added) != 1 || c.Added[0].Module != "nats" || c.Added[0].Composed == "" {
|
|
t.Errorf("a composition not run is added: %+v", c)
|
|
}
|
|
for name, a := range map[string]CandidateAnswer{
|
|
"no head": {Verdict: CandidateSame},
|
|
"same with a diff": {Proposal: "p-x1", Revision: 1, Verdict: CandidateSame, Differs: answers["differs"].Differs},
|
|
"differs with none": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers},
|
|
"refused no reason": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused},
|
|
"unknown verdict": {Proposal: "p-x1", Revision: 1, Verdict: "maybe"},
|
|
"diff with no module": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Why: "x"}}},
|
|
"same with an added": {Proposal: "p-x1", Revision: 1, Verdict: CandidateSame, Added: []ModuleAdded{{Module: "a"}}},
|
|
"differs refusing": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Removed: []string{"a"}, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
|
|
"refused and modules": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Refused: "x", Same: []string{"a"}},
|
|
"returns refusing": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToController, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
|
|
"returns refused": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToController, Refused: "x"},
|
|
} {
|
|
if _, err := EncodeHaveNode(HaveNode{Candidates: map[string]CandidateAnswer{"g": a}}); err == nil {
|
|
t.Errorf("%s: taken", name)
|
|
}
|
|
}
|
|
})
|
|
|
|
// ADR 0567 §2, §8: a proposal's changes laid over the current keys, what waits for the lease write, and the proposal's
|
|
// encoding and refusals.
|
|
t.Run("proposal", func(t *testing.T) {
|
|
current := map[string]Placement{"g14": g14, "novox": novox}
|
|
laid, findings := LayChanges(current, []PlacementProposalChange{
|
|
{Op: OpPlace, Node: "g14", Module: "new"},
|
|
{Op: OpTakeOff, Node: "g14", Module: "zsh"},
|
|
{Op: OpUnpin, Node: "g14", Provision: "shell"},
|
|
{Op: OpDecide, Node: "novox", Field: "site", Value: json.RawMessage(`"attic"`)},
|
|
})
|
|
if len(findings) != 0 || len(laid) != 2 || !reflect.DeepEqual(laid["g14"].Assigned(), []string{"app", "new"}) ||
|
|
len(laid["g14"].Holds) != 0 || laid["g14"].Pins != nil || laid["novox"].Decided.Site != "attic" || !laid["novox"].Decided.Hub {
|
|
t.Fatalf("laid %+v, %v", laid, findings)
|
|
}
|
|
if !reflect.DeepEqual(current["g14"].Assigned(), []string{"zsh", "app"}) {
|
|
t.Error("laying changed the current placement")
|
|
}
|
|
if fresh, f := LayChanges(current, []PlacementProposalChange{{Op: OpPlace, Node: "pi", Module: "zsh"}}); len(f) != 0 || fresh["pi"].Node != "pi" {
|
|
t.Errorf("a node with no key: %+v, %v", fresh, f)
|
|
}
|
|
for name, c := range map[string]struct {
|
|
change PlacementProposalChange
|
|
want string
|
|
}{
|
|
"not placed": {PlacementProposalChange{Op: OpTakeOff, Node: "g14", Module: "nope"}, "not placed"},
|
|
"no such field": {PlacementProposalChange{Op: OpDecide, Node: "g14", Field: "colour", Value: json.RawMessage(`"x"`)}, "no decided field"},
|
|
"wrong type": {PlacementProposalChange{Op: OpDecide, Node: "g14", Field: "hub", Value: json.RawMessage(`"x"`)}, "does not take"},
|
|
"unknown op": {PlacementProposalChange{Op: "move", Node: "g14"}, "no change of placement"},
|
|
"pin of nobody": {PlacementProposalChange{Op: OpPin, Node: "g14", Provision: "x"}, "provider"},
|
|
"not held": {PlacementProposalChange{Op: OpUnhold, Node: "g14", Module: "app", Claim: "x"}, "holds no"},
|
|
"bad port": {PlacementProposalChange{Op: OpPlace, Node: "g14", Module: "x", Ports: map[string]int{"a": 1}}, "port"},
|
|
"seed of another": {PlacementProposalChange{Op: OpSeed, Node: "g14", Whole: &novox}, "seed"},
|
|
} {
|
|
if _, f := LayChanges(current, []PlacementProposalChange{c.change}); len(f) != 1 || !strings.Contains(f[0], c.want) {
|
|
t.Errorf("%s: %v, want a finding naming %q", name, f, c.want)
|
|
}
|
|
}
|
|
for change, waits := range map[*PlacementProposalChange]bool{
|
|
{Op: OpPlace, Node: "g14", Module: "x"}: false, {Op: OpTakeOff, Node: "g14", Module: "x"}: false,
|
|
{Op: OpPin, Node: "g14"}: false, {Op: OpUnpin, Node: "g14"}: false, {Op: OpSeed, Node: "g14"}: false,
|
|
{Op: OpPlace, Node: "g14", Module: "x", Ports: map[string]int{"80": 80}}: true,
|
|
{Op: OpPlace, Node: "g14", Module: "x", Bindings: map[string]Provider{"p": {}}}: true,
|
|
{Op: OpHold, Node: "g14"}: true, {Op: OpDecide, Node: "g14"}: true,
|
|
{Op: OpConfig, Node: "g14", Values: map[string]any{"puid": 1}}: false, {Op: OpConfig, Node: "g14", MeshValues: map[string]any{"a": 1}}: false,
|
|
{Op: OpConfig, Node: "g14", Layer: map[string]any{"places": 1}}: true, {Op: OpConfig, Node: "g14", MeshWide: map[string]any{"accesses": 1}}: true,
|
|
} {
|
|
if got := change.BeforeLease(); (got != "") != waits || (waits && !strings.Contains(got, "lease write")) {
|
|
t.Errorf("%+v before the lease write: %q", *change, got)
|
|
}
|
|
}
|
|
|
|
const id = "p-20261028t183000-1a2b3c"
|
|
if got := NewProposalID(time.Date(2026, 10, 28, 18, 30, 0, 0, time.UTC)); !strings.HasPrefix(got, "p-20261028t183000-") || CheckProposalID(got) != nil {
|
|
t.Errorf("NewProposalID = %q", got)
|
|
}
|
|
p := PlacementProposal{ID: id, By: "operator@anchor", Why: "move the app", At: time.Unix(1, 0).UTC(),
|
|
Changes: []PlacementProposalChange{{Op: OpTakeOff, Node: "g14", Module: "zsh"}}, Base: map[string]uint64{"g14": 4},
|
|
State: ProposalAnswering}
|
|
raw, err := EncodePlacementProposal(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if back, err := DecodePlacementProposal(raw); err != nil || back.ID != id || back.Base["g14"] != 4 || !reflect.DeepEqual(back.Nodes(), []string{"g14"}) {
|
|
t.Fatalf("round trip %+v, %v", back, err)
|
|
}
|
|
seed := p
|
|
seed.Seed, seed.ExportDigest = true, ExportDigest(current)
|
|
seed.Changes = []PlacementProposalChange{{Op: OpSeed, Node: "g14", Whole: &g14}}
|
|
if _, err := EncodePlacementProposal(seed); err != nil {
|
|
t.Errorf("the seed: %v", err)
|
|
}
|
|
if ExportDigest(current) != ExportDigest(map[string]Placement{"novox": novox, "g14": g14}) || ExportDigest(current) == ExportDigest(nil) {
|
|
t.Error("the export digest depends on order or not on the placements")
|
|
}
|
|
back := p
|
|
back.HandBack, back.Changes = true, nil
|
|
if _, err := EncodePlacementProposal(back); err != nil {
|
|
t.Errorf("a hand-back: %v", err)
|
|
}
|
|
if k, err := PulseAnswerKey(id, "g14"); err != nil || k != "answer."+id+".g14" {
|
|
t.Errorf("PulseAnswerKey = %q, %v", k, err)
|
|
}
|
|
if k, err := PulseAskKey(id); err != nil || k != "ask."+id || PlacementPulsesBucket != "mesh-delivery-assigner_pulses" {
|
|
t.Errorf("PulseAskKey = %q, %v", k, err)
|
|
}
|
|
if ChangesDigest(p) == ChangesDigest(seed) {
|
|
t.Error("two proposals' changes digest alike")
|
|
}
|
|
for name, mut := range map[string]func(*PlacementProposal){
|
|
"no id": func(p *PlacementProposal) { p.ID = "" },
|
|
"no proposer": func(p *PlacementProposal) { p.By = "" },
|
|
"no why": func(p *PlacementProposal) { p.Why = " " },
|
|
"no change": func(p *PlacementProposal) { p.Changes = nil },
|
|
"no base": func(p *PlacementProposal) { p.Base = map[string]uint64{"novox": 1} },
|
|
"no state": func(p *PlacementProposal) { p.State = "" },
|
|
"asked, no ask": func(p *PlacementProposal) { p.State = ProposalAsked },
|
|
"seed mixed": func(p *PlacementProposal) { p.Seed, p.ExportDigest = true, "d" },
|
|
"bad change": func(p *PlacementProposal) { p.Changes[0].Op = "swap" },
|
|
"seed, no digest": func(p *PlacementProposal) { *p = seed; p.ExportDigest = "" },
|
|
"hand-back with changes": func(p *PlacementProposal) { p.HandBack = true },
|
|
} {
|
|
q := p
|
|
q.Changes = append([]PlacementProposalChange(nil), p.Changes...)
|
|
mut(&q)
|
|
if _, err := EncodePlacementProposal(q); err == nil {
|
|
t.Errorf("%s: taken", name)
|
|
}
|
|
}
|
|
})
|
|
}
|