package assignment import ( "encoding/json" "errors" "reflect" "slices" "strings" "testing" "time" client "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-sdk/go/keys" ) // ADR 0567 §2, §4: what a change says, the holder's check of every assignments, a proposal's changes laid over the keys, // its digests (held to the values the earlier holder's code made, in the field order it wrote), its refusals, and who holds // which seat. func TestAssignments(t *testing.T) { g14 := seat.Assignments{Node: "g14", Revision: "abc", Decided: seat.NodeDecided{Endpoint: "g14.example:51820", Site: "home", Address: "10.77.0.2", Account: "op", Taken: []string{"zsh"}, Tunnel: &seat.FoundTunnel{Interface: "wg0", Config: "c", Port: 51820, Mtu: 1420, PublicKey: "K", Peers: []seat.FoundTunnelPeer{{PublicKey: "P", Address: "10.0.0.3"}}}, PeerNames: map[string]string{"P": "pi"}}, Modules: []seat.PlacedModule{ {Module: "zsh"}, {Module: "app", Ports: map[string]int64{"8080": 18080}, Bindings: map[string]seat.Provider{"postgres": {Node: "novox", Module: "postgres"}}}, }, Pins: map[string]seat.Provider{"shell": {Node: "g14", Module: "zsh"}}, Holds: []seat.Holding{{Claim: "node-shell", Scope: "node", Module: "zsh"}}, } novox := seat.Assignments{Node: "novox", Decided: seat.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []seat.PlacedModule{{Module: "postgres", Ports: map[string]int64{"5432": 5432}}}, Holds: []seat.Holding{{Claim: "mesh-store", Scope: "mesh", Module: "postgres"}}} current := map[string]seat.Assignments{"g14": g14, "novox": novox} const id = "p-20261028t183000-1a2b3c" // the enroller is whichever module holds the seat mesh-node-enroller: no module is named for it const enroller = "enroll-lab" enrollers := EnrollersOf(map[string]seat.Assignments{"ace": {Node: "ace", Holds: []seat.Holding{{Claim: "mesh-node-enroller", Scope: "mesh", Module: enroller}}}}) if !slices.Equal(enrollers, []string{enroller}) { t.Fatalf("the holders of the enroller seat = %v", enrollers) } t.Run("what a change says", func(t *testing.T) { after := g14 after.Modules = []seat.PlacedModule{{Module: "app", Ports: map[string]int64{"8080": 18081}}, {Module: "new"}} got := ChangeOf(g14, after) want := Change{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(seat.Assignments{}, novox); !reflect.DeepEqual(got.Added, []string{"postgres"}) || got.Node != "novox" { t.Errorf("a new node: %+v", got) } c := []Change{{Node: "novox"}, {Node: "g14", Before: 1, After: 2}} SortChanges(c) if c[0].Node != "g14" || c[0].Body()["after"] != float64(2) { t.Errorf("sorted %+v, body %v", c, c[0].Body()) } }) t.Run("the check of every assignments", func(t *testing.T) { known := func(m string) bool { return m != "ghost" } enrolled := func(n string) bool { return n != "stranger" } if r := CheckAssignments(current, known, enrolled); len(r) != 0 { t.Fatalf("a sound assignments 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 name, refused := range map[string]bool{"mesh-dns-resolver": false, "mesh-broker": true} { a := seat.Assignments{Node: "ace", Modules: []seat.PlacedModule{{Module: "dns"}}, Holds: []seat.Holding{{Claim: name, Scope: "mesh", Module: "dns"}}} b := a b.Node = "novox" r := CheckAssignments(map[string]seat.Assignments{"ace": a, "novox": b}, known, enrolled) if (len(r) != 0) != refused { t.Errorf("%s on two nodes: refusals %v, want refused %v", name, r, refused) } } clash := g14 clash.Modules = append([]seat.PlacedModule{{Module: "ghost", Ports: map[string]int64{"80": 18080}}}, g14.Modules...) clash.Modules[2] = seat.PlacedModule{Module: "app", Ports: map[string]int64{"8080": 18080}, Bindings: map[string]seat.Provider{"postgres": {Node: "ace", Module: "postgres"}}} clash.Pins = map[string]seat.Provider{"shell": {Node: "g14", Module: "bash"}} // ADR 0564 clause 6, Limits 5 (issue 1952 S8): mesh-watcher and mesh-self-check are never placed on one node. clash.Modules = append(clash.Modules, seat.PlacedModule{Module: "mesh-watcher"}, seat.PlacedModule{Module: "mesh-self-check"}) twice := novox twice.Node = "stranger" twice.Decided.Address = "10.77.0.2" r := CheckAssignments(map[string]seat.Assignments{"g14": clash, "novox": novox, "stranger": twice}, known, 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", "g14: mesh-watcher and mesh-self-check are never placed on one node (ADR 0227 §6, ADR 0564 clause 6)"} { if !slices.ContainsFunc(r, func(line string) bool { return strings.Contains(line, want) }) { t.Errorf("no refusal says %q; got %v", want, r) } } }) t.Run("changes laid over the keys", func(t *testing.T) { laid, findings := LayChanges(current, []seat.ProposalChange{ {Op: client.OpPlace, Node: "g14", Module: "new"}, {Op: client.OpTakeOff, Node: "g14", Module: "zsh"}, {Op: client.OpUnpin, Node: "g14", Provision: "shell"}, {Op: client.OpDecide, Node: "novox", Field: "site", Value: "attic"}, }) if len(findings) != 0 || len(laid) != 2 || !reflect.DeepEqual(client.Assigned(laid["g14"]), []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(client.Assigned(current["g14"]), []string{"zsh", "app"}) { t.Error("laying changed the current assignments") } if fresh, f := LayChanges(current, []seat.ProposalChange{{Op: client.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 seat.ProposalChange want string }{ "not placed": {seat.ProposalChange{Op: client.OpTakeOff, Node: "g14", Module: "nope"}, "not placed"}, "no such field": {seat.ProposalChange{Op: client.OpDecide, Node: "g14", Field: "colour", Value: "x"}, "no decided field"}, "wrong type": {seat.ProposalChange{Op: client.OpDecide, Node: "g14", Field: "hub", Value: "x"}, "does not take"}, "unknown op": {seat.ProposalChange{Op: "move", Node: "g14"}, "no change of assignments"}, "pin of nobody": {seat.ProposalChange{Op: client.OpPin, Node: "g14", Provision: "x"}, "provider"}, "not held": {seat.ProposalChange{Op: client.OpUnhold, Node: "g14", Module: "app", Claim: "x"}, "holds no"}, "bad port": {seat.ProposalChange{Op: client.OpPlace, Node: "g14", Module: "x", Ports: map[string]int64{"a": 1}}, "port"}, "seed of another": {seat.ProposalChange{Op: client.OpSeed, Node: "g14", Whole: &novox}, "seed"}, } { if _, f := LayChanges(current, []seat.ProposalChange{c.change}); len(f) != 1 || !strings.Contains(f[0], c.want) { t.Errorf("%s: %v, want a finding naming %q", name, f, c.want) } } }) t.Run("a proposal, its digests and its refusals", func(t *testing.T) { if got := NewProposalID(time.Date(2026, 10, 28, 18, 30, 0, 0, time.UTC)); !strings.HasPrefix(got, "p-20261028t183000-") || client.CheckProposalID(got) != nil { t.Errorf("NewProposalID = %q", got) } p := seat.Proposal{ID: id, By: "operator@anchor", Why: "move the app", At: time.Unix(1, 0).UTC(), Changes: []seat.ProposalChange{{Op: client.OpTakeOff, Node: "g14", Module: "zsh"}}, Base: map[string]int64{"g14": 4}, State: client.ProposalAnswering} raw, err := EncodeProposal(p) if err != nil { t.Fatal(err) } if back, err := DecodeProposal(raw); err != nil || back.ID != id || back.Base["g14"] != 4 || !reflect.DeepEqual(Nodes(back), []string{"g14"}) { t.Fatalf("round trip %+v, %v", back, err) } if _, err := DecodeProposal([]byte(`{"v":9}`)); !errors.Is(err, keys.ErrUnknownVersion) { t.Errorf("version 9: %v", err) } seed := p seed.Seed, seed.ExportDigest = true, ExportDigest(current) seed.Changes = []seat.ProposalChange{{Op: client.OpSeed, Node: "g14", Whole: &g14}} if _, err := EncodeProposal(seed); err != nil { t.Errorf("the seed: %v", err) } back := p back.HandBack, back.Changes = true, nil if _, err := EncodeProposal(back); err != nil { t.Errorf("a hand-back: %v", err) } // The digests are what the earlier holder's code made of the same values (issue 1966: a proposal's ask binds its // changes' digest, and a seed its export's), whatever order the generated types keep their fields in. rich := seat.Proposal{ID: id, By: "operator@anchor", Why: "w", At: time.Unix(1, 0).UTC(), State: client.ProposalAnswering, Base: map[string]int64{"g14": 4, "lab": 0, "novox": 2}, Changes: []seat.ProposalChange{ {Op: client.OpTakeOff, Node: "g14", Module: "zsh"}, {Op: client.OpPlace, Node: "g14", Module: "new", Ports: map[string]int64{"80": 8080}, Bindings: map[string]seat.Provider{"pg": {Node: "novox", Module: "postgres"}}}, {Op: client.OpPin, Node: "g14", Provision: "shell", Provider: &seat.Provider{Node: "g14", Module: "bash"}}, {Op: client.OpHold, Node: "g14", Module: "app", Claim: "node-shell", Scope: "node", Kind: "k", Site: "s"}, {Op: client.OpDecide, Node: "novox", Field: "site", Value: "attic"}, {Op: client.OpDecide, Node: "novox", Field: "peer-names", Value: map[string]any{"k": "v"}}, {Op: client.OpConfig, Node: "g14", Module: "app", MeshWide: map[string]any{"a": float64(1)}, Layer: map[string]any{"b": "c"}, MeshWideValues: map[string]any{"d": true}, Values: map[string]any{"e": []any{"f"}}}, {Op: client.OpAddNode, Node: "lab", Decided: &seat.NodeDecided{Address: "10.0.0.7", Account: "x", AgentAccountHome: "/h"}, Keys: &seat.ProposalChangeKeys{Overlay: "wg", Identity: "id", Sealing: "se"}}, }} for name, c := range map[string]struct{ got, want string }{ "export": {ExportDigest(current), "sha256:742f03d8790d7efa6abff76636f4a69ba2704d646233960f52ce08604b96e128"}, "rich changes": {ChangesDigest(rich), "sha256:056338a2143d8c1af4e1275f41ffd06f18f3af3396a196d4444df83a0434475c"}, "take-off": {ChangesDigest(p), "sha256:7ce48744dea7934e6f3a6a1eeffb28df4096ee6a6a6b4ae29e15fb019044d8de"}, "seed": {ChangesDigest(seed), "sha256:731fe40d188acdf4a27ea73f3a5f1f9d584c3a8af7eaf15d4fb18e5da4853515"}, "no changes": {ChangesDigest(seat.Proposal{ID: id, Base: map[string]int64{"g14": 4}}), "sha256:3c5f1509c2d1c867954c47303e8b8d84bd4cd9aa3458464c21933c6496999bee"}, } { if c.got != c.want { t.Errorf("the digest of %s is %s, and the earlier holder made %s", name, c.got, c.want) } } if ExportDigest(current) != ExportDigest(map[string]seat.Assignments{"novox": novox, "g14": g14}) || ExportDigest(current) == ExportDigest(nil) { t.Error("the export digest depends on order or not on the assignments") } // 774/S42/AC1 (ADR 0587 §7): an add-node carries its enrolment's warrant naming its node and address, as its one // change, from the enroller alone; a warrant naming another node or address is refused. // At the operator's yes it carries the tunnel key alone (the operator, 2026-11-02 01:05). joining := seat.ProposalChange{Op: client.OpAddNode, Node: "lab", Decided: &seat.NodeDecided{Address: "10.0.0.7"}, Keys: &seat.ProposalChangeKeys{Overlay: "wg"}} add := seat.Proposal{ID: id, By: "ace.mesh-enrolment", Why: "lab joins", At: time.Unix(1, 0).UTC(), Changes: []seat.ProposalChange{joining}, Base: map[string]int64{"lab": 0}, State: client.ProposalAnswering, Warrant: &seat.Warrant{Node: "lab", Address: "10.0.0.7", Ask: "a-1", Warrant: "w-1"}} if raw, err := EncodeProposal(add); err != nil { t.Errorf("an add-node: %v", err) } else if back, err := DecodeProposal(raw); err != nil || back.Warrant == nil || back.Changes[0].Keys.Overlay != "wg" { t.Errorf("an add-node round trip: %+v, %v", back, err) } if AddNodeRefusal(add, enroller, enrollers) != "" || !strings.Contains(AddNodeRefusal(add, "mesh-delivery-assigner", enrollers), "enroll-lab's alone") { t.Errorf("add-node by caller: %q, %q", AddNodeRefusal(add, enroller, enrollers), AddNodeRefusal(add, "mesh-delivery-assigner", enrollers)) } if laid, f := LayChanges(current, add.Changes); len(f) != 0 || laid["lab"].Decided.Address != "10.0.0.7" || len(laid["lab"].Modules) != 0 { t.Errorf("an add-node laid: %+v, %v", laid, f) } if _, f := LayChanges(current, []seat.ProposalChange{{Op: client.OpAddNode, Node: "g14", Decided: &seat.NodeDecided{Address: "10.0.0.9"}, Keys: &seat.ProposalChangeKeys{Overlay: "wg"}}}); len(f) != 1 || !strings.Contains(f[0], "placed already") { t.Errorf("an add-node of a placed node: %v", f) } for name, mut := range map[string]func(*seat.Proposal){ "warrant of another node": func(p *seat.Proposal) { p.Warrant = &seat.Warrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"} }, "warrant of another address": func(p *seat.Proposal) { p.Warrant = &seat.Warrant{Node: "lab", Address: "10.0.0.8", Ask: "a", Warrant: "w"} }, "no warrant": func(p *seat.Proposal) { p.Warrant = nil }, "no tunnel key": func(p *seat.Proposal) { c := joining c.Keys = &seat.ProposalChangeKeys{Identity: "id", Sealing: "seal"} p.Changes = []seat.ProposalChange{c} }, "half its identity keys": func(p *seat.Proposal) { c := joining c.Keys = &seat.ProposalChangeKeys{Overlay: "wg", Identity: "id"} p.Changes = []seat.ProposalChange{c} }, "beside another change": func(p *seat.Proposal) { p.Changes = append(p.Changes, seat.ProposalChange{Op: client.OpPlace, Node: "lab", Module: "zsh"}) }, "the hub": func(p *seat.Proposal) { c := joining c.Decided = &seat.NodeDecided{Address: "10.0.0.7", Hub: true} p.Changes = []seat.ProposalChange{c} }, "a warrant on a move": func(p *seat.Proposal) { p.Changes, p.Base = []seat.ProposalChange{{Op: client.OpTakeOff, Node: "g14", Module: "zsh"}}, map[string]int64{"g14": 4} }, } { q := add q.Changes = append([]seat.ProposalChange(nil), add.Changes...) mut(&q) if _, err := EncodeProposal(q); err == nil { t.Errorf("an add-node with %s: taken", name) } } // With its identity keys whole, as bound at redeem, it is taken too. whole := add whole.Changes = []seat.ProposalChange{joining} whole.Changes[0].Keys = &seat.ProposalChangeKeys{Overlay: "wg", Identity: "id", Sealing: "seal"} if _, err := EncodeProposal(whole); err != nil { t.Errorf("an add-node with its identity keys: %v", err) } // An enrolment's end takes its never-confirmed node off again: the enroller's alone, of a node running nothing. remove := add remove.Changes = []seat.ProposalChange{{Op: client.OpRemoveNode, Node: "lab"}} if _, err := EncodeProposal(remove); err != nil || AddNodeRefusal(remove, "mesh-delivery-assigner", enrollers) == "" || AddNodeRefusal(remove, enroller, enrollers) != "" || !slices.Equal(RemovedNodes(remove), []string{"lab"}) { t.Errorf("a remove-node: %v, %q", err, AddNodeRefusal(remove, "mesh-delivery-assigner", enrollers)) } joined := map[string]seat.Assignments{"lab": {V: keys.Version, Node: "lab", Decided: seat.NodeDecided{Address: "10.0.0.7"}, Modules: []seat.PlacedModule{}}} if laid, f := LayChanges(joined, remove.Changes); len(f) != 0 || len(laid) != 0 { t.Errorf("a joining node removed: %v, %v", laid, f) } if _, f := LayChanges(current, []seat.ProposalChange{{Op: client.OpRemoveNode, Node: "g14"}}); len(f) != 1 || !strings.Contains(f[0], "runs modules") { t.Errorf("a running node removed: %v", f) } remove.Warrant = &seat.Warrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"} if _, err := EncodeProposal(remove); err == nil { t.Error("a remove-node on another node's warrant: taken") } for name, mut := range map[string]func(*seat.Proposal){ "no id": func(p *seat.Proposal) { p.ID = "" }, "no proposer": func(p *seat.Proposal) { p.By = "" }, "no why": func(p *seat.Proposal) { p.Why = " " }, "no change": func(p *seat.Proposal) { p.Changes = nil }, "no base": func(p *seat.Proposal) { p.Base = map[string]int64{"novox": 1} }, "no state": func(p *seat.Proposal) { p.State = "" }, "asked, no ask": func(p *seat.Proposal) { p.State = client.ProposalAsked }, "seed mixed": func(p *seat.Proposal) { p.Seed, p.ExportDigest = true, "d" }, "bad change": func(p *seat.Proposal) { p.Changes[0].Op = "swap" }, "seed, no digest": func(p *seat.Proposal) { *p = seed; p.ExportDigest = "" }, "hand-back with changes": func(p *seat.Proposal) { p.HandBack = true }, } { q := p q.Changes = append([]seat.ProposalChange(nil), p.Changes...) mut(&q) if _, err := EncodeProposal(q); err == nil { t.Errorf("%s: taken", name) } } }) // ADR 0571 clause 4: who holds which seat, from assignments; a node seat by its node. t.Run("who holds which seat", func(t *testing.T) { held := SeatHoldersOf(map[string]seat.Assignments{ "ace": {Node: "ace", Holds: []seat.Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "node-files", Scope: "node", Module: "mesh-files"}}}, "novox": {Node: "novox", Holds: []seat.Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "mesh-broker", Scope: "mesh", Module: "nats"}, {Claim: "photo-gallery", Scope: "mesh", Module: "photos"}}}, }) if o := held["photo-gallery"]; !o.Outside || len(o.Holders) != 1 || held["mesh-broker"].Outside { t.Errorf("a claim outside the set is %+v", o) } keyed := map[string][]byte{} for k, v := range held { keyed[k], _ = json.Marshal(v) } if h, err := client.HoldersOf(keyed, "mesh-dns-resolver", ""); err != nil || len(h) != 2 || h[0].Node != "ace" { t.Errorf("a replicated seat's holders are %v, %v", h, err) } if h, _ := client.HoldersOf(keyed, "node-files", "ace"); len(h) != 1 || h[0].Module != "mesh-files" || len(held) != 4 { t.Errorf("a node seat's holder is %v, keys %v", h, held) } if h, _ := client.HoldersOf(keyed, "node-files", "novox"); h != nil { t.Errorf("a node seat nobody holds there is held by %v", h) } }) }