Placement and the node record have one layout in mesh-sdk, so the indexer, the node agents and the composition check read them alike (issue 1126, ADR 0549 §1-§2, ADR 0547 §2)
This commit is contained in:
@@ -14,6 +14,8 @@ import (
|
||||
|
||||
// 1131/AC1: the have key forms and parses back; bad names are refused, naming them.
|
||||
func TestHaveKey(t *testing.T) {
|
||||
t.Run("presence", testPresenceKey)
|
||||
t.Run("pulse", testPulseKey)
|
||||
if HaveBucket != "node-engine_have" {
|
||||
t.Fatalf("HaveBucket = %q", HaveBucket)
|
||||
}
|
||||
@@ -58,7 +60,7 @@ func TestHaveKey(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1131/AC2: the presence keys form and parse back; a node named part is refused.
|
||||
func TestPresenceKey(t *testing.T) {
|
||||
func testPresenceKey(t *testing.T) {
|
||||
if PresenceBucket != "mesh-delivery-watchman_presence" {
|
||||
t.Fatalf("PresenceBucket = %q", PresenceBucket)
|
||||
}
|
||||
@@ -98,7 +100,7 @@ func TestPresenceKey(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1131/AC3: the pulse key splits at the first dot, the key holding dots.
|
||||
func TestPulseKey(t *testing.T) {
|
||||
func testPulseKey(t *testing.T) {
|
||||
if PulseBucket != "mesh-delivery-watchman_pulse" {
|
||||
t.Fatalf("PulseBucket = %q", PulseBucket)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// A node's record is what the node knows of itself, written by its own agent into the node-engine's state `records`,
|
||||
// one key `record.<node>` per node, and read by every node and by the parts that compose (novox/hq ADR 0547 §2: "of
|
||||
// other nodes it reads only what is public: their records"; issue 1126's prepare, L1 as narrowed by ADR 0549). Where a
|
||||
// module runs, its host ports, pins, bindings and seat holdings are not in it: they are the catalogue's placement
|
||||
// (placement.go). Nothing in it is secret: its keys are public halves, its accounts are login names.
|
||||
//
|
||||
// The agent writes it at start and whenever a fact it holds changes, only when the value differs, and says
|
||||
// `node-record-changed`. Seeded from what the node runs and reports, never from the controller's store.
|
||||
const (
|
||||
// NodeRecordsState is the node-engine's state of node records, and NodeRecordsBucket its bucket.
|
||||
NodeRecordsState = "records"
|
||||
NodeRecordsBucket = "node-engine_" + NodeRecordsState
|
||||
// NodeRecordsHistory is the revisions the records bucket keeps per key.
|
||||
NodeRecordsHistory = 5
|
||||
// NodeRecordChangedEvent is what a node's agent says when it wrote its record.
|
||||
NodeRecordChangedEvent = "node-record-changed"
|
||||
|
||||
recordWord = "record"
|
||||
)
|
||||
|
||||
// NodeRecordKey is the key, in NodeRecordsBucket, of one node's record: `record.<node>`.
|
||||
func NodeRecordKey(node string) (string, error) {
|
||||
if err := checkName("node", node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return recordWord + "." + node, nil
|
||||
}
|
||||
|
||||
// ParseNodeRecordKey is the inverse of NodeRecordKey; it refuses any other key, naming it.
|
||||
func ParseNodeRecordKey(key string) (node string, err error) {
|
||||
node, ok := strings.CutPrefix(key, recordWord+".")
|
||||
if !ok || checkName("node", node) != nil {
|
||||
return "", fmt.Errorf("delivery: %q is not a node record key, record.<node>", key)
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// NodePlace is where the node is on the private network: its tunnel key (public), endpoint, site, whether it is the
|
||||
// hub, and its address (compose.Place).
|
||||
type NodePlace struct {
|
||||
Key string `json:"key,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
Site string `json:"site,omitempty"`
|
||||
Hub bool `json:"hub,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
}
|
||||
|
||||
// RecordTunnel is a tunnel found on the node, as compose.Tunnel holds it: public keys and addresses only.
|
||||
type RecordTunnel struct {
|
||||
Interface string `json:"interface"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Config string `json:"config,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
MTU int `json:"mtu,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Range string `json:"range,omitempty"`
|
||||
PublicKey string `json:"public-key,omitempty"`
|
||||
Peers []RecordTunnelPeer `json:"peers,omitempty"`
|
||||
}
|
||||
|
||||
// RecordTunnelPeer is one peer of a found tunnel.
|
||||
type RecordTunnelPeer struct {
|
||||
PublicKey string `json:"public-key"`
|
||||
Address string `json:"address,omitempty"`
|
||||
}
|
||||
|
||||
// RecordCarriedPeer is a peer of the tunnel the hub adopted, which the hub carries (compose.CarriedPeer).
|
||||
type RecordCarriedPeer struct {
|
||||
PublicKey string `json:"public-key"`
|
||||
Address string `json:"address,omitempty"`
|
||||
EnrolledAs string `json:"enrolled-as,omitempty"`
|
||||
Named string `json:"named,omitempty"`
|
||||
}
|
||||
|
||||
// NodeRecord is one node's record. Tunnel is the tunnel found on it whose key is its private-network key; on the hub
|
||||
// alone, Adopted says whether the hub took its found tunnel over and Carried is every peer of it the hub carries, so
|
||||
// the tunnels and the carried peers are each one node's own and need no other home.
|
||||
type NodeRecord struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Place NodePlace `json:"place"`
|
||||
Capabilities []string `json:"capabilities,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"`
|
||||
SealingKey string `json:"sealing-key,omitempty"`
|
||||
ServingKey string `json:"serving-key,omitempty"`
|
||||
Certificate string `json:"certificate,omitempty"`
|
||||
OutwardLinks []string `json:"outward-links,omitempty"`
|
||||
Networks []catalogue.AttachedNetwork `json:"networks,omitempty"`
|
||||
NetworksSaid bool `json:"networks-said,omitempty"`
|
||||
Contract int `json:"contract,omitempty"`
|
||||
Tunnel *RecordTunnel `json:"tunnel,omitempty"`
|
||||
HubAdopted bool `json:"hub-adopted,omitempty"`
|
||||
Carried []RecordCarriedPeer `json:"carried,omitempty"`
|
||||
}
|
||||
|
||||
func (r NodeRecord) check() error {
|
||||
if err := checkName("node", r.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if !r.Place.Hub && (r.HubAdopted || len(r.Carried) > 0) {
|
||||
return fmt.Errorf("delivery: the record of %s carries peers or an adopted tunnel, which only the hub's does", r.Node)
|
||||
}
|
||||
if r.HubAdopted && r.Tunnel == nil {
|
||||
return fmt.Errorf("delivery: the record of %s says the hub adopted its tunnel, and names none", r.Node)
|
||||
}
|
||||
for _, c := range r.Capabilities {
|
||||
if c == "" {
|
||||
return fmt.Errorf("delivery: the record of %s names an empty capability", r.Node)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeNodeRecord is r as JSON at Version; it refuses what DecodeNodeRecord refuses.
|
||||
func EncodeNodeRecord(r NodeRecord) ([]byte, error) {
|
||||
if err := r.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.V = Version
|
||||
return json.Marshal(r)
|
||||
}
|
||||
|
||||
// DecodeNodeRecord reads a node record; it refuses an unknown version (ErrUnknownVersion), a node that is not a name,
|
||||
// carried peers or an adopted tunnel on a node that is not the hub, and an empty capability.
|
||||
func DecodeNodeRecord(data []byte) (NodeRecord, error) {
|
||||
var r NodeRecord
|
||||
if err := decode(data, &r); err != nil {
|
||||
return NodeRecord{}, err
|
||||
}
|
||||
if err := r.check(); err != nil {
|
||||
return NodeRecord{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// SameNodeRecord says whether two records are the same value, so the agent writes only when they differ.
|
||||
func SameNodeRecord(a, b NodeRecord) bool {
|
||||
a.V, b.V = 0, 0
|
||||
return jsonEqual(a, b)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// secretNamed is facts.secretKey's pattern: a field so named would read as a secret, and the runtime's credential-field
|
||||
// check (ADR 0201 §13) refuses such a field in a state every node reads.
|
||||
var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`)
|
||||
|
||||
// Issue 1126 (L1 as narrowed by ADR 0549): a node's record keys, reads back, refuses what only the hub may carry, and
|
||||
// names no field like a secret.
|
||||
func TestNodeRecord(t *testing.T) {
|
||||
key, err := NodeRecordKey("ace")
|
||||
if err != nil || key != "record.ace" || NodeRecordsBucket != "node-engine_records" {
|
||||
t.Fatalf("NodeRecordKey = %q, %v; bucket %q", key, err, NodeRecordsBucket)
|
||||
}
|
||||
if n, err := ParseNodeRecordKey(key); err != nil || n != "ace" {
|
||||
t.Fatalf("ParseNodeRecordKey = %q, %v", n, err)
|
||||
}
|
||||
for _, bad := range []string{"placement.ace", "record.", "record.a.b"} {
|
||||
if _, err := ParseNodeRecordKey(bad); err == nil {
|
||||
t.Errorf("ParseNodeRecordKey(%q) took it", bad)
|
||||
}
|
||||
}
|
||||
|
||||
hub := NodeRecord{Node: "novox", Place: NodePlace{Key: "k", Endpoint: "192.0.2.1:51820", Hub: true, Address: "10.0.0.1"},
|
||||
Capabilities: []string{"docker"}, PublicDomain: "example.org", Account: "op", AccountHome: "/home/op",
|
||||
AgentAccount: "agent", AgentHome: "/var/lib/agent", SealingKey: "age1x", ServingKey: "pub", Certificate: "cert",
|
||||
OutwardLinks: []string{"eth0"}, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}},
|
||||
NetworksSaid: true, Contract: 3, Taken: []string{"x"}, Adopted: true,
|
||||
Tunnel: &RecordTunnel{Interface: "wg0", PublicKey: "k", Peers: []RecordTunnelPeer{{PublicKey: "p"}}},
|
||||
HubAdopted: true, Carried: []RecordCarriedPeer{{PublicKey: "p", EnrolledAs: "phone"}}}
|
||||
raw, err := EncodeNodeRecord(hub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeNodeRecord(raw)
|
||||
if err != nil || !SameNodeRecord(back, hub) || back.V != Version {
|
||||
t.Fatalf("round trip: %+v, %v", back, err)
|
||||
}
|
||||
|
||||
for name, r := range map[string]NodeRecord{
|
||||
"carried off the hub": {Node: "ace", Carried: []RecordCarriedPeer{{PublicKey: "p"}}},
|
||||
"adopted off the hub": {Node: "ace", HubAdopted: true},
|
||||
"adopted with no tunnel": {Node: "novox", Place: NodePlace{Hub: true}, HubAdopted: true},
|
||||
"empty capability": {Node: "ace", Capabilities: []string{""}},
|
||||
"node that is not a name": {Node: "a.b"},
|
||||
} {
|
||||
if _, err := EncodeNodeRecord(r); err == nil {
|
||||
t.Errorf("%s: taken", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Every JSON field name of the record and the types it carries, walked: none reads as a secret.
|
||||
var walk func(reflect.Type, string)
|
||||
walk = func(ty reflect.Type, at string) {
|
||||
for ty.Kind() == reflect.Pointer || ty.Kind() == reflect.Slice {
|
||||
ty = ty.Elem()
|
||||
}
|
||||
if ty.Kind() != reflect.Struct {
|
||||
return
|
||||
}
|
||||
for i := 0; i < ty.NumField(); i++ {
|
||||
f := ty.Field(i)
|
||||
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
|
||||
if secretNamed.MatchString(name) || secretNamed.MatchString(f.Name) {
|
||||
t.Errorf("%s.%s (%q) is named like a secret", at, f.Name, name)
|
||||
}
|
||||
walk(f.Type, at+"."+f.Name)
|
||||
}
|
||||
}
|
||||
walk(reflect.TypeOf(NodeRecord{}), "NodeRecord")
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Placement of modules on nodes lives in the catalogue (novox/hq ADR 0549): one file per node, `placement/<node>.json`
|
||||
// in the catalogue repository, changed only by a pull request. At each trunk merge of the catalogue the indexer reads
|
||||
// the files and writes each node's key `placement.<node>` in its state `placement`, only when it differs, and says
|
||||
// `placement-changed` naming the node, the revision and the modules added, removed or changed (ADR 0549 §2). The node
|
||||
// agents, the assigner, the grants part and mesh-config read it (§3); nothing in it is secret.
|
||||
const (
|
||||
// PlacementState is the indexer's state of placements, and PlacementBucket its bucket.
|
||||
PlacementState = "placement"
|
||||
PlacementBucket = IndexerModule + "_" + PlacementState
|
||||
// PlacementHistory is the revisions the placement bucket keeps per key.
|
||||
PlacementHistory = 10
|
||||
// PlacementDir is the catalogue's directory of placement files, one `<node>.json` per node.
|
||||
PlacementDir = "placement"
|
||||
// PlacementChangedEvent is what the indexer says when a node's placement key changed.
|
||||
PlacementChangedEvent = "placement-changed"
|
||||
|
||||
placementWord = "placement"
|
||||
)
|
||||
|
||||
// PlacementKey is the key, in PlacementBucket, of one node's placement: `placement.<node>`.
|
||||
func PlacementKey(node string) (string, error) {
|
||||
if err := checkName("node", node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return placementWord + "." + node, nil
|
||||
}
|
||||
|
||||
// ParsePlacementKey is the inverse of PlacementKey; it refuses any other key, naming it.
|
||||
func ParsePlacementKey(key string) (node string, err error) {
|
||||
node, ok := strings.CutPrefix(key, placementWord+".")
|
||||
if !ok || checkName("node", node) != nil {
|
||||
return "", fmt.Errorf("delivery: %q is not a placement key, placement.<node>", key)
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// PlacementFile is the path, in the catalogue repository, of one node's placement file.
|
||||
func PlacementFile(node string) string { return path.Join(PlacementDir, node+".json") }
|
||||
|
||||
// PlacementFileNode is the node a catalogue path is the placement file of, and whether it is one: `placement/<node>.json`
|
||||
// directly under PlacementDir.
|
||||
func PlacementFileNode(p string) (string, bool) {
|
||||
dir, file := path.Split(p)
|
||||
node, ok := strings.CutSuffix(file, ".json")
|
||||
if path.Clean(dir) != PlacementDir || !ok || checkName("node", node) != nil {
|
||||
return "", false
|
||||
}
|
||||
return node, true
|
||||
}
|
||||
|
||||
// Provider is the module on a node that answers a requirement or holds a seat: a pin's or a binding's provider.
|
||||
type Provider struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
// Chosen is p as the composition names a chosen provider.
|
||||
func (p Provider) Chosen() catalogue.Chosen { return catalogue.Chosen{Node: p.Node, Module: p.Module} }
|
||||
|
||||
// PlacedModule is one module placed on the node: the host port each port it declares is published on (by the port the
|
||||
// software listens on), and, by provision, the provider each of its requirements that keeps data is bound to.
|
||||
type PlacedModule struct {
|
||||
Module string `json:"module"`
|
||||
Ports map[string]int `json:"ports,omitempty"`
|
||||
Bindings map[string]Provider `json:"bindings,omitempty"`
|
||||
}
|
||||
|
||||
// Holding is one seat a module placed on the node holds (catalogue.Held without its node, which is the file's).
|
||||
type Holding struct {
|
||||
Claim string `json:"claim"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Module string `json:"module"`
|
||||
Site string `json:"site,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
|
||||
// Placement is one node's placement: the file the catalogue keeps and, with Revision, the value of its key. Modules are
|
||||
// in the order the file names them, which is the order the composition reads the node's modules in. Pins is, by
|
||||
// provision, the provider chosen for the node where more than one could answer.
|
||||
type Placement struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Revision string `json:"revision,omitempty"`
|
||||
Modules []PlacedModule `json:"modules"`
|
||||
Pins map[string]Provider `json:"pins,omitempty"`
|
||||
Holds []Holding `json:"holds,omitempty"`
|
||||
}
|
||||
|
||||
func (p Placement) check() error {
|
||||
if err := checkName("node", p.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, m := range p.Modules {
|
||||
if err := checkName("module", m.Module); err != nil {
|
||||
return err
|
||||
}
|
||||
if seen[m.Module] {
|
||||
return fmt.Errorf("delivery: the placement of %s names %s twice", p.Node, m.Module)
|
||||
}
|
||||
seen[m.Module] = true
|
||||
for port, at := range m.Ports {
|
||||
if n, err := strconv.Atoi(port); err != nil || n <= 0 || n > 65535 {
|
||||
return fmt.Errorf("delivery: %s's port %q on %s is not a port number", m.Module, port, p.Node)
|
||||
}
|
||||
if at <= 0 || at > 65535 {
|
||||
return fmt.Errorf("delivery: %s's port %s on %s is published on %d, which is not a port number", m.Module, port, p.Node, at)
|
||||
}
|
||||
}
|
||||
for provision, to := range m.Bindings {
|
||||
if provision == "" || to.Node == "" || to.Module == "" {
|
||||
return fmt.Errorf("delivery: %s on %s has a binding with no provision, node or module", m.Module, p.Node)
|
||||
}
|
||||
}
|
||||
}
|
||||
for provision, to := range p.Pins {
|
||||
if provision == "" || to.Node == "" || to.Module == "" {
|
||||
return fmt.Errorf("delivery: the placement of %s has a pin with no provision, node or module", p.Node)
|
||||
}
|
||||
}
|
||||
for _, h := range p.Holds {
|
||||
if h.Claim == "" || !seen[h.Module] {
|
||||
return fmt.Errorf("delivery: the placement of %s has %s hold %q, which is not a module placed there or names no seat", p.Node, h.Module, h.Claim)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodePlacement is p as JSON at Version; it refuses what DecodePlacement refuses.
|
||||
func EncodePlacement(p Placement) ([]byte, error) {
|
||||
if err := p.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.V = Version
|
||||
return json.Marshal(p)
|
||||
}
|
||||
|
||||
// DecodePlacement reads a placement file or key value; it refuses an unknown version (ErrUnknownVersion), a node or
|
||||
// module that is not a name, a module named twice, a port that is not a number, a binding or pin naming no provider,
|
||||
// and a holding by a module not placed on the node.
|
||||
func DecodePlacement(data []byte) (Placement, error) {
|
||||
var p Placement
|
||||
if err := decode(data, &p); err != nil {
|
||||
return Placement{}, err
|
||||
}
|
||||
if err := p.check(); err != nil {
|
||||
return Placement{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// SamePlacement says whether two placements are the same value, so the indexer writes only when they differ. The
|
||||
// revision is not compared: a merge that changes no placement writes nothing (ADR 0549 §2).
|
||||
func SamePlacement(a, b Placement) bool {
|
||||
a.Revision, b.Revision = "", ""
|
||||
ja, errA := EncodePlacement(a)
|
||||
jb, errB := EncodePlacement(b)
|
||||
return errA == nil && errB == nil && string(ja) == string(jb)
|
||||
}
|
||||
|
||||
// PlacementChange is what `placement-changed` says: the node, the catalogue revision, and its modules added, removed
|
||||
// or changed against the placement before.
|
||||
type PlacementChange struct {
|
||||
Node string `json:"node"`
|
||||
Revision string `json:"revision"`
|
||||
Added []string `json:"added,omitempty"`
|
||||
Removed []string `json:"removed,omitempty"`
|
||||
Changed []string `json:"changed,omitempty"`
|
||||
}
|
||||
|
||||
// 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 Placement) PlacementChange {
|
||||
c := PlacementChange{Node: after.Node, Revision: after.Revision}
|
||||
if c.Node == "" {
|
||||
c.Node = before.Node
|
||||
}
|
||||
was := map[string]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
|
||||
}
|
||||
|
||||
func jsonEqual(a, b any) bool {
|
||||
ja, _ := json.Marshal(a)
|
||||
jb, _ := json.Marshal(b)
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
// Assigned is the modules placed on the node, in the file's order (compose.Node's Assigned).
|
||||
func (p Placement) Assigned() []string {
|
||||
out := make([]string, 0, len(p.Modules))
|
||||
for _, m := range p.Modules {
|
||||
out = append(out, m.Module)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ChosenPins is the node's pins as the composition reads them (compose.Node's Pins); nil when it has none.
|
||||
func (p Placement) ChosenPins() map[string]catalogue.Chosen {
|
||||
if len(p.Pins) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]catalogue.Chosen, len(p.Pins))
|
||||
for provision, to := range p.Pins {
|
||||
out[provision] = to.Chosen()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Bound is, by consumer module and provision, where each binding sends it (compose.Node's Bound); nil when none.
|
||||
func (p Placement) Bound() map[string]map[string]catalogue.Chosen {
|
||||
var out map[string]map[string]catalogue.Chosen
|
||||
for _, m := range p.Modules {
|
||||
if len(m.Bindings) == 0 {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]map[string]catalogue.Chosen{}
|
||||
}
|
||||
out[m.Module] = make(map[string]catalogue.Chosen, len(m.Bindings))
|
||||
for provision, to := range m.Bindings {
|
||||
out[m.Module][provision] = to.Chosen()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HostPorts is, by module and the port it declares, the host port it is published on (compose.Node's Ports); nil when
|
||||
// none. The check has refused a port that is not a number, so none is dropped here.
|
||||
func (p Placement) HostPorts() map[string]map[int]int {
|
||||
var out map[string]map[int]int
|
||||
for _, m := range p.Modules {
|
||||
if len(m.Ports) == 0 {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]map[int]int{}
|
||||
}
|
||||
out[m.Module] = make(map[int]int, len(m.Ports))
|
||||
for port, at := range m.Ports {
|
||||
n, _ := strconv.Atoi(port)
|
||||
out[m.Module][n] = at
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Held is the node's holdings as the composition reads them (compose.Input's Holdings rows for this node).
|
||||
func (p Placement) Held() []catalogue.Held {
|
||||
out := make([]catalogue.Held, 0, len(p.Holds))
|
||||
for _, h := range p.Holds {
|
||||
out = append(out, catalogue.Held{Claim: h.Claim, Scope: h.Scope, Node: p.Node, Module: h.Module, Site: h.Site, Kind: h.Kind})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CheckPlacements is the catalogue's own check of every placement file (ADR 0549 §1), over the placements by node, the
|
||||
// modules the catalogue holds and the nodes enrolled. It returns every refusal, in node order, never only the first: a
|
||||
// placement naming a node not enrolled or a module the catalogue does not hold; 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.
|
||||
// A host port a manifest declares without a number and a requirement left unbound are the composition's to find (its
|
||||
// ForeseenPorts and Unbound), which the catalogue's composition check reads.
|
||||
func CheckPlacements(placements map[string]Placement, held func(module string) bool, enrolled func(node string) bool) []string {
|
||||
var refusals []string
|
||||
nodes := make([]string, 0, len(placements))
|
||||
for n := range placements {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
placed := func(p 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{}
|
||||
for _, node := range nodes {
|
||||
p := placements[node]
|
||||
if p.Node != node {
|
||||
refusals = append(refusals, fmt.Sprintf("the placement file of %s names the node %q", node, p.Node))
|
||||
}
|
||||
if !enrolled(node) {
|
||||
refusals = append(refusals, fmt.Sprintf("%s is not an enrolled node", node))
|
||||
}
|
||||
hostPorts := map[int]string{}
|
||||
for _, m := range p.Modules {
|
||||
if !held(m.Module) {
|
||||
refusals = append(refusals, fmt.Sprintf("%s: the catalogue 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
|
||||
}
|
||||
seat := h.Claim + "/" + h.Kind
|
||||
if other, twice := meshSeats[seat]; 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[seat] = node
|
||||
}
|
||||
}
|
||||
return refusals
|
||||
}
|
||||
|
||||
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,152 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// ADR 0549 §1, §2: the placement key and file, the value's refusals, its reading as the composition reads a node, what
|
||||
// a change says, and the catalogue's check of every file.
|
||||
func TestPlacement(t *testing.T) {
|
||||
t.Run("key and file", func(t *testing.T) {
|
||||
key, err := PlacementKey("g14")
|
||||
if err != nil || key != "placement.g14" {
|
||||
t.Fatalf("PlacementKey = %q, %v", key, err)
|
||||
}
|
||||
if n, err := ParsePlacementKey(key); err != nil || n != "g14" {
|
||||
t.Fatalf("ParsePlacementKey = %q, %v", n, err)
|
||||
}
|
||||
for _, bad := range []string{"want.g14.x", "placement.", "placement.a.b"} {
|
||||
if _, err := ParsePlacementKey(bad); err == nil {
|
||||
t.Errorf("ParsePlacementKey(%q) took it", bad)
|
||||
}
|
||||
}
|
||||
if _, err := PlacementKey("a.b"); err == nil {
|
||||
t.Error("PlacementKey took a dotted node")
|
||||
}
|
||||
if PlacementBucket != "mesh-delivery-indexer_placement" || PlacementFile("ace") != "placement/ace.json" {
|
||||
t.Fatalf("bucket %q, file %q", PlacementBucket, PlacementFile("ace"))
|
||||
}
|
||||
for p, want := range map[string]string{"placement/ace.json": "ace", "./placement/ace.json": "ace",
|
||||
"placement/x/ace.json": "", "modules/ace.json": "", "placement/ace.yaml": ""} {
|
||||
if n, ok := PlacementFileNode(p); n != want || ok != (want != "") {
|
||||
t.Errorf("PlacementFileNode(%q) = %q, %v", p, n, ok)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
g14 := Placement{Node: "g14", Revision: "abc",
|
||||
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", 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)
|
||||
}
|
||||
empty := Placement{Node: "x"}
|
||||
if empty.ChosenPins() != nil || empty.Bound() != nil || empty.HostPorts() != nil || len(empty.Held()) != 0 {
|
||||
t.Error("an empty placement reads as something")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("what a change says", func(t *testing.T) {
|
||||
after := g14
|
||||
after.Revision = "def"
|
||||
after.Modules = []PlacedModule{{Module: "app", Ports: map[string]int{"8080": 18081}}, {Module: "new"}}
|
||||
got := ChangeOf(g14, after)
|
||||
want := PlacementChange{Node: "g14", Revision: "def", Added: []string{"new"}, Removed: []string{"zsh"}, Changed: []string{"app"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("ChangeOf = %+v, want %+v", got, want)
|
||||
}
|
||||
pinned := g14
|
||||
pinned.Pins = nil
|
||||
if got := ChangeOf(g14, pinned); !reflect.DeepEqual(got.Changed, []string{"app", "zsh"}) {
|
||||
t.Errorf("a pin change changes every module on the node: %+v", got)
|
||||
}
|
||||
if got := ChangeOf(Placement{}, novox); !reflect.DeepEqual(got.Added, []string{"postgres"}) || got.Node != "novox" {
|
||||
t.Errorf("a new node: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the catalogue's check", func(t *testing.T) {
|
||||
held := func(m string) bool { return m != "ghost" }
|
||||
enrolled := func(n string) bool { return n != "stranger" }
|
||||
if r := CheckPlacements(map[string]Placement{"g14": g14, "novox": novox}, held, enrolled); len(r) != 0 {
|
||||
t.Fatalf("a sound placement is refused: %v", r)
|
||||
}
|
||||
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"
|
||||
r := CheckPlacements(map[string]Placement{"g14": clash, "novox": novox, "stranger": twice}, held, enrolled)
|
||||
for _, want := range []string{"holds no module ghost", "ghost and app are both published on host port 18080",
|
||||
"bound to ace/postgres", "pin of shell names g14/bash", "stranger is not an enrolled node",
|
||||
"mesh-store, held once for the mesh, is held on novox and stranger"} {
|
||||
found := false
|
||||
for _, line := range r {
|
||||
found = found || strings.Contains(line, want)
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no refusal says %q; got %v", want, r)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user