Merge remote-tracking branch 'origin/main' into fix/delivery-records-true
This commit is contained in:
+43
-10
@@ -166,8 +166,9 @@ func checkFingerprint(fp, of string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Want is a node's want for a module: the fingerprint FP the node should run and the digest Composed of the one
|
||||
// composition of it the node may apply (ADR 0539 clause 1), or Removed for a module that left the node's modules (then
|
||||
// Want is a node's want for a module: the fingerprint FP the node should run and, before ADR 0547's second milestone,
|
||||
// the digest Composed of the one composition of it the node may apply (ADR 0539 clause 1; from that milestone the
|
||||
// node composes its own, ADR 0547 §2, and a want names none), or Removed for a module that left the node's modules (then
|
||||
// both are empty). When the module's newest fingerprint is not yet allowed on this node, Newest names it and Rule the
|
||||
// rule that holds the node back; both are empty when FP is the newest. Backup is the answer to a failed backup before
|
||||
// the apply, BackupAgain or BackupSkip (clause 16), empty when none was asked. Words say why in a sentence. The
|
||||
@@ -185,11 +186,18 @@ type Want struct {
|
||||
// is released. A put's want names no newest fingerprint or rule.
|
||||
Put bool `json:"put,omitempty"`
|
||||
Hold bool `json:"hold,omitempty"`
|
||||
// Yes is the digest of a composition a person allowed the node to move to (novox/hq ADR 0547 §3): a move of a module
|
||||
// of policy record that the node agent judged to recreate a container or move its data is applied only when its
|
||||
// want names the composition so allowed. Empty when none was asked.
|
||||
Yes string `json:"yes,omitempty"`
|
||||
// Self says the node composes this module itself (ADR 0547 §2): the want names the build alone, and no composition.
|
||||
// A want that is not Self names the one composition it allows (ADR 0539 clause 1), as before.
|
||||
Self bool `json:"self,omitempty"`
|
||||
}
|
||||
|
||||
func (w Want) check() error {
|
||||
if w.Removed {
|
||||
if w.FP != "" || w.Composed != "" || w.Newest != "" || w.Rule != 0 || w.Backup != "" || w.Put || w.Hold {
|
||||
if w.FP != "" || w.Composed != "" || w.Newest != "" || w.Rule != 0 || w.Backup != "" || w.Put || w.Hold || w.Self || w.Yes != "" {
|
||||
return fmt.Errorf("delivery: a removed want names no fingerprint, composition, rule, backup answer or put")
|
||||
}
|
||||
return nil
|
||||
@@ -203,11 +211,22 @@ func (w Want) check() error {
|
||||
if err := checkFingerprint(w.FP, "a want's fingerprint"); err != nil {
|
||||
return err
|
||||
}
|
||||
if w.Composed == "" {
|
||||
return fmt.Errorf("delivery: the want of %s names no composition: a want names the digest of the one composition it allows (ADR 0539 clause 1)", w.FP)
|
||||
// From ADR 0547's second milestone a node the catalogue places composes its own modules (§2): its want is Self and
|
||||
// names the build alone. Any other want names the one composition it allows (ADR 0539 clause 1).
|
||||
switch {
|
||||
case w.Self && w.Composed != "":
|
||||
return fmt.Errorf("delivery: the want of %s says the node composes it, and names a composition", w.FP)
|
||||
case !w.Self && w.Composed == "":
|
||||
return fmt.Errorf("delivery: the want of %s names no composition: a want names the digest of the one composition it allows (ADR 0539 clause 1), unless the node composes it itself", w.FP)
|
||||
case w.Composed != "":
|
||||
if err := checkDigest(w.Composed, "a want's composition digest"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := checkDigest(w.Composed, "a want's composition digest"); err != nil {
|
||||
return err
|
||||
if w.Yes != "" {
|
||||
if err := checkDigest(w.Yes, "the digest a want's yes allows"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
switch w.Backup {
|
||||
case "", BackupAgain, BackupSkip:
|
||||
@@ -309,11 +328,14 @@ type NodeWant struct {
|
||||
V int `json:"v"`
|
||||
Composed string `json:"composed"`
|
||||
Words string `json:"words,omitempty"`
|
||||
// Self says the node composes its own composition (ADR 0547 §2) and the want names none.
|
||||
Self bool `json:"self,omitempty"`
|
||||
}
|
||||
|
||||
// EncodeNodeWant is w as JSON at Version; it refuses a digest not of the sha256 form.
|
||||
// EncodeNodeWant is w as JSON at Version; it refuses a digest not of the sha256 form, and a want that names no
|
||||
// composition unless it says the node composes its own (Self, ADR 0547 §2).
|
||||
func EncodeNodeWant(w NodeWant) ([]byte, error) {
|
||||
if err := checkDigest(w.Composed, "the node's own want's composition digest"); err != nil {
|
||||
if err := w.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.V = Version
|
||||
@@ -332,12 +354,23 @@ func DecodeNodeWant(data []byte) (NodeWant, error) {
|
||||
if err := d.Decode(&w); err != nil {
|
||||
return NodeWant{}, fmt.Errorf("delivery: a node's own want does not fit its shape: %w", err)
|
||||
}
|
||||
if err := checkDigest(w.Composed, "the node's own want's composition digest"); err != nil {
|
||||
if err := w.check(); err != nil {
|
||||
return NodeWant{}, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// check refuses a node's own want that names no composition and does not say the node composes its own, or does both.
|
||||
func (w NodeWant) check() error {
|
||||
switch {
|
||||
case w.Self && w.Composed != "":
|
||||
return fmt.Errorf("delivery: the node's own want says the node composes it, and names a composition")
|
||||
case w.Self:
|
||||
return nil
|
||||
}
|
||||
return checkDigest(w.Composed, "the node's own want's composition digest")
|
||||
}
|
||||
|
||||
// EncodeHeld is h as JSON at Version; it refuses a node name a key refuses.
|
||||
func EncodeHeld(h Held) ([]byte, error) {
|
||||
if err := h.check(); err != nil {
|
||||
|
||||
+40
-4
@@ -48,7 +48,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -97,6 +99,10 @@ const (
|
||||
// composition is applied, and the node waits for the answer in its want (novox/hq ADR 0539 clause 16). Detail
|
||||
// carries the backup's own words.
|
||||
PhaseHeld Phase = "held"
|
||||
// PhaseWaitingYes is a module of policy record whose composed move the node agent judged to recreate a container or
|
||||
// move its data (novox/hq ADR 0547 §3): nothing of it is applied, Judgement says the judgement and Waits the digest
|
||||
// of the composition waiting, and the assigner asks a person; the node applies it once its want's Yes names Waits.
|
||||
PhaseWaitingYes Phase = "waiting-yes"
|
||||
)
|
||||
|
||||
// Health is what the module's own check says.
|
||||
@@ -213,12 +219,15 @@ func parseModuleKeyPart(part string) (string, error) {
|
||||
return module, nil
|
||||
}
|
||||
|
||||
// HaveNodeWord is the last token of a node's own facts' key in HaveBucket.
|
||||
const HaveNodeWord = nodeWord
|
||||
|
||||
// HaveNodeKey is the key, in HaveBucket, of a node's own facts: `<node>.node`.
|
||||
func HaveNodeKey(node string) (string, error) {
|
||||
if err := checkName("node", node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return node + ".node", nil
|
||||
return node + "." + HaveNodeWord, nil
|
||||
}
|
||||
|
||||
// ParseHaveKey is the inverse of HaveKey and HaveNodeKey: isNode is true for `<node>.node`, with module empty.
|
||||
@@ -337,6 +346,15 @@ type Have struct {
|
||||
// UserList is what the node-engine of the node holding mesh-broker wrote of the bus's user list, in the have of the
|
||||
// bus module (novox/hq ADR 0547 §7, §8). Nil on every other have.
|
||||
UserList *UserListWritten `json:"user-list,omitempty"`
|
||||
// Judgement and Waits are, in phase waiting-yes, the node agent's judgement of the move and the digest of the
|
||||
// composition that waits for a person's yes (ADR 0547 §3); empty otherwise.
|
||||
Judgement Judgement `json:"judgement,omitempty"`
|
||||
Waits string `json:"waits,omitempty"`
|
||||
// Sealed is, for a module the node composes itself, each value its running composition carries sealed to the node's
|
||||
// key (a secret, a credential, a vault box), as applied (ADR 0547 §3, issue 1126): what the node's composer reuses
|
||||
// after a restart while it opens to the vault's value, so a value sealed again moves no composition. Openable by the
|
||||
// node alone, as the vault hands them over the bus. Empty on every other have.
|
||||
Sealed []string `json:"sealed,omitempty"`
|
||||
}
|
||||
|
||||
// UserListWritten is the bus's user list as the node-engine of the bus node wrote it to the bus module's file, applied
|
||||
@@ -381,6 +399,9 @@ type HaveNode struct {
|
||||
Strays json.RawMessage `json:"strays,omitempty"`
|
||||
Filters json.RawMessage `json:"filters,omitempty"`
|
||||
Profile json.RawMessage `json:"profile,omitempty"`
|
||||
// Record is what the machine observes of itself, typed, for every node and composing part to read (issue 1126,
|
||||
// ADR 0555 §3): the node's record lives in its own have key, so it needs no bucket, writer or grant of its own.
|
||||
Record *NodeRecord `json:"record,omitempty"`
|
||||
}
|
||||
|
||||
// Presence is whether a node or a part's instance is online; the entry's own time says since when. The watchman
|
||||
@@ -429,8 +450,18 @@ func decode(data []byte, into any) error {
|
||||
func (h Have) check() error {
|
||||
switch h.Phase {
|
||||
case PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed, PhaseRemoved, PhaseHeld:
|
||||
if h.Judgement != "" || h.Waits != "" {
|
||||
return fmt.Errorf("delivery: only a have waiting for a yes names a judgement and the composition waiting")
|
||||
}
|
||||
case PhaseWaitingYes:
|
||||
if h.Judgement != JudgedRecreates && h.Judgement != JudgedMovesData && h.Judgement != JudgedCannot {
|
||||
return fmt.Errorf("delivery: a have waiting for a yes names the judgement %q, not recreates, moves-data or cannot-judge", h.Judgement)
|
||||
}
|
||||
if err := checkDigest(h.Waits, "the composition a have waits a yes for"); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running, failed, removed or held", h.Phase)
|
||||
return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running, failed, removed, held or waiting-yes", h.Phase)
|
||||
}
|
||||
if h.Composed != "" {
|
||||
if err := checkDigest(h.Composed, "the have's composition digest"); err != nil {
|
||||
@@ -509,7 +540,11 @@ func DecodePulse(data []byte) (Pulse, error) {
|
||||
func SameHave(a, b Have) bool {
|
||||
ua, ub := a.UserList, b.UserList
|
||||
a.UserList, b.UserList = nil, nil
|
||||
if a != b || (ua == nil) != (ub == nil) {
|
||||
if !slices.Equal(a.Sealed, b.Sealed) {
|
||||
return false
|
||||
}
|
||||
a.Sealed, b.Sealed = nil, nil
|
||||
if !reflect.DeepEqual(a, b) || (ua == nil) != (ub == nil) {
|
||||
return false
|
||||
}
|
||||
if ua == nil {
|
||||
@@ -524,7 +559,8 @@ func SameHave(a, b Have) bool {
|
||||
// SameHaveNode says whether two node facts are the same value, comparing each carried JSON without its spacing.
|
||||
func SameHaveNode(a, b HaveNode) bool {
|
||||
return a.V == b.V && a.Composed == b.Composed && sameJSON(a.Windows, b.Windows) && sameJSON(a.Busy, b.Busy) &&
|
||||
sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile)
|
||||
sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile) &&
|
||||
(a.Record == nil) == (b.Record == nil) && (a.Record == nil || SameNodeRecord(*a.Record, *b.Record))
|
||||
}
|
||||
|
||||
// SamePresence says whether two presences are the same value.
|
||||
|
||||
@@ -14,6 +14,9 @@ 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)
|
||||
t.Run("pulse ttl", testPulseTTL)
|
||||
if HaveBucket != "node-engine_have" {
|
||||
t.Fatalf("HaveBucket = %q", HaveBucket)
|
||||
}
|
||||
@@ -58,7 +61,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 +101,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)
|
||||
}
|
||||
@@ -124,6 +127,7 @@ func TestPulseKey(t *testing.T) {
|
||||
|
||||
// 1131/AC4: each value encodes, decodes equal, and carries "v":1.
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
t.Run("phase and health refused", testPhaseHealthRefused)
|
||||
hasV1 := func(t *testing.T, b []byte) {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
@@ -149,7 +153,7 @@ func TestRoundTrip(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.V = Version
|
||||
if out != in {
|
||||
if !SameHave(out, in) {
|
||||
t.Fatalf("got %+v want %+v", out, in)
|
||||
}
|
||||
})
|
||||
@@ -340,7 +344,7 @@ func TestUnknownVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1131/AC6: a phase or health outside the lists is refused on encode and decode.
|
||||
func TestPhaseHealthRefused(t *testing.T) {
|
||||
func testPhaseHealthRefused(t *testing.T) {
|
||||
for _, p := range []Phase{PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed} {
|
||||
for _, h := range []Health{HealthOK, HealthFailed, HealthWaits, HealthUnknown} {
|
||||
if _, err := EncodeHave(Have{Phase: p, Health: h}); err != nil {
|
||||
@@ -363,10 +367,27 @@ func TestPhaseHealthRefused(t *testing.T) {
|
||||
if _, err := DecodeHave([]byte(`{"v":1,"phase":"running","health":"fine"}`)); err == nil {
|
||||
t.Error("bad health decoded")
|
||||
}
|
||||
// ADR 0547 §3 (issue 1126): a have waits for a yes with its judgement and the composition waiting, and no other have
|
||||
// names either.
|
||||
waits := Have{Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedRecreates, Waits: "sha256:" + strings.Repeat("a", 64)}
|
||||
if raw, err := EncodeHave(waits); err != nil {
|
||||
t.Errorf("a have waiting for a yes: %v", err)
|
||||
} else if back, err := DecodeHave(raw); err != nil || back.Judgement != JudgedRecreates || back.Waits != waits.Waits {
|
||||
t.Errorf("a have waiting for a yes came back %+v, %v", back, err)
|
||||
}
|
||||
for name, h := range map[string]Have{
|
||||
"neither judged": {Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedNeither, Waits: waits.Waits},
|
||||
"no waiting digest": {Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedMovesData},
|
||||
"running judged": {Phase: PhaseRunning, Health: HealthOK, Judgement: JudgedRecreates},
|
||||
} {
|
||||
if _, err := EncodeHave(h); err == nil {
|
||||
t.Errorf("%s: encoded", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1131/AC7: the pulse's time to live is 120 seconds minus the move's age; at the bound the move is stalled.
|
||||
func TestPulseTTL(t *testing.T) {
|
||||
func testPulseTTL(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
age time.Duration
|
||||
ttl time.Duration
|
||||
@@ -390,6 +411,7 @@ func TestPulseTTL(t *testing.T) {
|
||||
|
||||
// 1131/AC8: equal values say same, different ones do not.
|
||||
func TestSame(t *testing.T) {
|
||||
t.Run("have removed", testHaveRemoved)
|
||||
a := Have{V: 1, Build: "b", Phase: PhaseRunning, Health: HealthOK}
|
||||
b := a
|
||||
if !SameHave(a, b) {
|
||||
@@ -399,6 +421,17 @@ func TestSame(t *testing.T) {
|
||||
if SameHave(a, b) {
|
||||
t.Error("different haves same")
|
||||
}
|
||||
// The sealed values a self-composing node runs are part of its have (issue 1126).
|
||||
sa, sb := a, a
|
||||
sa.Sealed, sb.Sealed = []string{`{"box":1}`}, []string{`{"box":1}`}
|
||||
if !SameHave(sa, sb) || SameHave(sa, a) {
|
||||
t.Error("equal sealed values differ, or one said and one not are the same")
|
||||
}
|
||||
if raw, err := EncodeHave(sa); err != nil {
|
||||
t.Error(err)
|
||||
} else if back, err := DecodeHave(raw); err != nil || !SameHave(back, sa) {
|
||||
t.Errorf("sealed values do not travel: %+v, %v", back, err)
|
||||
}
|
||||
// The user list is compared by what it says, never by where it is held (ADR 0547 §8).
|
||||
at := time.Date(2026, 10, 25, 21, 0, 0, 0, time.UTC)
|
||||
ua, ub := a, a
|
||||
@@ -443,7 +476,7 @@ func TestSame(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHaveRemoved(t *testing.T) { // 1370/AC3
|
||||
func testHaveRemoved(t *testing.T) { // 1370/AC3
|
||||
in := Have{Build: "b1", Phase: PhaseRemoved, Health: HealthUnknown, Detail: "the declaration no longer holds it"}
|
||||
b, err := EncodeHave(in)
|
||||
if err != nil {
|
||||
@@ -454,7 +487,7 @@ func TestHaveRemoved(t *testing.T) { // 1370/AC3
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.V = Version
|
||||
if out != in || out.Phase != "removed" {
|
||||
if !SameHave(out, in) || out.Phase != "removed" {
|
||||
t.Errorf("round trip: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@ const oddManifest = "{\n\t\"module\": \"a\",\n \"z\": 1, \"a\": [ \"x\",\"y\"
|
||||
|
||||
func mergedWithInputs() Forge {
|
||||
return Forge{Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa", Merge: "cccc", Root: "t0",
|
||||
Trees: map[string]string{"a": "t1", "b": "t2"},
|
||||
Manifests: map[string]string{"a": oddManifest},
|
||||
Locks: map[string]map[string]string{"a": {"go.sum": strings.Repeat("ab", 32)}}}
|
||||
Trees: map[string]string{"a": "t1", "b": "t2"},
|
||||
Manifests: map[string]string{"a": oddManifest},
|
||||
Locks: map[string]map[string]string{"a": {"go.sum": strings.Repeat("ab", 32)}},
|
||||
Placements: map[string]string{"ace": `{"v":1,"node":"ace","modules":[]}`}, PlacementsRead: true}
|
||||
}
|
||||
|
||||
// 1442/AC1
|
||||
@@ -21,7 +22,7 @@ func TestForgeMergedCarriesManifestsAndLocks(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, frag := range []string{`"manifests":{"a":`, `"locks":{"a":{"go.sum":`} {
|
||||
for _, frag := range []string{`"manifests":{"a":`, `"locks":{"a":{"go.sum":`, `"placements":{"ace":`, `"placements-read":true`} {
|
||||
if !strings.Contains(string(data), frag) {
|
||||
t.Errorf("the encoding lacks %s: %s", frag, data)
|
||||
}
|
||||
@@ -47,6 +48,9 @@ func TestForgeMergedRefusesBadInputs(t *testing.T) {
|
||||
"locks outside trees": {func(f *Forge) { f.Locks["yy"] = map[string]string{"go.sum": "h"} }, `"yy"`},
|
||||
"empty manifest": {func(f *Forge) { f.Manifests["b"] = "" }, `"b"`},
|
||||
"empty lock hash": {func(f *Forge) { f.Locks["a"]["go.sum"] = "" }, `"a"`},
|
||||
"placements unsaid": {func(f *Forge) { f.PlacementsRead = false }, "placements-read"},
|
||||
"placement of no node": {func(f *Forge) { f.Placements["a.b"] = "{}" }, "names no node"},
|
||||
"placements on a pull": {func(f *Forge) { f.Kind, f.Action, f.Merge, f.Root, f.Trees = ForgePull, PullOpened, "", "", nil }, "only a merged message"},
|
||||
} {
|
||||
f := mergedWithInputs()
|
||||
tc.mut(&f)
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
// The judgement of a move (ADR 0377 §3), as a pure function over two compositions of one module, so the node agent that
|
||||
// composes its own modules judges what it would apply (novox/hq ADR 0547 §3; issue 1126's S8). Moved from
|
||||
// mesh-controller cmd/mesh-controller/composed_judge.go at origin/main (the controller keeps its copy, unchanged, until
|
||||
// it loses the lease, ADR 0547 §1): judged, onlyTheUserList and sameResources, with their helpers, are its code.
|
||||
//
|
||||
// ADR 0377 §3 over two compositions of one module: **recreates** when a container resource is changed, added or
|
||||
// removed; **moves data** when, with no container changed, a directory or file resource is changed, added or removed
|
||||
// that a container of the module mounts or that holds or lies within one of its directories (where a module keeps its
|
||||
// data), when its `data` declaration changes, or, for a module that declares data, when one of its long-running
|
||||
// processes changes; **neither** otherwise, and for a module the node has never run.
|
||||
|
||||
// Judged is one move's judgement: what it does, its words, and whether only the bus's user list changes (ADR 0539 §7:
|
||||
// a file change and a reload of the bus, no move of it, never asked).
|
||||
type Judged struct {
|
||||
Judgement Judgement
|
||||
Words string
|
||||
UserListOnly bool
|
||||
}
|
||||
|
||||
// JudgeMove judges moving a node's module from the composition it runs to the one composed for it. Running is nil when the
|
||||
// node runs no composition of the module. A composition equal to the one running (resource for resource, each
|
||||
// canonical) is unchanged: no move, and no ask (ADR 0547 §3).
|
||||
func JudgeMove(running *Composed, composed Composed) Judged {
|
||||
if composed.Module == "" {
|
||||
return Judged{Judgement: JudgedNeither, Words: "the node's own composition"}
|
||||
}
|
||||
if running == nil {
|
||||
return Judged{Judgement: JudgedNeither, Words: "the node runs no composition of " + composed.Module}
|
||||
}
|
||||
if running.Data == composed.Data && SameComposedResources(*running, composed) {
|
||||
return Judged{Judgement: JudgedUnchanged, Words: "equal to the composition the node runs"}
|
||||
}
|
||||
if onlyTheUserList(*running, composed) {
|
||||
return Judged{Judgement: JudgedNeither, Words: "only the bus's user list changes", UserListOnly: true}
|
||||
}
|
||||
j, words := judged(*running, composed)
|
||||
return Judged{Judgement: j, Words: words}
|
||||
}
|
||||
|
||||
// busUsersSuffix ends the id of the bus module's user list as the composer writes it: `<module>.bus-users`.
|
||||
const busUsersSuffix = ".bus-users"
|
||||
|
||||
// onlyTheUserList says two compositions of a module differ, and only in its file of the bus's user list.
|
||||
func onlyTheUserList(from, to Composed) bool {
|
||||
if from.Data != to.Data {
|
||||
return false
|
||||
}
|
||||
before, after := judgedResourcesByID(from), judgedResourcesByID(to)
|
||||
if len(before) != len(after) {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for id, now := range after {
|
||||
was, ok := before[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if bytes.Equal(was.raw, now.raw) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(id, busUsersSuffix) || now.kind != string(declaration.TypeFile) {
|
||||
return false
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// judged applies ADR 0377 §3 to two compositions of one module.
|
||||
func judged(from, to Composed) (Judgement, string) {
|
||||
before, after := judgedResourcesByID(from), judgedResourcesByID(to)
|
||||
if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 {
|
||||
return JudgedRecreates, "it recreates " + strings.Join(ids, ", ")
|
||||
}
|
||||
if from.Data != to.Data {
|
||||
return JudgedMovesData, "its data declaration changes"
|
||||
}
|
||||
places := dataPlaces(before, after)
|
||||
held := func(kind string) bool {
|
||||
return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile)
|
||||
}
|
||||
var moved []string
|
||||
for _, id := range changedOf(before, after, held) {
|
||||
r := before[id]
|
||||
if r == nil {
|
||||
r = after[id]
|
||||
}
|
||||
if withinPlaces(r.path, places) {
|
||||
moved = append(moved, id)
|
||||
}
|
||||
}
|
||||
if to.Data || from.Data {
|
||||
moved = append(moved, changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeProcess) })...)
|
||||
}
|
||||
if len(moved) > 0 {
|
||||
sort.Strings(moved)
|
||||
return JudgedMovesData, "it changes where its data is kept: " + strings.Join(moved, ", ")
|
||||
}
|
||||
return JudgedNeither, "it recreates no container and moves no data"
|
||||
}
|
||||
|
||||
// judgedResource is a resource as the judgement reads it: its kind, the path it acts on, what its containers mount,
|
||||
// and its canonical bytes.
|
||||
type judgedResource struct {
|
||||
kind string
|
||||
path string
|
||||
mounts []string
|
||||
raw []byte
|
||||
}
|
||||
|
||||
func judgedResourcesByID(c Composed) map[string]*judgedResource {
|
||||
out := map[string]*judgedResource{}
|
||||
for _, raw := range c.Resources {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
// A container's volumes, `<path on the machine>:<path inside>[:<options>]`.
|
||||
Volumes []string `json:"volumes"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
continue
|
||||
}
|
||||
r := &judgedResource{kind: head.Type, path: head.Path, raw: compactResource(raw)}
|
||||
for _, v := range head.Volumes {
|
||||
source, _, _ := strings.Cut(v, ":")
|
||||
r.mounts = append(r.mounts, source)
|
||||
}
|
||||
out[head.ID] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compactResource is a resource's bytes canonical: its keys sorted, its spacing dropped, its numbers as written, so two
|
||||
// compositions that say the same compare equal however each was written.
|
||||
func compactResource(raw []byte) []byte {
|
||||
var v any
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.UseNumber()
|
||||
if err := d.Decode(&v); err != nil {
|
||||
return raw
|
||||
}
|
||||
out, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// changedOf are the ids of the resources of the kinds counted that are changed, added or removed, sorted.
|
||||
func changedOf(before, after map[string]*judgedResource, counted func(kind string) bool) []string {
|
||||
var out []string
|
||||
for id, now := range after {
|
||||
if !counted(now.kind) {
|
||||
continue
|
||||
}
|
||||
if was, ok := before[id]; !ok || !bytes.Equal(was.raw, now.raw) {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
for id, was := range before {
|
||||
if _, ok := after[id]; !ok && counted(was.kind) {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// dataPlaces are the paths where the module keeps what a move must not lose: what its containers mount, and its
|
||||
// directories, before and after.
|
||||
func dataPlaces(before, after map[string]*judgedResource) []string {
|
||||
var out []string
|
||||
for _, rs := range []map[string]*judgedResource{before, after} {
|
||||
for _, r := range rs {
|
||||
if r.kind == string(declaration.TypeContainer) {
|
||||
out = append(out, r.mounts...)
|
||||
}
|
||||
if r.kind == string(declaration.TypeDirectory) && r.path != "" {
|
||||
out = append(out, r.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withinPlaces says p is one of places, or lies within one, or holds one.
|
||||
func withinPlaces(p string, places []string) bool {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
p = path.Clean(p)
|
||||
for _, place := range places {
|
||||
if place == "" || !strings.HasPrefix(place, "/") {
|
||||
continue
|
||||
}
|
||||
place = path.Clean(place)
|
||||
if p == place || strings.HasPrefix(p, place+"/") || strings.HasPrefix(place, p+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SameComposedResources says two compositions of a module hold the same resources in the same order, each compared
|
||||
// canonical.
|
||||
func SameComposedResources(a, b Composed) bool {
|
||||
if len(a.Resources) != len(b.Resources) {
|
||||
return false
|
||||
}
|
||||
for i := range a.Resources {
|
||||
if !bytes.Equal(compactResource(a.Resources[i]), compactResource(b.Resources[i])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func composedOf(module string, c byte, data bool, resources ...string) Composed {
|
||||
out := Composed{Node: "ace", Module: module, FP: SourcePrefix2 + strings.Repeat(string(c), 64), Data: data}
|
||||
for _, r := range resources {
|
||||
out.Resources = append(out.Resources, json.RawMessage(r))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The pairs mesh-controller's composed_judge_test.go captured (issue 1945 AC12), and the bus's user list, judged as the
|
||||
// controller judges them (ADR 0377 §3, ADR 0539 §7), by the node agent's function (ADR 0547 §3, issue 1126 S8).
|
||||
func TestJudgeMove(t *testing.T) {
|
||||
const (
|
||||
container = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:1111111111111111111111111111111111111111111111111111111111111111","volumes":["/var/lib/mesh-mail/spool:/spool"]}`
|
||||
container2 = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:2222222222222222222222222222222222222222222222222222222222222222","volumes":["/var/lib/mesh-mail/spool:/spool"]}`
|
||||
spool = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}`
|
||||
spoolMoved = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/srv/mesh-mail/spool"}`
|
||||
tools = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"a"}`
|
||||
tools2 = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"b"}`
|
||||
config = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"a"}`
|
||||
config2 = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"b"}`
|
||||
process = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/a"}`
|
||||
process2 = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/b"}`
|
||||
users = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"a"}`
|
||||
users2 = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"b"}`
|
||||
natsConf = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"a"}`
|
||||
natsConf2 = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"b"}`
|
||||
)
|
||||
mail := func(c byte, data bool, rs ...string) *Composed {
|
||||
m := composedOf("mesh-mail", c, data, rs...)
|
||||
return &m
|
||||
}
|
||||
bus := func(rs ...string) *Composed { m := composedOf("nats", 'b', false, rs...); return &m }
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
from, to *Composed
|
||||
want Judgement
|
||||
userList bool
|
||||
}{
|
||||
{"a new image", mail('a', true, container, spool, tools), mail('b', true, container2, spool, tools), JudgedRecreates, false},
|
||||
{"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false},
|
||||
{"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false},
|
||||
{"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false},
|
||||
{"its data declaration", mail('a', false, container, spool), mail('b', true, container, spool), JudgedMovesData, false},
|
||||
{"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false},
|
||||
{"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false},
|
||||
{"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false},
|
||||
{"never ran", nil, mail('b', true, container), JudgedNeither, false},
|
||||
{"equal, written otherwise", mail('a', true, `{"type":"directory","id":"mesh-mail.spool","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}`), mail('b', true, spool), JudgedUnchanged, false},
|
||||
{"only the user list", bus(natsConf, users), bus(natsConf, users2), JudgedNeither, true},
|
||||
{"the user list and more", bus(natsConf, users), bus(natsConf2, users2), JudgedNeither, false},
|
||||
} {
|
||||
got := JudgeMove(c.from, *c.to)
|
||||
if got.Judgement != c.want || got.UserListOnly != c.userList || got.Words == "" {
|
||||
t.Errorf("%s: %+v, want %s (user list only %v)", c.name, got, c.want, c.userList)
|
||||
}
|
||||
}
|
||||
if got := JudgeMove(nil, Composed{Node: "ace"}); got.Judgement != JudgedNeither {
|
||||
t.Errorf("the node's own composition: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// A node's record is what the machine observes of itself (issue 1126, ADR 0555 §3), written by its own agent in its
|
||||
// own have key, `<node>.node` of the node-engine's have bucket (HaveNode.Record), whenever an observed fact changes and
|
||||
// only when the value differs. The key's change is the event its readers hear: every node and the parts that compose
|
||||
// watch it. Where a module runs, and every fact a person decides of the node (its endpoint, site, hub, address on the
|
||||
// private network, public domain, accounts, the tunnel found at adoption), are the catalogue's placement
|
||||
// (placement.go). Nothing in it is secret: its keys are public halves.
|
||||
|
||||
// RecordTunnel is a tunnel found on a node when it was adopted, as compose.Tunnel holds it: public keys and addresses
|
||||
// only. After the node took it over the tunnel is no longer there to observe, so it is kept as what was decided at
|
||||
// adoption, in the node's placement (NodeDecided.Tunnel).
|
||||
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"`
|
||||
}
|
||||
|
||||
// NodeRecord is one node's record: what the machine observes of itself (issue 1126, ADR 0555: what a person decides
|
||||
// lives in the catalogue's placement file, Placement.Decided; what the machine observes lives here). TunnelKey is the
|
||||
// public key of its private-network tunnel; Certificate the one the mesh issued for its internal name, public, as the
|
||||
// node holds it. The tunnel found at adoption and the names given to its peers are decisions kept in the placement;
|
||||
// whether the hub took its found tunnel over, and which node enrolled under a carried peer's key, are read from both.
|
||||
type NodeRecord struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
TunnelKey string `json:"tunnel-key,omitempty"`
|
||||
Capabilities []string `json:"capabilities,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"`
|
||||
// Authority is the mesh's own certificate as this machine holds it (public): the one its declaration last wrote where
|
||||
// a module asked for it. A node composing its own composes a certificate with it (compose.Input.Authority).
|
||||
Authority string `json:"authority,omitempty"`
|
||||
}
|
||||
|
||||
func (r NodeRecord) check() error {
|
||||
if err := checkName("node", r.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
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, 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,73 @@
|
||||
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 (ADR 0555): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
|
||||
// neither it nor the placement file names a field like a secret.
|
||||
func TestNodeRecord(t *testing.T) {
|
||||
hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub",
|
||||
Certificate: "cert", OutwardLinks: []string{"eth0"}, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}},
|
||||
NetworksSaid: true, Contract: 3}
|
||||
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)
|
||||
}
|
||||
// It rides in the node's own have key: a have that differs only in its record is another value.
|
||||
with, without := HaveNode{V: Version, Record: &hub}, HaveNode{V: Version}
|
||||
if raw, err := EncodeHaveNode(with); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if got, err := DecodeHaveNode(raw); err != nil || got.Record == nil || !SameHaveNode(got, with) || SameHaveNode(got, without) {
|
||||
t.Fatalf("the have's record: %+v, %v", got, err)
|
||||
}
|
||||
changed := hub
|
||||
changed.Contract = 4
|
||||
if SameHaveNode(with, HaveNode{V: Version, Record: &changed}) {
|
||||
t.Error("a changed record reads as the same have")
|
||||
}
|
||||
|
||||
for name, r := range map[string]NodeRecord{
|
||||
"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")
|
||||
// And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential.
|
||||
walk(reflect.TypeOf(Placement{}), "Placement")
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
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"
|
||||
|
||||
// CatalogueRepository is the repository whose placement files are the mesh's placement (ADR 0549 §1): the forge bridge
|
||||
// reads them at each of its merges, and no other repository's.
|
||||
CatalogueRepository = "novox/mesh-catalog"
|
||||
)
|
||||
|
||||
// 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.
|
||||
//
|
||||
// MeshWide and Layer are where the module's things are, as a settings layer's placement holds them (catalogue.Layer's
|
||||
// Placement: `places`, `accesses`, `networks`, `endpoints`, `expose`, `reach`, `ports`; ADR 0348 §11): the whole mesh's
|
||||
// layer and this node's, which the controller keeps in its table `placement` (ADR 0549 §6 names it) and composes as two
|
||||
// layers, the mesh's first. Each node's file repeats the mesh-wide layer of the modules placed on it, so a node reads
|
||||
// its own key alone. HasLayer says the node has a layer of its own even when it holds no placement, since a layer
|
||||
// that is there and empty still carries the module's config values (Layers).
|
||||
//
|
||||
// MeshValues and Values are the config values of a module in the settings form (its manifest declares no config, so
|
||||
// mesh-config holds none for it): what a person set in the whole mesh's layer and this node's, beside the placement
|
||||
// (issue 1126; on 2026-10-26 the media modules on one node, plex's puid and pgid among them). A module in the config
|
||||
// form takes its values from mesh-config and has none here.
|
||||
type PlacedModule struct {
|
||||
Module string `json:"module"`
|
||||
Ports map[string]int `json:"ports,omitempty"`
|
||||
Bindings map[string]Provider `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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Decided NodeDecided `json:"decided"`
|
||||
Modules []PlacedModule `json:"modules"`
|
||||
Pins map[string]Provider `json:"pins,omitempty"`
|
||||
Holds []Holding `json:"holds,omitempty"`
|
||||
}
|
||||
|
||||
// NodeDecided is what a person decides of the node, kept in its placement file (issue 1126's K2: what a person decides
|
||||
// lives in the catalogue, what the machine observes lives in its record, NodeRecord): where it is dialled (endpoint) and
|
||||
// its site, whether it is the private network's hub, its address on the private network, the domain its routed names
|
||||
// are composed under, its operator and agent logins by name and their homes, whether it is adopted rather than
|
||||
// converged, the modules taken on an adopted node, the tunnel found on it at adoption and, on the hub, the names given
|
||||
// to that tunnel's peers. The address is a decision written once, at enrolment, by a
|
||||
// catalogue pull request: nothing allocates it at run time, and CheckPlacements refuses two nodes with one address.
|
||||
// No secret and no key material: accounts are names, never credentials.
|
||||
type NodeDecided 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 is the tunnel found on the node when it was adopted, kept as decided then: once the node takes it over it is
|
||||
// no longer there to observe. PeerNames is, on the hub, the name a person gave a peer of that tunnel, by its public key.
|
||||
Tunnel *RecordTunnel `json:"tunnel,omitempty"`
|
||||
PeerNames map[string]string `json:"peer-names,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
|
||||
}
|
||||
|
||||
// Layers is one module's settings layers on the node, as the composition reads them (compose.Node's Settings): the
|
||||
// whole mesh's layer when it has one, then the node's, which carries the module's config values as mesh-config resolved
|
||||
// them for this node (values, nil when none) beside its placement; a module with no layer and no value has none. This is
|
||||
// the controller's SettingsFor for a module that takes its config values from mesh-config (issue 664). A module in the
|
||||
// settings form carries its values in the file (MeshValues, Values), which then stand for mesh-config's.
|
||||
func (m PlacedModule) Layers(node string, values map[string]any) []catalogue.Layer {
|
||||
var out []catalogue.Layer
|
||||
if m.HasMesh || len(m.MeshWide) > 0 || len(m.MeshValues) > 0 {
|
||||
mesh := map[string]any{}
|
||||
for k, v := range m.MeshValues {
|
||||
mesh[k] = v
|
||||
}
|
||||
out = append(out, catalogue.Layer{From: catalogue.MeshWideLayer, Values: mesh, Placement: m.MeshWide})
|
||||
}
|
||||
if len(m.Values) > 0 {
|
||||
values = make(map[string]any, len(m.Values))
|
||||
for k, v := range m.Values {
|
||||
values[k] = v
|
||||
}
|
||||
}
|
||||
if m.HasLayer || len(m.Layer) > 0 || len(values) > 0 {
|
||||
if values == nil {
|
||||
values = map[string]any{}
|
||||
}
|
||||
out = append(out, catalogue.Layer{From: node, Values: values, Placement: m.Layer})
|
||||
}
|
||||
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;
|
||||
// two nodes with one address on the private network; more than one hub.
|
||||
// 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{}
|
||||
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 file 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 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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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,172 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// ADR 0549 §1, §2: the placement key and file, the value's refusals, its reading as the composition reads a node, what
|
||||
// a change says, and the catalogue's check of every file.
|
||||
func TestPlacement(t *testing.T) {
|
||||
t.Run("key and file", func(t *testing.T) {
|
||||
key, err := PlacementKey("g14")
|
||||
if err != nil || key != "placement.g14" {
|
||||
t.Fatalf("PlacementKey = %q, %v", key, err)
|
||||
}
|
||||
if n, err := ParsePlacementKey(key); err != nil || n != "g14" {
|
||||
t.Fatalf("ParsePlacementKey = %q, %v", n, err)
|
||||
}
|
||||
for _, bad := range []string{"want.g14.x", "placement.", "placement.a.b"} {
|
||||
if _, err := ParsePlacementKey(bad); err == nil {
|
||||
t.Errorf("ParsePlacementKey(%q) took it", bad)
|
||||
}
|
||||
}
|
||||
if _, err := PlacementKey("a.b"); err == nil {
|
||||
t.Error("PlacementKey took a dotted node")
|
||||
}
|
||||
if PlacementBucket != "mesh-delivery-indexer_placement" || PlacementFile("ace") != "placement/ace.json" {
|
||||
t.Fatalf("bucket %q, file %q", PlacementBucket, PlacementFile("ace"))
|
||||
}
|
||||
for p, want := range map[string]string{"placement/ace.json": "ace", "./placement/ace.json": "ace",
|
||||
"placement/x/ace.json": "", "modules/ace.json": "", "placement/ace.yaml": ""} {
|
||||
if n, ok := PlacementFileNode(p); n != want || ok != (want != "") {
|
||||
t.Errorf("PlacementFileNode(%q) = %q, %v", p, n, ok)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
g14 := Placement{Node: "g14", Revision: "abc",
|
||||
Decided: NodeDecided{Endpoint: "g14.example:51820", Site: "home", Address: "10.77.0.2", Account: "op", Taken: []string{"zsh"}},
|
||||
Modules: []PlacedModule{
|
||||
{Module: "zsh"},
|
||||
{Module: "app", Ports: map[string]int{"8080": 18080}, Bindings: map[string]Provider{"postgres": {Node: "novox", Module: "postgres"}}},
|
||||
},
|
||||
Pins: map[string]Provider{"shell": {Node: "g14", Module: "zsh"}},
|
||||
Holds: []Holding{{Claim: "node-shell", Scope: "node", Module: "zsh"}},
|
||||
}
|
||||
novox := Placement{Node: "novox", Decided: NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []PlacedModule{{Module: "postgres", Ports: map[string]int{"5432": 5432}}},
|
||||
Holds: []Holding{{Claim: "mesh-store", Scope: "mesh", Module: "postgres"}}}
|
||||
|
||||
t.Run("value", func(t *testing.T) {
|
||||
raw, err := EncodePlacement(g14)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodePlacement(raw)
|
||||
if err != nil || back.V != Version || !SamePlacement(back, g14) {
|
||||
t.Fatalf("round trip: %+v, %v", back, err)
|
||||
}
|
||||
moved := g14
|
||||
moved.Revision = "def"
|
||||
if !SamePlacement(g14, moved) {
|
||||
t.Error("a new revision alone counts as a change")
|
||||
}
|
||||
if _, err := DecodePlacement([]byte(`{"v":9,"node":"g14","modules":[]}`)); !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("version 9: %v", err)
|
||||
}
|
||||
for name, bad := range map[string]string{
|
||||
"module twice": `{"v":1,"node":"g14","modules":[{"module":"a"},{"module":"a"}]}`,
|
||||
"port not number": `{"v":1,"node":"g14","modules":[{"module":"a","ports":{"http":80}}]}`,
|
||||
"host port zero": `{"v":1,"node":"g14","modules":[{"module":"a","ports":{"80":0}}]}`,
|
||||
"binding no node": `{"v":1,"node":"g14","modules":[{"module":"a","bindings":{"pg":{"module":"pg"}}}]}`,
|
||||
"pin no module": `{"v":1,"node":"g14","modules":[],"pins":{"pg":{"node":"x"}}}`,
|
||||
"hold not placed": `{"v":1,"node":"g14","modules":[],"holds":[{"claim":"s","module":"a"}]}`,
|
||||
"node not a name": `{"v":1,"node":"g.14","modules":[]}`,
|
||||
"value not shaped": `{"v":1,"node":"g14","modules":{}}`,
|
||||
} {
|
||||
if _, err := DecodePlacement([]byte(bad)); err == nil {
|
||||
t.Errorf("%s: taken", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("as the composition reads it", func(t *testing.T) {
|
||||
if got := g14.Assigned(); !reflect.DeepEqual(got, []string{"zsh", "app"}) {
|
||||
t.Errorf("Assigned = %v, want the file's order", got)
|
||||
}
|
||||
if got := g14.ChosenPins(); !reflect.DeepEqual(got, map[string]catalogue.Chosen{"shell": {Node: "g14", Module: "zsh"}}) {
|
||||
t.Errorf("ChosenPins = %v", got)
|
||||
}
|
||||
if got := g14.Bound(); !reflect.DeepEqual(got, map[string]map[string]catalogue.Chosen{"app": {"postgres": {Node: "novox", Module: "postgres"}}}) {
|
||||
t.Errorf("Bound = %v", got)
|
||||
}
|
||||
if got := g14.HostPorts(); !reflect.DeepEqual(got, map[string]map[int]int{"app": {8080: 18080}}) {
|
||||
t.Errorf("HostPorts = %v", got)
|
||||
}
|
||||
if got := g14.Held(); !reflect.DeepEqual(got, []catalogue.Held{{Claim: "node-shell", Scope: "node", Node: "g14", Module: "zsh"}}) {
|
||||
t.Errorf("Held = %v", got)
|
||||
}
|
||||
layered := PlacedModule{Module: "app", HasMesh: true, Layer: map[string]any{"expose": map[string]any{"80": "mesh"}}}
|
||||
want := []catalogue.Layer{
|
||||
{From: catalogue.MeshWideLayer, Values: map[string]any{}},
|
||||
{From: "g14", Values: map[string]any{"mode": "x"}, Placement: map[string]any{"expose": map[string]any{"80": "mesh"}}},
|
||||
}
|
||||
if got := layered.Layers("g14", map[string]any{"mode": "x"}); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Layers = %#v, want %#v", got, want)
|
||||
}
|
||||
if got := (PlacedModule{Module: "a"}).Layers("g14", map[string]any{"k": 1}); len(got) != 1 || got[0].From != "g14" {
|
||||
t.Errorf("values alone make the node's layer: %#v", got)
|
||||
}
|
||||
if got := (PlacedModule{Module: "a", HasLayer: true}).Layers("g14", nil); len(got) != 1 || got[0].Values == nil {
|
||||
t.Errorf("an empty layer of the node's own is kept, with empty values: %#v", got)
|
||||
}
|
||||
if got := (PlacedModule{Module: "a"}).Layers("g14", nil); got != nil {
|
||||
t.Errorf("no layer and no value is no layer: %#v", got)
|
||||
}
|
||||
empty := Placement{Node: "x"}
|
||||
if empty.ChosenPins() != nil || empty.Bound() != nil || empty.HostPorts() != nil || len(empty.Held()) != 0 {
|
||||
t.Error("an empty placement reads as something")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("what a change says", func(t *testing.T) {
|
||||
after := g14
|
||||
after.Revision = "def"
|
||||
after.Modules = []PlacedModule{{Module: "app", Ports: map[string]int{"8080": 18081}}, {Module: "new"}}
|
||||
got := ChangeOf(g14, after)
|
||||
want := PlacementChange{Node: "g14", Revision: "def", Added: []string{"new"}, Removed: []string{"zsh"}, Changed: []string{"app"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("ChangeOf = %+v, want %+v", got, want)
|
||||
}
|
||||
pinned := g14
|
||||
pinned.Pins = nil
|
||||
if got := ChangeOf(g14, pinned); !reflect.DeepEqual(got.Changed, []string{"app", "zsh"}) {
|
||||
t.Errorf("a pin change changes every module on the node: %+v", got)
|
||||
}
|
||||
if got := ChangeOf(Placement{}, novox); !reflect.DeepEqual(got.Added, []string{"postgres"}) || got.Node != "novox" {
|
||||
t.Errorf("a new node: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the catalogue's check", func(t *testing.T) {
|
||||
held := func(m string) bool { return m != "ghost" }
|
||||
enrolled := func(n string) bool { return n != "stranger" }
|
||||
if r := CheckPlacements(map[string]Placement{"g14": g14, "novox": novox}, held, enrolled); len(r) != 0 {
|
||||
t.Fatalf("a sound placement is refused: %v", r)
|
||||
}
|
||||
clash := g14
|
||||
clash.Modules = append([]PlacedModule{{Module: "ghost", Ports: map[string]int{"80": 18080}}}, g14.Modules...)
|
||||
clash.Modules[2] = PlacedModule{Module: "app", Ports: map[string]int{"8080": 18080}, Bindings: map[string]Provider{"postgres": {Node: "ace", Module: "postgres"}}}
|
||||
clash.Pins = map[string]Provider{"shell": {Node: "g14", Module: "bash"}}
|
||||
twice := novox
|
||||
twice.Node = "stranger"
|
||||
twice.Decided.Address = "10.77.0.2"
|
||||
r := CheckPlacements(map[string]Placement{"g14": clash, "novox": novox, "stranger": twice}, held, enrolled)
|
||||
for _, want := range []string{"holds no module ghost", "ghost and app are both published on host port 18080",
|
||||
"bound to ace/postgres", "pin of shell names g14/bash", "stranger is not an enrolled node",
|
||||
"mesh-store, held once for the mesh, is held on novox and stranger",
|
||||
"g14 and stranger both have the address 10.77.0.2", "novox, stranger are each placed as it"} {
|
||||
found := false
|
||||
for _, line := range r {
|
||||
found = found || strings.Contains(line, want)
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no refusal says %q; got %v", want, r)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -537,6 +537,11 @@ type Forge struct {
|
||||
Act string `json:"act,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
Open []OpenPull `json:"open,omitempty"`
|
||||
// Placements are, on a merged message of the catalogue repository, every placement file of the merge commit, the
|
||||
// bytes exact, by node (PlacementFile; novox/hq ADR 0549 §2), and PlacementsRead says they were read, so a catalogue
|
||||
// whose last placement file was removed is told from a message that read none (older writers, another repository).
|
||||
Placements map[string]string `json:"placements,omitempty"`
|
||||
PlacementsRead bool `json:"placements-read,omitempty"`
|
||||
}
|
||||
|
||||
func (f Forge) check() error {
|
||||
@@ -549,6 +554,17 @@ func (f Forge) check() error {
|
||||
if len(f.Open) > 0 {
|
||||
return fmt.Errorf("delivery: only an open message names open pull requests")
|
||||
}
|
||||
if (len(f.Placements) > 0 || f.PlacementsRead) && f.Kind != ForgeMerged {
|
||||
return fmt.Errorf("delivery: only a merged message carries placement files")
|
||||
}
|
||||
if len(f.Placements) > 0 && !f.PlacementsRead {
|
||||
return fmt.Errorf("delivery: a message with placement files must say it read them (placements-read)")
|
||||
}
|
||||
for node := range f.Placements {
|
||||
if err := checkName("node", node); err != nil {
|
||||
return fmt.Errorf("delivery: a placement file names no node: %w", err)
|
||||
}
|
||||
}
|
||||
if err := checkRepository(f.Repository); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
func hashOf(c byte) string { return "sha256:" + digestOf(c) }
|
||||
|
||||
// 1943/AC3: a want naming its fingerprint and the digest of the composition it allows round-trips, and so does one
|
||||
// carrying the answer to a failed backup; a want naming a fingerprint without a digest, a digest not of the sha256
|
||||
// carrying the answer to a failed backup, and from ADR 0547's second milestone one naming the build alone or the yes
|
||||
// a person gave; a digest not of the sha256
|
||||
// form, a removed want naming a digest, an unknown backup answer and a rule past the seventh are refused.
|
||||
func TestAWantNamesItsComposition(t *testing.T) {
|
||||
for _, w := range []Want{
|
||||
@@ -17,6 +18,9 @@ func TestAWantNamesItsComposition(t *testing.T) {
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Backup: BackupSkip, Words: "the operator: go on without a backup"},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: RuleComposed, Words: "no composition of b for ace yet"},
|
||||
{Removed: true},
|
||||
// ADR 0547 §2, §3 (issue 1126): from the second milestone a want names the build alone, and the yes a person gave.
|
||||
{FP: fp2of('a'), Self: true},
|
||||
{FP: fp2of('a'), Self: true, Yes: hashOf('3')},
|
||||
} {
|
||||
raw, err := EncodeWant(w)
|
||||
if err != nil {
|
||||
@@ -33,6 +37,9 @@ func TestAWantNamesItsComposition(t *testing.T) {
|
||||
why string
|
||||
}{
|
||||
{Want{FP: fp2of('a')}, "names no composition"},
|
||||
{Want{FP: fp2of('a'), Self: true, Composed: hashOf('1')}, "names a composition"},
|
||||
{Want{FP: fp2of('a'), Self: true, Yes: "sha256:12"}, "a want's yes allows"},
|
||||
{Want{Removed: true, Self: true}, "removed want names no"},
|
||||
{Want{FP: fp2of('a'), Composed: "sha256:12"}, "not sha256"},
|
||||
{Want{Removed: true, Composed: hashOf('1')}, "removed want names no"},
|
||||
{Want{FP: fp2of('a'), Composed: hashOf('1'), Backup: "later"}, `backup answer "later"`},
|
||||
@@ -45,7 +52,7 @@ func TestAWantNamesItsComposition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if _, err := DecodeWant([]byte(`{"v":1,"fp":"` + fp2of('a') + `"}`)); err == nil || !strings.Contains(err.Error(), "names no composition") {
|
||||
t.Errorf("a want of release 5, naming no composition, was read: %v", err)
|
||||
t.Errorf("a want of release 5, naming no composition and not the node's own, was read: %v", err)
|
||||
}
|
||||
if RuleComposed != 7 {
|
||||
t.Errorf("RuleComposed = %d, want the seventh rule", RuleComposed)
|
||||
@@ -86,11 +93,17 @@ func TestTheNodesOwnWant(t *testing.T) {
|
||||
if err != nil || back != w {
|
||||
t.Errorf("node want came back %+v, %v", back, err)
|
||||
}
|
||||
for _, bad := range []NodeWant{{}, {Composed: "sha256:1"}} {
|
||||
for _, bad := range []NodeWant{{}, {Composed: "sha256:1"}, {Self: true, Composed: hashOf('2')}} {
|
||||
if _, err := EncodeNodeWant(bad); err == nil {
|
||||
t.Errorf("EncodeNodeWant(%+v) accepted", bad)
|
||||
}
|
||||
}
|
||||
// ADR 0547 §2 (issue 1126): from the second milestone the node composes its own composition, and its want names none.
|
||||
if raw, err := EncodeNodeWant(NodeWant{Self: true, Words: "the node composes its own"}); err != nil {
|
||||
t.Errorf("a node want naming no composition: %v", err)
|
||||
} else if back, err := DecodeNodeWant(raw); err != nil || back.Composed != "" {
|
||||
t.Errorf("a node want naming no composition came back %+v, %v", back, err)
|
||||
}
|
||||
if _, err := DecodeNodeWant([]byte(`{"v":1,"composed":"` + hashOf('2') + `","fp":"` + fp2of('a') + `"}`)); err == nil {
|
||||
t.Errorf("a node want naming a fingerprint was read")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user