Assigner: its own placement-check package over the contract's types: the check, the changes laid over the keys, the proposal's refusals and digests, who holds which seat (ADR 0634 §1; issue 1966)
Digests are held to the earlier holder's values in the field order it wrote them. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
@@ -4,6 +4,7 @@ go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient v0.0.0
|
||||
git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// Package placement is the assigner's own check of a placement and of a proposal that changes it (novox/hq ADR 0567 §2,
|
||||
// ADR 0634 §1 on where a seat's code goes): what the holder of the seat mesh-module-assigner decides before it writes
|
||||
// anything. The layouts it works on are the seat's contract's (package meshmoduleassigner); what every reader of the
|
||||
// placement shares (the keys assembled into a node's placement, the node key's digest, who holds a seat) is the seat's
|
||||
// client's (package meshmoduleassignerclient). Nothing here is read by another module.
|
||||
package placement
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
client "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
|
||||
seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
// KeptApart are the modules never placed on one node, each row naming the record that asks it (novox/hq ADR 0564 clause 6
|
||||
// and Limits 5; issue 1952 S8): no manifest word says it, so the check below holds the table. mesh-watcher hears the
|
||||
// self-check's `checked` from a second machine (ADR 0227 §6), which on the self-check's own node would fall silent with
|
||||
// it.
|
||||
var KeptApart = []struct{ A, B, Record string }{
|
||||
{A: "mesh-watcher", B: "mesh-self-check", Record: "ADR 0227 §6, ADR 0564 clause 6"},
|
||||
}
|
||||
|
||||
// CheckPlacements is the holder's check of a proposal laid over every placement (ADR 0567 §2), over the placements by
|
||||
// node, the modules that are known and the nodes with a record. It returns every refusal, in node order, never only the
|
||||
// first: a placement naming a node not enrolled or a module that is not known (held by the indexer, or provided by the
|
||||
// mesh itself: the caller's `known` says both); two modules of one node on the same host port; a pin, binding or holding
|
||||
// naming a provider that is not placed where it says; a seat of scope mesh held twice, unless the seat is replicated
|
||||
// (catalogue.Seat's Replicated, ADR 0223), which is held once per node; two nodes with one address on the private
|
||||
// network; more than one hub; two modules KeptApart on one node. A host port a manifest declares without a number and a
|
||||
// requirement left unbound are the composition's to find (its ForeseenPorts and Unbound).
|
||||
func CheckPlacements(placements map[string]seat.Placement, known func(module string) bool, enrolled func(node string) bool) []string {
|
||||
var refusals []string
|
||||
nodes := sortedKeys(placements)
|
||||
placed := func(p seat.Provider) bool {
|
||||
other, ok := placements[p.Node]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, m := range other.Modules {
|
||||
if m.Module == p.Module {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
meshSeats := map[string]string{}
|
||||
addresses := map[string]string{}
|
||||
var hubs []string
|
||||
for _, node := range nodes {
|
||||
p := placements[node]
|
||||
if p.Node != node {
|
||||
refusals = append(refusals, fmt.Sprintf("the placement of %s names the node %q", node, p.Node))
|
||||
}
|
||||
if a := strings.TrimSpace(p.Decided.Address); a != "" {
|
||||
if other, twice := addresses[a]; twice {
|
||||
refusals = append(refusals, fmt.Sprintf("%s and %s both have the address %s on the private network", other, node, a))
|
||||
}
|
||||
addresses[a] = node
|
||||
}
|
||||
if p.Decided.Hub {
|
||||
hubs = append(hubs, node)
|
||||
}
|
||||
if !enrolled(node) {
|
||||
refusals = append(refusals, fmt.Sprintf("%s is a node with no record", node))
|
||||
}
|
||||
placedHere := map[string]bool{}
|
||||
for _, m := range p.Modules {
|
||||
placedHere[m.Module] = true
|
||||
}
|
||||
for _, apart := range KeptApart {
|
||||
if placedHere[apart.A] && placedHere[apart.B] {
|
||||
refusals = append(refusals, fmt.Sprintf("%s: %s and %s are never placed on one node (%s)", node, apart.A, apart.B, apart.Record))
|
||||
}
|
||||
}
|
||||
hostPorts := map[int64]string{}
|
||||
for _, m := range p.Modules {
|
||||
if !known(m.Module) {
|
||||
refusals = append(refusals, fmt.Sprintf("%s: the indexer holds no module %s", node, m.Module))
|
||||
}
|
||||
for _, port := range sortedKeys(m.Ports) {
|
||||
at := m.Ports[port]
|
||||
if other, clash := hostPorts[at]; clash && other != m.Module {
|
||||
refusals = append(refusals, fmt.Sprintf("%s: %s and %s are both published on host port %d", node, other, m.Module, at))
|
||||
}
|
||||
hostPorts[at] = m.Module
|
||||
}
|
||||
for _, provision := range sortedKeys(m.Bindings) {
|
||||
if to := m.Bindings[provision]; !placed(to) {
|
||||
refusals = append(refusals, fmt.Sprintf("%s: %s's %s is bound to %s/%s, which is not placed there", node, m.Module, provision, to.Node, to.Module))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, provision := range sortedKeys(p.Pins) {
|
||||
if to := p.Pins[provision]; !placed(to) {
|
||||
refusals = append(refusals, fmt.Sprintf("%s: the pin of %s names %s/%s, which is not placed there", node, provision, to.Node, to.Module))
|
||||
}
|
||||
}
|
||||
for _, h := range p.Holds {
|
||||
if h.Scope != "mesh" {
|
||||
continue
|
||||
}
|
||||
name := h.Claim + "/" + h.Kind
|
||||
if s, known := catalogue.SeatNamed(h.Claim); known && s.Replicated {
|
||||
// A replicated mesh seat (ADR 0223) is held on several nodes at once, each answering the same thing;
|
||||
// it is still held once on each node.
|
||||
name += "/" + node
|
||||
}
|
||||
if other, twice := meshSeats[name]; twice {
|
||||
refusals = append(refusals, fmt.Sprintf("the seat %s, held once for the mesh, is held on %s and %s", h.Claim, other, node))
|
||||
}
|
||||
meshSeats[name] = node
|
||||
}
|
||||
}
|
||||
if len(hubs) > 1 {
|
||||
refusals = append(refusals, fmt.Sprintf("the private network has one hub, and %s are each placed as it", strings.Join(hubs, ", ")))
|
||||
}
|
||||
return refusals
|
||||
}
|
||||
|
||||
// Change is what `placement-changed` says of one node (ADR 0567 §6): the node, the revision of its key before (0: it had
|
||||
// none) and after (0: the key was deleted), and its modules added, removed or changed against the placement before.
|
||||
type Change struct {
|
||||
Node string `json:"node"`
|
||||
Before uint64 `json:"before"`
|
||||
After uint64 `json:"after"`
|
||||
Added []string `json:"added,omitempty"`
|
||||
Removed []string `json:"removed,omitempty"`
|
||||
Changed []string `json:"changed,omitempty"`
|
||||
}
|
||||
|
||||
// Body is the change as the seat's event carries it, one entry of its `nodes`.
|
||||
func (c Change) Body() map[string]any {
|
||||
raw, _ := json.Marshal(c)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ChangeOf is what changed from before to after on one node; before is the zero Placement for a node new to placement.
|
||||
// A change of the node's pins or holdings counts as a change of every module placed on it both times, since the
|
||||
// composition of each may read them.
|
||||
func ChangeOf(before, after seat.Placement) Change {
|
||||
c := Change{Node: after.Node}
|
||||
if c.Node == "" {
|
||||
c.Node = before.Node
|
||||
}
|
||||
was := map[string]seat.PlacedModule{}
|
||||
for _, m := range before.Modules {
|
||||
was[m.Module] = m
|
||||
}
|
||||
nodeWide := !jsonEqual(before.Pins, after.Pins) || !jsonEqual(before.Holds, after.Holds)
|
||||
now := map[string]bool{}
|
||||
for _, m := range after.Modules {
|
||||
now[m.Module] = true
|
||||
old, ok := was[m.Module]
|
||||
switch {
|
||||
case !ok:
|
||||
c.Added = append(c.Added, m.Module)
|
||||
case nodeWide || !jsonEqual(old, m):
|
||||
c.Changed = append(c.Changed, m.Module)
|
||||
}
|
||||
}
|
||||
for _, m := range before.Modules {
|
||||
if !now[m.Module] {
|
||||
c.Removed = append(c.Removed, m.Module)
|
||||
}
|
||||
}
|
||||
sort.Strings(c.Added)
|
||||
sort.Strings(c.Removed)
|
||||
sort.Strings(c.Changed)
|
||||
return c
|
||||
}
|
||||
|
||||
// SortChanges orders a placement-changed event's nodes by node.
|
||||
func SortChanges(c []Change) {
|
||||
sort.Slice(c, func(i, j int) bool { return c[i].Node < c[j].Node })
|
||||
}
|
||||
|
||||
func jsonEqual(a, b any) bool {
|
||||
ja, _ := json.Marshal(a)
|
||||
jb, _ := json.Marshal(b)
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
// SeatHoldersOf is the state composed from every node's placement (ADR 0567 §1): each holding by the seat it claims,
|
||||
// keyed as the client's SeatHoldersKey says, with the module holding it and its node. A claim naming a seat's former name
|
||||
// is keyed by the seat's name (ADR 0122); one naming no seat is kept, marked Outside. Deterministic, so a writer writes
|
||||
// only a key that differs.
|
||||
func SeatHoldersOf(placements map[string]seat.Placement) map[string]seat.SeatHolders {
|
||||
out := map[string]seat.SeatHolders{}
|
||||
for _, n := range sortedKeys(placements) {
|
||||
for _, h := range placements[n].Holds {
|
||||
node := ""
|
||||
if h.Scope == "node" {
|
||||
node = n
|
||||
}
|
||||
s, known := catalogue.SeatNamed(h.Claim)
|
||||
name := h.Claim
|
||||
if known {
|
||||
name = s.Name
|
||||
}
|
||||
key, err := client.SeatHoldersKey(name, node)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sh := out[key]
|
||||
sh.V, sh.Seat, sh.Scope, sh.Node, sh.Outside = keys.Version, name, h.Scope, node, !known
|
||||
sh.Holders = append(sh.Holders, seat.SeatHoldersHoldersItem{Module: h.Module, Node: n})
|
||||
out[key] = sh
|
||||
}
|
||||
}
|
||||
for k, sh := range out {
|
||||
sort.Slice(sh.Holders, func(i, j int) bool {
|
||||
return sh.Holders[i].Node+"/"+sh.Holders[i].Module < sh.Holders[j].Node+"/"+sh.Holders[j].Module
|
||||
})
|
||||
out[k] = sh
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package placement
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
// The digests here are of values a proposal's ask and a seed were bound to before this package: the fields are written in
|
||||
// the order the first writer wrote them (the generated contract types order theirs by name), so a digest an earlier holder
|
||||
// bound an ask to is the one this computes. Golden values in the tests hold it.
|
||||
|
||||
type providerForm struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
type placedForm struct {
|
||||
Module string `json:"module"`
|
||||
Ports map[string]int64 `json:"ports,omitempty"`
|
||||
Bindings map[string]providerForm `json:"bindings,omitempty"`
|
||||
MeshWide map[string]any `json:"mesh-wide,omitempty"`
|
||||
HasMesh bool `json:"has-mesh-wide,omitempty"`
|
||||
Layer map[string]any `json:"layer,omitempty"`
|
||||
HasLayer bool `json:"has-layer,omitempty"`
|
||||
MeshValues map[string]any `json:"mesh-wide-values,omitempty"`
|
||||
Values map[string]any `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
type holdingForm struct {
|
||||
Claim string `json:"claim"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Module string `json:"module"`
|
||||
Site string `json:"site,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
|
||||
type decidedForm struct {
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
Site string `json:"site,omitempty"`
|
||||
Hub bool `json:"hub,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
PublicDomain string `json:"public-domain,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
AccountHome string `json:"account-home,omitempty"`
|
||||
AgentAccount string `json:"agent-account,omitempty"`
|
||||
AgentHome string `json:"agent-account-home,omitempty"`
|
||||
Adopted bool `json:"adopted,omitempty"`
|
||||
Taken []string `json:"taken,omitempty"`
|
||||
Tunnel *tunnelForm `json:"tunnel,omitempty"`
|
||||
PeerNames map[string]string `json:"peer-names,omitempty"`
|
||||
}
|
||||
|
||||
type tunnelForm struct {
|
||||
Interface string `json:"interface"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Config string `json:"config,omitempty"`
|
||||
Port int64 `json:"port,omitempty"`
|
||||
MTU int64 `json:"mtu,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Range string `json:"range,omitempty"`
|
||||
PublicKey string `json:"public-key,omitempty"`
|
||||
Peers []tunnelPeerForm `json:"peers,omitempty"`
|
||||
}
|
||||
|
||||
type tunnelPeerForm struct {
|
||||
PublicKey string `json:"public-key"`
|
||||
Address string `json:"address,omitempty"`
|
||||
}
|
||||
|
||||
type placementForm struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Revision string `json:"revision,omitempty"`
|
||||
Decided decidedForm `json:"decided"`
|
||||
Modules []placedForm `json:"modules"`
|
||||
Pins map[string]providerForm `json:"pins,omitempty"`
|
||||
Holds []holdingForm `json:"holds,omitempty"`
|
||||
}
|
||||
|
||||
type joiningForm struct {
|
||||
Identity string `json:"identity"`
|
||||
Overlay string `json:"overlay,omitempty"`
|
||||
Sealing string `json:"sealing"`
|
||||
Serving string `json:"serving,omitempty"`
|
||||
}
|
||||
|
||||
type changeForm struct {
|
||||
Op string `json:"op"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module,omitempty"`
|
||||
Ports map[string]int64 `json:"ports,omitempty"`
|
||||
Bindings map[string]providerForm `json:"bindings,omitempty"`
|
||||
Provision string `json:"provision,omitempty"`
|
||||
Provider *providerForm `json:"provider,omitempty"`
|
||||
Claim string `json:"claim,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Site string `json:"site,omitempty"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
MeshWide map[string]any `json:"mesh-wide,omitempty"`
|
||||
Layer map[string]any `json:"layer,omitempty"`
|
||||
MeshValues map[string]any `json:"mesh-wide-values,omitempty"`
|
||||
Values map[string]any `json:"values,omitempty"`
|
||||
Whole *placementForm `json:"whole,omitempty"`
|
||||
Decided *decidedForm `json:"decided,omitempty"`
|
||||
Keys *joiningForm `json:"keys,omitempty"`
|
||||
}
|
||||
|
||||
func providerOf(p seat.Provider) providerForm { return providerForm{Node: p.Node, Module: p.Module} }
|
||||
|
||||
func providersOf(m map[string]seat.Provider) map[string]providerForm {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]providerForm, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = providerOf(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tunnelOf(t *seat.FoundTunnel) *tunnelForm {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
out := &tunnelForm{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.Mtu, Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
|
||||
for _, p := range t.Peers {
|
||||
out.Peers = append(out.Peers, tunnelPeerForm{PublicKey: p.PublicKey, Address: p.Address})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decidedOf(d seat.NodeDecided) decidedForm {
|
||||
return decidedForm{Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address, PublicDomain: d.PublicDomain,
|
||||
Account: d.Account, AccountHome: d.AccountHome, AgentAccount: d.AgentAccount, AgentHome: d.AgentAccountHome,
|
||||
Adopted: d.Adopted, Taken: d.Taken, Tunnel: tunnelOf(d.Tunnel), PeerNames: d.PeerNames}
|
||||
}
|
||||
|
||||
func placementOf(p seat.Placement) placementForm {
|
||||
out := placementForm{V: int(p.V), Node: p.Node, Revision: p.Revision, Decided: decidedOf(p.Decided),
|
||||
Pins: providersOf(p.Pins)}
|
||||
if p.Modules != nil {
|
||||
out.Modules = []placedForm{}
|
||||
}
|
||||
for _, m := range p.Modules {
|
||||
out.Modules = append(out.Modules, placedForm{Module: m.Module, Ports: m.Ports, Bindings: providersOf(m.Bindings),
|
||||
MeshWide: m.MeshWide, HasMesh: m.HasMeshWide, Layer: m.Layer, HasLayer: m.HasLayer, MeshValues: m.MeshWideValues, Values: m.Values})
|
||||
}
|
||||
for _, h := range p.Holds {
|
||||
out.Holds = append(out.Holds, holdingForm{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func changeOf(c seat.ProposalChange) changeForm {
|
||||
out := changeForm{Op: c.Op, Node: c.Node, Module: c.Module, Ports: c.Ports, Bindings: providersOf(c.Bindings), Provision: c.Provision,
|
||||
Claim: c.Claim, Scope: c.Scope, Kind: c.Kind, Site: c.Site, Field: c.Field, MeshWide: c.MeshWide, Layer: c.Layer,
|
||||
MeshValues: c.MeshWideValues, Values: c.Values}
|
||||
if c.Provider != nil {
|
||||
p := providerOf(*c.Provider)
|
||||
out.Provider = &p
|
||||
}
|
||||
if c.Value != nil {
|
||||
out.Value, _ = json.Marshal(c.Value)
|
||||
}
|
||||
if c.Whole != nil {
|
||||
w := placementOf(*c.Whole)
|
||||
out.Whole = &w
|
||||
}
|
||||
if c.Decided != nil {
|
||||
d := decidedOf(*c.Decided)
|
||||
out.Decided = &d
|
||||
}
|
||||
if c.Keys != nil {
|
||||
out.Keys = &joiningForm{Identity: c.Keys.Identity, Overlay: c.Keys.Overlay, Sealing: c.Keys.Sealing, Serving: c.Keys.Serving}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ExportDigest is the digest of an export's answer, every node's placement, as the seed proposes it: the same
|
||||
// placements give the same digest, whatever order they came in.
|
||||
func ExportDigest(placements map[string]seat.Placement) string {
|
||||
h := sha256.New()
|
||||
for _, node := range sortedKeys(placements) {
|
||||
p := placements[node]
|
||||
p.V = keys.Version // the export was made with the placement encoded at the keys' version
|
||||
raw, _ := json.Marshal(placementOf(p))
|
||||
fmt.Fprintf(h, "%s\n%s\n", node, raw)
|
||||
}
|
||||
return "sha256:" + hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// ChangesDigest is the digest of a proposal's changes, which its authorising ask is bound to (§5).
|
||||
func ChangesDigest(p seat.Proposal) string {
|
||||
var changes []changeForm
|
||||
if p.Changes != nil {
|
||||
changes = make([]changeForm, 0, len(p.Changes))
|
||||
}
|
||||
for _, c := range p.Changes {
|
||||
changes = append(changes, changeOf(c))
|
||||
}
|
||||
raw, _ := json.Marshal(struct {
|
||||
ID string `json:"id"`
|
||||
Changes []changeForm `json:"changes"`
|
||||
Base map[string]int64 `json:"base"`
|
||||
}{p.ID, changes, p.Base})
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package placement
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest"
|
||||
)
|
||||
|
||||
// TestMain gives catalogue the seat list a host reads (ADR 0636 §1): none is compiled into mesh-sdk.
|
||||
func TestMain(m *testing.M) {
|
||||
catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions()))
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package placement
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
client "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
|
||||
seat "git.novox.be/novox/mesh-contracts/placement/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 placement, 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 TestPlacement(t *testing.T) {
|
||||
g14 := seat.Placement{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.Placement{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.Placement{"g14": g14, "novox": novox}
|
||||
const id = "p-20261028t183000-1a2b3c"
|
||||
const enroller = "mesh-enrolment"
|
||||
|
||||
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.Placement{}, 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 placement", func(t *testing.T) {
|
||||
known := func(m string) bool { return m != "ghost" }
|
||||
enrolled := func(n string) bool { return n != "stranger" }
|
||||
if r := CheckPlacements(current, known, 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 name, refused := range map[string]bool{"mesh-dns-resolver": false, "mesh-broker": true} {
|
||||
a := seat.Placement{Node: "ace", Modules: []seat.PlacedModule{{Module: "dns"}}, Holds: []seat.Holding{{Claim: name, Scope: "mesh", Module: "dns"}}}
|
||||
b := a
|
||||
b.Node = "novox"
|
||||
r := CheckPlacements(map[string]seat.Placement{"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 := CheckPlacements(map[string]seat.Placement{"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: OpPlace, Node: "g14", Module: "new"},
|
||||
{Op: OpTakeOff, Node: "g14", Module: "zsh"},
|
||||
{Op: OpUnpin, Node: "g14", Provision: "shell"},
|
||||
{Op: 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 placement")
|
||||
}
|
||||
if fresh, f := LayChanges(current, []seat.ProposalChange{{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 seat.ProposalChange
|
||||
want string
|
||||
}{
|
||||
"not placed": {seat.ProposalChange{Op: OpTakeOff, Node: "g14", Module: "nope"}, "not placed"},
|
||||
"no such field": {seat.ProposalChange{Op: OpDecide, Node: "g14", Field: "colour", Value: "x"}, "no decided field"},
|
||||
"wrong type": {seat.ProposalChange{Op: OpDecide, Node: "g14", Field: "hub", Value: "x"}, "does not take"},
|
||||
"unknown op": {seat.ProposalChange{Op: "move", Node: "g14"}, "no change of placement"},
|
||||
"pin of nobody": {seat.ProposalChange{Op: OpPin, Node: "g14", Provision: "x"}, "provider"},
|
||||
"not held": {seat.ProposalChange{Op: OpUnhold, Node: "g14", Module: "app", Claim: "x"}, "holds no"},
|
||||
"bad port": {seat.ProposalChange{Op: OpPlace, Node: "g14", Module: "x", Ports: map[string]int64{"a": 1}}, "port"},
|
||||
"seed of another": {seat.ProposalChange{Op: 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: OpTakeOff, Node: "g14", Module: "zsh"}}, Base: map[string]int64{"g14": 4},
|
||||
State: 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: 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: ProposalAnswering,
|
||||
Base: map[string]int64{"g14": 4, "lab": 0, "novox": 2},
|
||||
Changes: []seat.ProposalChange{
|
||||
{Op: OpTakeOff, Node: "g14", Module: "zsh"},
|
||||
{Op: OpPlace, Node: "g14", Module: "new", Ports: map[string]int64{"80": 8080}, Bindings: map[string]seat.Provider{"pg": {Node: "novox", Module: "postgres"}}},
|
||||
{Op: OpPin, Node: "g14", Provision: "shell", Provider: &seat.Provider{Node: "g14", Module: "bash"}},
|
||||
{Op: OpHold, Node: "g14", Module: "app", Claim: "node-shell", Scope: "node", Kind: "k", Site: "s"},
|
||||
{Op: OpDecide, Node: "novox", Field: "site", Value: "attic"},
|
||||
{Op: OpDecide, Node: "novox", Field: "peer-names", Value: map[string]any{"k": "v"}},
|
||||
{Op: 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: 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.Placement{"novox": novox, "g14": g14}) || ExportDigest(current) == ExportDigest(nil) {
|
||||
t.Error("the export digest depends on order or not on the placements")
|
||||
}
|
||||
// 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: 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: 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, enroller) != "" || !strings.Contains(AddNodeRefusal(add, "mesh-delivery-assigner", enroller), "mesh-enrolment's alone") {
|
||||
t.Errorf("add-node by caller: %q, %q", AddNodeRefusal(add, enroller, enroller), AddNodeRefusal(add, "mesh-delivery-assigner", enroller))
|
||||
}
|
||||
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: 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: 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: 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: OpRemoveNode, Node: "lab"}}
|
||||
if _, err := EncodeProposal(remove); err != nil || AddNodeRefusal(remove, "mesh-delivery-assigner", enroller) == "" ||
|
||||
AddNodeRefusal(remove, enroller, enroller) != "" || !slices.Equal(RemovedNodes(remove), []string{"lab"}) {
|
||||
t.Errorf("a remove-node: %v, %q", err, AddNodeRefusal(remove, "mesh-delivery-assigner", enroller))
|
||||
}
|
||||
joined := map[string]seat.Placement{"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: 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 = 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 placement; a node seat by its node.
|
||||
t.Run("who holds which seat", func(t *testing.T) {
|
||||
held := SeatHoldersOf(map[string]seat.Placement{
|
||||
"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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package placement
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
client "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
|
||||
seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
// A placement changes only by a proposal (novox/hq ADR 0567 §2): `propose` takes why and a list of changes; the holder
|
||||
// lays them over the current keys, names the revision of each key it laid them over, and either refuses at once with every
|
||||
// finding or writes the proposal under its id in its state `proposals` and a candidate for each node it must hear from.
|
||||
|
||||
// The operations a proposal's change names (ADR 0567 §2).
|
||||
const (
|
||||
OpPlace = "place" // place a module on a node, with its host ports and bindings
|
||||
OpTakeOff = "take-off" // take a module off a node
|
||||
OpPin = "pin" // pin the provider of a provision on a node
|
||||
OpUnpin = "unpin" // remove a pin
|
||||
OpHold = "hold" // a module placed on the node holds a seat
|
||||
OpUnhold = "unhold" // it holds it no more
|
||||
OpDecide = "decide" // set a decided field of the node (NodeDecided by its JSON name), its address among them
|
||||
OpConfig = "config" // set a module's config placement or values on the node
|
||||
OpSeed = "seed" // the node's whole placement: the take-over (adopt, §7), never given by a caller
|
||||
// OpAddNode adds a joining node with its decided fields and the public halves of its keys, on the warrant of the
|
||||
// enrolment that issued its token (novox/hq ADR 0587 §7): the enroller's alone, the proposal's one change.
|
||||
OpAddNode = "add-node"
|
||||
// OpRemoveNode takes a joining node off placement again when its enrolment ends unconfirmed, revoked or expired
|
||||
// (the operator's decision of 2026-11-02 01:05): the enroller's alone, on the same warrant, the proposal's one
|
||||
// change, of a node with no module and no holding. The holder deletes its key (RemovedNodes).
|
||||
OpRemoveNode = "remove-node"
|
||||
)
|
||||
|
||||
// The states of an open proposal.
|
||||
const (
|
||||
// ProposalAnswering: its candidates are written and the nodes it must hear from have not all answered.
|
||||
ProposalAnswering = "answering"
|
||||
// ProposalFailed: its verdict failed; it stays open, for its proposer to read and withdraw, or for a node's later
|
||||
// answer to judge it again.
|
||||
ProposalFailed = "failed"
|
||||
// ProposalAsked: its verdict passed and the operator is asked to approve it.
|
||||
ProposalAsked = "asked"
|
||||
)
|
||||
|
||||
// VerdictWaits is a proposal's verdict while a node it must hear from has not answered within the bound; it passes or
|
||||
// fails as a check's verdict does (ADR 0567 §4).
|
||||
const VerdictWaits = "waits"
|
||||
|
||||
// VerdictPass and VerdictFail are the other two states of a proposal's verdict.
|
||||
const (
|
||||
VerdictPass = "pass"
|
||||
VerdictFail = "fail"
|
||||
)
|
||||
|
||||
// Nodes is every node whose key the proposal changes, sorted.
|
||||
func Nodes(p seat.Proposal) []string { return sortedKeys(p.Base) }
|
||||
|
||||
// RemovedNodes are the nodes whose key a proposal deletes (its remove-node), which LayChanges leaves out of what it lays.
|
||||
func RemovedNodes(p seat.Proposal) []string {
|
||||
var out []string
|
||||
for _, c := range p.Changes {
|
||||
if c.Op == OpRemoveNode {
|
||||
out = append(out, c.Node)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CheckChange refuses a change that names what its operation needs not, or an operation that is none.
|
||||
func CheckChange(c seat.ProposalChange) error {
|
||||
if err := keys.CheckName("node", c.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
needs := func(what string, ok bool) error {
|
||||
if !ok {
|
||||
return fmt.Errorf("placement: a %s change of %s names no %s", c.Op, c.Node, what)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch c.Op {
|
||||
case OpPlace, OpTakeOff, OpConfig:
|
||||
return needs("module", keys.CheckName("module", c.Module) == nil)
|
||||
case OpPin:
|
||||
return needs("provision or provider", c.Provision != "" && c.Provider != nil && c.Provider.Node != "" && c.Provider.Module != "")
|
||||
case OpUnpin:
|
||||
return needs("provision", c.Provision != "")
|
||||
case OpHold, OpUnhold:
|
||||
return needs("module or claim", keys.CheckName("module", c.Module) == nil && c.Claim != "")
|
||||
case OpDecide:
|
||||
return needs("field or value", c.Field != "" && c.Value != nil)
|
||||
case OpSeed:
|
||||
if c.Whole == nil || c.Whole.Node != c.Node {
|
||||
return fmt.Errorf("placement: the seed of %s names no placement of that node", c.Node)
|
||||
}
|
||||
return client.CheckPlacement(*c.Whole)
|
||||
case OpAddNode:
|
||||
if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Overlay == "" {
|
||||
return fmt.Errorf("placement: the add-node of %s names no address or no tunnel key", c.Node)
|
||||
}
|
||||
// The identity keys come at redeem, whole or not at all: an identity with no sealing key, or the reverse.
|
||||
if (c.Keys.Identity == "") != (c.Keys.Sealing == "") || (c.Keys.Identity == "" && c.Keys.Serving != "") {
|
||||
return fmt.Errorf("placement: the add-node of %s names part of its identity keys; they come whole at redeem", c.Node)
|
||||
}
|
||||
if c.Decided.Hub {
|
||||
return fmt.Errorf("placement: the add-node of %s makes it the hub, which a joining node never is", c.Node)
|
||||
}
|
||||
if c.Module != "" || len(c.Ports)+len(c.Bindings) > 0 || c.Whole != nil {
|
||||
return fmt.Errorf("placement: the add-node of %s places a module; a module is placed by a proposal of its own", c.Node)
|
||||
}
|
||||
return nil
|
||||
case OpRemoveNode:
|
||||
if c.Module != "" || c.Decided != nil || c.Keys != nil || c.Whole != nil {
|
||||
return fmt.Errorf("placement: the remove-node of %s names more than its node", c.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("placement: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide, config, add-node or remove-node", c.Op)
|
||||
}
|
||||
|
||||
// LayChanges is the placements of the nodes the changes name with the changes laid over current, in the order given;
|
||||
// current is every node's placement now (absent: the node has no key). It returns, by node, the laid placement, and
|
||||
// every finding: a change that does not fit its node (a module taken off that is not placed, configured or holding
|
||||
// there; a field NodeDecided does not have) or a placement the client refuses. What CheckPlacements refuses across nodes
|
||||
// is the caller's to ask of the result laid over current.
|
||||
func LayChanges(current map[string]seat.Placement, changes []seat.ProposalChange) (map[string]seat.Placement, []string) {
|
||||
laid := map[string]seat.Placement{}
|
||||
var findings []string
|
||||
of := func(node string) seat.Placement {
|
||||
if p, ok := laid[node]; ok {
|
||||
return p
|
||||
}
|
||||
p, ok := current[node]
|
||||
if !ok {
|
||||
return seat.Placement{V: keys.Version, Node: node, Modules: []seat.PlacedModule{}}
|
||||
}
|
||||
return clonePlacement(p)
|
||||
}
|
||||
for i, c := range changes {
|
||||
if err := CheckChange(c); err != nil {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %v", i+1, err))
|
||||
continue
|
||||
}
|
||||
p := of(c.Node)
|
||||
at := -1
|
||||
for j, m := range p.Modules {
|
||||
if m.Module == c.Module {
|
||||
at = j
|
||||
}
|
||||
}
|
||||
notPlaced := func() {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s is not placed on %s", i+1, c.Module, c.Node))
|
||||
}
|
||||
switch c.Op {
|
||||
case OpSeed:
|
||||
p = clonePlacement(*c.Whole)
|
||||
case OpAddNode:
|
||||
// A joining node has no key yet (ADR 0587 §7): one that has is no joining node.
|
||||
if _, has := current[c.Node]; has {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s is placed already, so it does not join", i+1, c.Node))
|
||||
continue
|
||||
}
|
||||
p = seat.Placement{V: keys.Version, Node: c.Node, Decided: *c.Decided, Modules: []seat.PlacedModule{}}
|
||||
case OpRemoveNode:
|
||||
// Only a joining node never confirmed goes this way: one with no module and no holding (a node that runs
|
||||
// anything is taken off module by module, by a proposal the operator approves).
|
||||
cur, has := current[c.Node]
|
||||
if !has {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s is not placed, so there is nothing to remove", i+1, c.Node))
|
||||
} else if len(cur.Modules)+len(cur.Holds) > 0 {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s runs modules or holds seats, so it is no joining node to remove", i+1, c.Node))
|
||||
}
|
||||
continue
|
||||
case OpPlace:
|
||||
m := seat.PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings}
|
||||
if at >= 0 {
|
||||
o := p.Modules[at]
|
||||
m.MeshWide, m.HasMeshWide, m.Layer, m.HasLayer = o.MeshWide, o.HasMeshWide, o.Layer, o.HasLayer
|
||||
m.MeshWideValues, m.Values = o.MeshWideValues, o.Values
|
||||
p.Modules[at] = m
|
||||
} else {
|
||||
p.Modules = append(p.Modules, m)
|
||||
}
|
||||
case OpTakeOff:
|
||||
if at < 0 {
|
||||
notPlaced()
|
||||
continue
|
||||
}
|
||||
p.Modules = append(p.Modules[:at:at], p.Modules[at+1:]...)
|
||||
var holds []seat.Holding
|
||||
for _, h := range p.Holds {
|
||||
if h.Module != c.Module {
|
||||
holds = append(holds, h)
|
||||
}
|
||||
}
|
||||
p.Holds = holds
|
||||
case OpConfig:
|
||||
if at < 0 {
|
||||
notPlaced()
|
||||
continue
|
||||
}
|
||||
m := &p.Modules[at]
|
||||
if c.MeshWide != nil {
|
||||
m.MeshWide, m.HasMeshWide = c.MeshWide, true
|
||||
}
|
||||
if c.Layer != nil {
|
||||
m.Layer, m.HasLayer = c.Layer, true
|
||||
}
|
||||
if c.MeshWideValues != nil {
|
||||
m.MeshWideValues = c.MeshWideValues
|
||||
}
|
||||
if c.Values != nil {
|
||||
m.Values = c.Values
|
||||
}
|
||||
case OpPin:
|
||||
if p.Pins == nil {
|
||||
p.Pins = map[string]seat.Provider{}
|
||||
}
|
||||
p.Pins[c.Provision] = *c.Provider
|
||||
case OpUnpin:
|
||||
delete(p.Pins, c.Provision)
|
||||
if len(p.Pins) == 0 {
|
||||
p.Pins = nil
|
||||
}
|
||||
case OpHold:
|
||||
if at < 0 {
|
||||
notPlaced()
|
||||
continue
|
||||
}
|
||||
p.Holds = append(p.Holds, seat.Holding{Claim: c.Claim, Scope: c.Scope, Module: c.Module, Site: c.Site, Kind: c.Kind})
|
||||
case OpUnhold:
|
||||
kept := p.Holds[:0:0]
|
||||
for _, h := range p.Holds {
|
||||
if h.Module != c.Module || h.Claim != c.Claim {
|
||||
kept = append(kept, h)
|
||||
}
|
||||
}
|
||||
if len(kept) == len(p.Holds) {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s holds no %s on %s", i+1, c.Module, c.Claim, c.Node))
|
||||
continue
|
||||
}
|
||||
p.Holds = kept
|
||||
case OpDecide:
|
||||
d, err := decide(p.Decided, c.Field, c.Value)
|
||||
if err != nil {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %v", i+1, err))
|
||||
continue
|
||||
}
|
||||
p.Decided = d
|
||||
}
|
||||
laid[c.Node] = p
|
||||
}
|
||||
for _, node := range sortedKeys(laid) {
|
||||
if err := client.CheckPlacement(laid[node]); err != nil {
|
||||
findings = append(findings, err.Error())
|
||||
}
|
||||
}
|
||||
return laid, findings
|
||||
}
|
||||
|
||||
// decide is d with the field named by its JSON name set to value, refused when NodeDecided has no such field or the
|
||||
// value does not fit it.
|
||||
func decide(d seat.NodeDecided, field string, value any) (seat.NodeDecided, error) {
|
||||
raw, _ := json.Marshal(d)
|
||||
var fields map[string]json.RawMessage
|
||||
_ = json.Unmarshal(raw, &fields)
|
||||
if fields == nil {
|
||||
fields = map[string]json.RawMessage{}
|
||||
}
|
||||
if !decidedFields[field] {
|
||||
return d, fmt.Errorf("a node has no decided field %q", field)
|
||||
}
|
||||
set, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return d, fmt.Errorf("the decided field %q does not take %v: %v", field, value, err)
|
||||
}
|
||||
fields[field] = set
|
||||
raw, _ = json.Marshal(fields)
|
||||
var out seat.NodeDecided
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&out); err != nil {
|
||||
return d, fmt.Errorf("the decided field %q does not take %s: %v", field, set, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// decidedFields are NodeDecided's fields by their JSON names.
|
||||
var decidedFields = func() map[string]bool {
|
||||
raw, _ := json.Marshal(seat.NodeDecided{Endpoint: "x", Site: "x", Hub: true, Address: "x", PublicDomain: "x", Account: "x",
|
||||
AccountHome: "x", AgentAccount: "x", AgentAccountHome: "x", Adopted: true, Taken: []string{"x"}, Tunnel: &seat.FoundTunnel{Interface: "x"},
|
||||
PeerNames: map[string]string{"x": "x"}})
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
out := map[string]bool{}
|
||||
for k := range m {
|
||||
out[k] = true
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
func clonePlacement(p seat.Placement) seat.Placement {
|
||||
raw, _ := json.Marshal(p)
|
||||
var out seat.Placement
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
if out.Modules == nil {
|
||||
out.Modules = []seat.PlacedModule{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NewProposalID is a fresh proposal id: its time in UTC and six random hex digits, `p-20261028t183000-1a2b3c`. The
|
||||
// client's CheckProposalID says what an id is.
|
||||
func NewProposalID(at time.Time) string {
|
||||
var b [3]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return "p-" + strings.ToLower(at.UTC().Format("20060102T150405")) + "-" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// CheckProposal refuses a proposal that is none: an id that is none, no proposer or why, no change (but a hand-back), a
|
||||
// change it does not take or of a node with no base revision, the seed mixed with other changes or without its export
|
||||
// digest, a state it does not know, and an add-node or remove-node that is not as ADR 0587 §7 says.
|
||||
func CheckProposal(p seat.Proposal) error {
|
||||
if err := client.CheckProposalID(p.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(p.By) == "" || strings.TrimSpace(p.Why) == "" {
|
||||
return fmt.Errorf("placement: the proposal %s names no proposer or no why", p.ID)
|
||||
}
|
||||
if len(p.Base) == 0 || (len(p.Changes) == 0) != p.HandBack || (p.HandBack && p.Seed) {
|
||||
return fmt.Errorf("placement: the proposal %s changes nothing, or is a hand-back with changes", p.ID)
|
||||
}
|
||||
for i, c := range p.Changes {
|
||||
if err := CheckChange(c); err != nil {
|
||||
return fmt.Errorf("placement: the proposal %s, change %d: %w", p.ID, i+1, err)
|
||||
}
|
||||
if (c.Op == OpSeed) != p.Seed {
|
||||
return fmt.Errorf("placement: the proposal %s mixes the seed with other changes", p.ID)
|
||||
}
|
||||
if _, ok := p.Base[c.Node]; !ok {
|
||||
return fmt.Errorf("placement: the proposal %s changes %s and names no base revision of its key", p.ID, c.Node)
|
||||
}
|
||||
}
|
||||
switch p.State {
|
||||
case ProposalAnswering, ProposalFailed:
|
||||
case ProposalAsked:
|
||||
if p.Ask == "" || p.Verdict == nil || p.Verdict.State != VerdictPass {
|
||||
return fmt.Errorf("placement: the proposal %s is asked with no ask or no passing verdict", p.ID)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("placement: the proposal %s is in no state %q", p.ID, p.State)
|
||||
}
|
||||
if p.Seed && p.ExportDigest == "" {
|
||||
return fmt.Errorf("placement: the seed %s names no export digest", p.ID)
|
||||
}
|
||||
return checkAddNode(p)
|
||||
}
|
||||
|
||||
// checkAddNode refuses an add-node or a remove-node that is not the proposal's one change, carries no warrant, or whose
|
||||
// warrant names another node (or, for an add-node, another address) than the change; and a warrant on any other
|
||||
// proposal (ADR 0587 §7).
|
||||
func checkAddNode(p seat.Proposal) error {
|
||||
adds := 0
|
||||
for _, c := range p.Changes {
|
||||
if c.Op == OpAddNode || c.Op == OpRemoveNode {
|
||||
adds++
|
||||
}
|
||||
}
|
||||
if adds == 0 {
|
||||
if p.Warrant != nil {
|
||||
return fmt.Errorf("placement: the proposal %s carries an enrolment's warrant and adds no node", p.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if adds != 1 || len(p.Changes) != 1 || p.HandBack || p.Seed {
|
||||
return fmt.Errorf("placement: the proposal %s adds a node beside other changes; an add-node is a proposal's one change", p.ID)
|
||||
}
|
||||
c, w := p.Changes[0], p.Warrant
|
||||
if w == nil || w.Ask == "" || w.Warrant == "" {
|
||||
return fmt.Errorf("placement: the add-node of %s carries no warrant of its enrolment", c.Node)
|
||||
}
|
||||
if c.Op == OpRemoveNode {
|
||||
if w.Node != c.Node {
|
||||
return fmt.Errorf("placement: the remove-node of %s carries the warrant of %s", c.Node, w.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if w.Node != c.Node || w.Address != c.Decided.Address {
|
||||
return fmt.Errorf("placement: the add-node of %s at %s carries the warrant of %s at %s", c.Node, c.Decided.Address, w.Node, w.Address)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddNodeRefusal is why the holder of the seat refuses an add-node or a remove-node from caller, the module the runtime
|
||||
// proved called (ADR 0587 §7), or "": only the enroller (the module holding the seat mesh-node-enroller, which the caller
|
||||
// of this names) adds a node; what the proposal itself must hold, CheckProposal refuses.
|
||||
func AddNodeRefusal(p seat.Proposal, caller, enroller string) string {
|
||||
if err := checkAddNode(p); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if p.Warrant != nil && caller != enroller {
|
||||
return fmt.Sprintf("an add-node or a remove-node is %s's alone, and %s proposed it", enroller, caller)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// EncodeProposal is p as JSON at the keys' Version; it refuses what DecodeProposal refuses.
|
||||
func EncodeProposal(p seat.Proposal) ([]byte, error) {
|
||||
if err := CheckProposal(p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.V = keys.Version
|
||||
return json.Marshal(p)
|
||||
}
|
||||
|
||||
// DecodeProposal reads a proposal; it refuses an unknown version, a field the contract does not have, and what
|
||||
// CheckProposal refuses.
|
||||
func DecodeProposal(data []byte) (seat.Proposal, error) {
|
||||
if err := keys.CheckVersion(data); err != nil {
|
||||
return seat.Proposal{}, err
|
||||
}
|
||||
p, err := seat.DecodeProposalsByProposalValue(data)
|
||||
if err != nil {
|
||||
return seat.Proposal{}, fmt.Errorf("placement: a proposal does not fit its shape: %w", err)
|
||||
}
|
||||
if err := CheckProposal(p); err != nil {
|
||||
return seat.Proposal{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
Reference in New Issue
Block a user