Name the seat's states, candidates by proposal and the proposal itself, so readers and the holder share one contract (ADR 0567 §1-§6, issue 1126)

This commit is contained in:
2026-10-28 18:09:38 +01:00
parent 902e3c9e96
commit edc6f269f3
8 changed files with 721 additions and 168 deletions
+6 -6
View File
@@ -15,18 +15,18 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/delivery"
) )
// Answer is a node's answer to its group's placement candidates (novox/hq ADR 0559 §4): s.Node composed in shadow with // Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with
// s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as // s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as
// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes // for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes
// nothing; head and revision are the candidate's, said back in the answer. // nothing; the proposal and the revision are the candidate's, said back in the answer.
// //
// The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the // The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the
// node does not run, the build the trunk holds; a placed module with none is added, with no build of it known. What the // node does not run, the build the trunk holds; a placed module with none is added, with no build of it known. What the
// composition stands in for is judged: a value of a module the group adds (on this node or another: its credential, own // composition stands in for is judged: a value of a module the proposal adds (on this node or another: its credential, own
// secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other // secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other
// stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own). // stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own).
func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, head string, revision uint64) delivery.CandidateAnswer { func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, proposal string, revision uint64) delivery.CandidateAnswer {
a := delivery.CandidateAnswer{Head: head, Revision: revision} a := delivery.CandidateAnswer{Proposal: proposal, Revision: revision}
refuse := func(err error) delivery.CandidateAnswer { refuse := func(err error) delivery.CandidateAnswer {
a.Refused = err.Error() a.Refused = err.Error()
a.Verdict = delivery.CandidateRefused a.Verdict = delivery.CandidateRefused
@@ -138,7 +138,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) }) cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) })
skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) }) skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) })
// What the group adds, by node and module: placed by a candidate and not by the trunk on that node. // What the proposal adds, by node and module: placed by a candidate and not by the trunk on that node.
added := map[string]bool{} added := map[string]bool{}
for node, p := range s.Placements { for node, p := range s.Placements {
if p == nil { if p == nil {
+6 -6
View File
@@ -70,7 +70,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
// Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named. // Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named.
b := mk("lemurs", "web", "api", "spiegel") b := mk("lemurs", "web", "api", "spiegel")
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b)}, a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b)},
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "h1", 3) map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
var refused []string var refused []string
for _, r := range a.Refusals { for _, r := range a.Refusals {
refused = append(refused, r.Module) refused = append(refused, r.Module)
@@ -141,14 +141,14 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
if string(gotOwn) != string(wantOwn) { if string(gotOwn) != string(wantOwn) {
t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn) t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn)
} }
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "h1", 3) a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3)
if a.Verdict != delivery.CandidateSame { if a.Verdict != delivery.CandidateSame {
t.Errorf("the fold's answer: %+v, want the same", a) t.Errorf("the fold's answer: %+v, want the same", a)
} }
// (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a // (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a
// module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer). // module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer).
keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes}) keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes})
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 { if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 {
t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a) t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a)
} }
// (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed // (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed
@@ -161,7 +161,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
} }
withMembership = append(withMembership, c) withMembership = append(withMembership, c)
} }
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "h1", 3); a.Verdict != delivery.CandidateSame { if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != delivery.CandidateSame {
t.Errorf("the node's own with its bus membership is answered %+v, want the same", a) t.Errorf("the node's own with its bus membership is answered %+v, want the same", a)
} }
// Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it. // Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it.
@@ -301,7 +301,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
p.Decided.Endpoint = "n1.example:51820" p.Decided.Endpoint = "n1.example:51820"
b.placements["n1"] = p b.placements["n1"] = p
fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f")} fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f")}
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "h1", 3) a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3)
refused = nil refused = nil
for _, r := range a.Refusals { for _, r := range a.Refusals {
refused = append(refused, r.Module) refused = append(refused, r.Module)
@@ -469,7 +469,7 @@ func otherNodeNotResolvedIsNamed(t *testing.T) {
p := p p := p
given[n] = &p given[n] = &p
} }
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "h", 1) a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1)
if a.Verdict != delivery.CandidateRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") { if a.Verdict != delivery.CandidateRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") {
t.Errorf("a refusal for want of n2's provider = %+v", a) t.Errorf("a refusal for want of n2's provider = %+v", a)
} }
+2 -2
View File
@@ -401,7 +401,7 @@ func TestInput(t *testing.T) {
check func(delivery.CandidateAnswer) bool check func(delivery.CandidateAnswer) bool
}{ }{
"the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame, "the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame,
func(a delivery.CandidateAnswer) bool { return len(a.Same) == 3 && a.Head == "h1" && a.Revision == 7 }}, func(a delivery.CandidateAnswer) bool { return len(a.Same) == 3 && a.Proposal == "p-h1" && a.Revision == 7 }},
"the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame, "the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame,
func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }}, func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
"one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers, "one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers,
@@ -442,7 +442,7 @@ func TestInput(t *testing.T) {
if name == "mesh-wireguard and an old-form module, folded" { if name == "mesh-wireguard and an old-form module, folded" {
fps = map[string]string{"keeper": oneFps["keeper"]} // the node's wants: no build of lemurs or the private network fps = map[string]string{"keeper": oneFps["keeper"]} // the node's wants: no build of lemurs or the private network
} }
a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "h1", 7) a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7)
if a.Verdict != c.verdict || !c.check(a) { if a.Verdict != c.verdict || !c.check(a) {
t.Errorf("%s: %+v", name, a) t.Errorf("%s: %+v", name, a)
} }
+2 -2
View File
@@ -413,11 +413,11 @@ type HaveNode struct {
Sealed []string `json:"sealed,omitempty"` Sealed []string `json:"sealed,omitempty"`
// SealedAt is Sealed by slot (Have.SealedAt). // SealedAt is Sealed by slot (Have.SealedAt).
SealedAt Seals `json:"sealed-at,omitempty"` SealedAt Seals `json:"sealed-at,omitempty"`
// Candidates is the node's answer to each placement candidate naming it, by group (PlacementCandidate): written by // Candidates is the node's answer to each placement candidate naming it, by proposal id (PlacementCandidate): written by
// its shadow composer whenever an answer changes, removed when the candidate key goes. The node applies nothing of a // its shadow composer whenever an answer changes, removed when the candidate key goes. The node applies nothing of a
// candidate. // candidate.
Candidates map[string]CandidateAnswer `json:"candidates,omitempty"` Candidates map[string]CandidateAnswer `json:"candidates,omitempty"`
// Composes is whose compositions the node applies: its own (ComposesOwn, the catalogue places it) or the // Composes is whose compositions the node applies: its own (ComposesOwn, placement places it) or the
// controller's (ComposesController); ComposesWhy says why in words. Written by the node agent at each start, so a // controller's (ComposesController); ComposesWhy says why in words. Written by the node agent at each start, so a
// node returning to the controller's compositions when its placement key is deleted says so (issue 1126). Empty // node returning to the controller's compositions when its placement key is deleted says so (issue 1126). Empty
// from an engine older than the field. // from an engine older than the field.
+49 -53
View File
@@ -3,7 +3,6 @@ package delivery
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"path"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -11,29 +10,38 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue" "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` // Placement of modules on nodes is the state of the seat mesh-placement (novox/hq ADR 0567), held once for the mesh by
// in the catalogue repository, changed only by a pull request. At each trunk merge of the catalogue the indexer reads // the assigner: one key `placement.<node>` per node in its state `placement`, written only on the operator's Approve of a
// the files and writes each node's key `placement.<node>` in its state `placement`, only when it differs, and says // proposal the nodes it touches answered (§2-§5), by compare-and-set at the revision the proposal was laid over. No
// `placement-changed` naming the node, the revision and the modules added, removed or changed (ADR 0549 §2). The node // repository holds it. The node agents, the grants part, mesh-config and the status view read it (§9); nothing in it is
// agents, the assigner, the grants part and mesh-config read it (§3); nothing in it is secret. // secret. Its states are named after the holding module, as every module state is, and these constants are the seat's
// contract: every reader takes the names from here (§1).
const ( const (
// PlacementState is the indexer's state of placements, and PlacementBucket its bucket. // PlacementState is the seat's state of placements, and PlacementBucket its bucket. PlacementRead is the name a
// reader's manifest declares in its reads.
PlacementState = "placement" PlacementState = "placement"
PlacementBucket = IndexerModule + "_" + PlacementState PlacementBucket = AssignerModule + "_" + PlacementState
// PlacementHistory is the revisions the placement bucket keeps per key. PlacementRead = AssignerModule + "." + PlacementState
PlacementHistory = 10 // PlacementHistory is the revisions the placement bucket keeps per key: the bus's most (§6).
// PlacementDir is the catalogue's directory of placement files, one `<node>.json` per node. PlacementHistory = 64
PlacementDir = "placement"
// PlacementChangedEvent is what the indexer says when a node's placement key changed. // PlacementStream is the holder's stream of the seat's events, kept PlacementStreamDays (§6), and declared as the
PlacementChangedEvent = "placement-changed" // holder's data so the mesh's backups keep it.
PlacementStream = "placement"
PlacementStreamDays = 400
// The seat's events, said by its holder as its own (§2, §4, §5, §6), each carrying a PlacementEvent.
PlacementProposedEvent = "placement-proposed"
PlacementWithdrawnEvent = "placement-withdrawn"
PlacementJudgedEvent = "placement-judged"
PlacementChangedEvent = "placement-changed"
placementWord = "placement" 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"
) )
// PlacementEvents are the seat's four events, in the order a proposal says them.
var PlacementEvents = []string{PlacementProposedEvent, PlacementJudgedEvent, PlacementChangedEvent, PlacementWithdrawnEvent}
// PlacementKey is the key, in PlacementBucket, of one node's placement: `placement.<node>`. // PlacementKey is the key, in PlacementBucket, of one node's placement: `placement.<node>`.
func PlacementKey(node string) (string, error) { func PlacementKey(node string) (string, error) {
if err := checkName("node", node); err != nil { if err := checkName("node", node); err != nil {
@@ -51,20 +59,6 @@ func ParsePlacementKey(key string) (node string, err error) {
return node, nil 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. // 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 { type Provider struct {
Node string `json:"node"` Node string `json:"node"`
@@ -109,8 +103,8 @@ type Holding struct {
Kind string `json:"kind,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 // Placement is one node's placement, the value of its key. Modules are in the order the composition reads the node's
// in the order the file names them, which is the order the composition reads the node's modules in. Pins is, by // modules in. Revision is unused since placement left the catalogue (ADR 0567): the key's own revision is the bus's. Pins is, by
// provision, the provider chosen for the node where more than one could answer. // provision, the provider chosen for the node where more than one could answer.
type Placement struct { type Placement struct {
V int `json:"v"` V int `json:"v"`
@@ -122,13 +116,13 @@ type Placement struct {
Holds []Holding `json:"holds,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 // NodeDecided is what a person decides of the node, kept in its placement key (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 // lives in placement, 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 // 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 // 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 // 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 // to that tunnel's peers. The address is a decision written once, at enrolment, by an
// catalogue pull request: nothing allocates it at run time, and CheckPlacements refuses two nodes with one address. // approved proposal (ADR 0567 §2): 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. // No secret and no key material: accounts are names, never credentials.
type NodeDecided struct { type NodeDecided struct {
Endpoint string `json:"endpoint,omitempty"` Endpoint string `json:"endpoint,omitempty"`
@@ -211,8 +205,8 @@ func DecodePlacement(data []byte) (Placement, error) {
return p, nil return p, nil
} }
// SamePlacement says whether two placements are the same value, so the indexer writes only when they differ. The // SamePlacement says whether two placements are the same value, so the holder writes only when they differ. The
// revision is not compared: a merge that changes no placement writes nothing (ADR 0549 §2). // revision is not compared.
func SamePlacement(a, b Placement) bool { func SamePlacement(a, b Placement) bool {
a.Revision, b.Revision = "", "" a.Revision, b.Revision = "", ""
ja, errA := EncodePlacement(a) ja, errA := EncodePlacement(a)
@@ -220,21 +214,23 @@ func SamePlacement(a, b Placement) bool {
return errA == nil && errB == nil && string(ja) == string(jb) 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 // PlacementChange is what `placement-changed` says of one node (ADR 0567 §6): the node, the revision of its key before
// or changed against the placement before. // (0: it had none) and after (0: the key was deleted), and its modules added, removed or changed against the placement
// before.
type PlacementChange struct { type PlacementChange struct {
Node string `json:"node"` Node string `json:"node"`
Revision string `json:"revision"` Before uint64 `json:"before"`
Added []string `json:"added,omitempty"` After uint64 `json:"after"`
Removed []string `json:"removed,omitempty"` Added []string `json:"added,omitempty"`
Changed []string `json:"changed,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. // 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 // 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. // composition of each may read them.
func ChangeOf(before, after Placement) PlacementChange { func ChangeOf(before, after Placement) PlacementChange {
c := PlacementChange{Node: after.Node, Revision: after.Revision} c := PlacementChange{Node: after.Node}
if c.Node == "" { if c.Node == "" {
c.Node = before.Node c.Node = before.Node
} }
@@ -368,8 +364,8 @@ func (p Placement) Held() []catalogue.Held {
return out return out
} }
// CheckPlacements is the catalogue's own check of every placement file (ADR 0549 §1), over the placements by node, the // CheckPlacements is the holder's check of a proposal laid over every placement (ADR 0567 §2), over the placements by
// modules the catalogue holds and the nodes enrolled. It returns every refusal, in node order, never only the first: a // node, the modules the indexer holds and the nodes with a record. It returns every refusal, in node order, never only the first: a
// placement naming a node not enrolled or a module the catalogue does not hold; two modules of one node on the same host // 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, unless the seat is replicated (catalogue.Seat's Replicated, ADR 0223), which // port; a pin, binding or holding naming a provider that is not placed where it says; a seat of scope mesh held twice, unless the seat is replicated (catalogue.Seat's Replicated, ADR 0223), which
// is held once per node; // is held once per node;
@@ -401,7 +397,7 @@ func CheckPlacements(placements map[string]Placement, held func(module string) b
for _, node := range nodes { for _, node := range nodes {
p := placements[node] p := placements[node]
if p.Node != node { if p.Node != node {
refusals = append(refusals, fmt.Sprintf("the placement file of %s names the node %q", node, p.Node)) refusals = append(refusals, fmt.Sprintf("the placement of %s names the node %q", node, p.Node))
} }
if a := strings.TrimSpace(p.Decided.Address); a != "" { if a := strings.TrimSpace(p.Decided.Address); a != "" {
if other, twice := addresses[a]; twice { if other, twice := addresses[a]; twice {
@@ -413,12 +409,12 @@ func CheckPlacements(placements map[string]Placement, held func(module string) b
hubs = append(hubs, node) hubs = append(hubs, node)
} }
if !enrolled(node) { if !enrolled(node) {
refusals = append(refusals, fmt.Sprintf("%s is not an enrolled node", node)) refusals = append(refusals, fmt.Sprintf("%s is a node with no record", node))
} }
hostPorts := map[int]string{} hostPorts := map[int]string{}
for _, m := range p.Modules { for _, m := range p.Modules {
if !held(m.Module) { if !held(m.Module) {
refusals = append(refusals, fmt.Sprintf("%s: the catalogue holds no module %s", node, m.Module)) refusals = append(refusals, fmt.Sprintf("%s: the indexer holds no module %s", node, m.Module))
} }
for _, port := range sortedKeys(m.Ports) { for _, port := range sortedKeys(m.Ports) {
at := m.Ports[port] at := m.Ports[port]
+43 -53
View File
@@ -6,70 +6,62 @@ import (
"strings" "strings"
) )
// Placement candidates (issue 1126; ADR 0549 §4: a placement change passes the composition check before any node sees // Placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): for each node a proposal must hear from, the seat's holder
// it). A catalogue pull request that changes a placement file carries every placement file at its head on its pull // writes one key `<proposal>.<node>` in its state `candidates`: the node's placement with the proposal laid over it for a
// message (Forge.Placements). The indexer writes, for each node whose file at the head differs from the trunk's, the // node whose key it changes, or the node's current key for one whose composition it changes only through another node
// key `<group>.<node>` of its state `placement-candidates`, and deletes it when the pull request merges or closes. It is // (Reached: a binding, a firewall or a resolver entry reaching it). It deletes them when the proposal ends. A state of its
// a state of its own, since the readers of `placement` refuse any key that is not `placement.<node>`. // own, since the readers of `placement` refuse any key that is not `placement.<node>`.
// //
// The node it names composes in shadow with the candidate (its own sealed values, the boxes it already runs) and // The node watches `*.<node>`, composes in shadow with every candidate of its proposal laid over the current placement
// compares the result with what it runs; it applies nothing. Its answer rides in its own have key, `<node>.node` // (its own sealed values, the boxes it already runs) and compares the result with what it runs; it applies nothing. Its
// (HaveNode.Candidates, by group), which that node alone writes; the assigner's check of the group reads every placed // answer rides in its own have key, `<node>.node` (HaveNode.Candidates, by proposal), which that node alone writes; the
// node's answer at the head and fails on any difference, refusal or silence. Event-driven both ways: the candidate key's // holder reads it and judges (ADR 0567 §4). Event-driven both ways: the candidate key's write is the node's event, the
// write is the node's event, the `<node>.node` change the check's. // `<node>.node` change the holder's.
const ( const (
// PlacementCandidatesState is the indexer's state of placement candidates, and PlacementCandidatesBucket its bucket. // PlacementCandidatesState is the seat's state of placement candidates, PlacementCandidatesBucket its bucket and
PlacementCandidatesState = "placement-candidates" // PlacementCandidatesRead the name a reader's manifest declares.
PlacementCandidatesBucket = IndexerModule + "_" + PlacementCandidatesState PlacementCandidatesState = "candidates"
PlacementCandidatesBucket = AssignerModule + "_" + PlacementCandidatesState
PlacementCandidatesRead = AssignerModule + "." + PlacementCandidatesState
// PlacementCandidatesHistory is the revisions the bucket keeps per key. // PlacementCandidatesHistory is the revisions the bucket keeps per key.
PlacementCandidatesHistory = 5 PlacementCandidatesHistory = 5
) )
// PlacementCandidateKey is the key, in PlacementCandidatesBucket, of one node's candidate placement in one group: // PlacementCandidateKey is the key, in PlacementCandidatesBucket, of one node's candidate in one proposal:
// `<group>.<node>`, the group escaped as CandidatesKey escapes it, so a node watches `*.<node>` and a group's keys are // `<proposal>.<node>`, so a node watches `*.<node>` and a proposal's keys are `<proposal>.*`.
// `<group>.*`. func PlacementCandidateKey(proposal, node string) (string, error) {
func PlacementCandidateKey(group, node string) (string, error) { if err := CheckProposalID(proposal); err != nil {
g, err := groupKey(group)
if err != nil {
return "", err return "", err
} }
if err := checkName("node", node); err != nil { if err := checkName("node", node); err != nil {
return "", err return "", err
} }
return g + "." + node, nil return proposal + "." + node, nil
} }
// ParsePlacementCandidateKey is the inverse of PlacementCandidateKey; it refuses any other key, naming it. // ParsePlacementCandidateKey is the inverse of PlacementCandidateKey; it refuses any other key, naming it.
func ParsePlacementCandidateKey(key string) (group, node string, err error) { func ParsePlacementCandidateKey(key string) (proposal, node string, err error) {
g, node, ok := strings.Cut(key, ".") proposal, node, ok := strings.Cut(key, ".")
if !ok || checkName("node", node) != nil { if !ok || checkName("node", node) != nil || CheckProposalID(proposal) != nil {
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key, <group>.<node>", key) return "", "", fmt.Errorf("delivery: %q is not a placement candidate key, <proposal>.<node>", key)
} }
if group, err = parseGroupKey(g); err != nil { return proposal, node, nil
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key: %w", key, err)
}
return group, node, nil
} }
// PlacementCandidate is the value of a candidate key: the group, the catalogue pull request and the head its placement // PlacementCandidate is the value of a candidate key: the proposal it is of, and the node's placement with the proposal
// files were read at, and the node's placement at that head; nil Placement says the head removes the node's file. // laid over it; nil Placement says the proposal takes the node's key away. Reached says the proposal changes not this
// node's key but its composition, through another node's: Placement is then its current key (nil when it has none).
type PlacementCandidate struct { type PlacementCandidate struct {
V int `json:"v"` V int `json:"v"`
Group string `json:"group"` Proposal string `json:"proposal"`
Pull Head `json:"pull"` Reached bool `json:"reached,omitempty"`
Placement *Placement `json:"placement,omitempty"` Placement *Placement `json:"placement,omitempty"`
} }
func (c PlacementCandidate) check() error { func (c PlacementCandidate) check() error {
if c.Group == "" { if err := CheckProposalID(c.Proposal); err != nil {
return fmt.Errorf("delivery: a placement candidate names no group")
}
if err := checkRepository(c.Pull.Repository); err != nil {
return err return err
} }
if c.Pull.Number <= 0 || c.Pull.Head == "" {
return fmt.Errorf("delivery: a placement candidate needs its pull request's number and head")
}
if c.Placement != nil { if c.Placement != nil {
return c.Placement.check() return c.Placement.check()
} }
@@ -90,8 +82,8 @@ func EncodePlacementCandidate(c PlacementCandidate) ([]byte, error) {
return json.Marshal(c) return json.Marshal(c)
} }
// DecodePlacementCandidate reads a candidate; it refuses an unknown version, no group, a pull request without number or // DecodePlacementCandidate reads a candidate; it refuses an unknown version, a proposal id that is not one, and a
// head, and a placement DecodePlacement refuses. // placement DecodePlacement refuses.
func DecodePlacementCandidate(data []byte) (PlacementCandidate, error) { func DecodePlacementCandidate(data []byte) (PlacementCandidate, error) {
var c PlacementCandidate var c PlacementCandidate
if err := decode(data, &c); err != nil { if err := decode(data, &c); err != nil {
@@ -115,14 +107,13 @@ const (
CandidateRefused = "refused" CandidateRefused = "refused"
) )
// CandidateAnswer is a node's answer to its group's candidate keys, in HaveNode.Candidates (ADR 0559 §4): the head // CandidateAnswer is a node's answer to its proposal's candidate keys, in HaveNode.Candidates (ADR 0567 §3): the
// and the revision of the node's own key in the group it answered (0 when the group has no key of its own for it: its // proposal and the revision of the node's own candidate key it answered, its verdict, the modules composed the same, those that differ with both digests, those
// file is unchanged and it answers as placed at the head or on the trunk, judged by Head alone), its verdict, the modules composed the same, those that differ with both digests, those
// the candidate adds (naming the values not yet issued for each, which is no refusal: a module's credential and boxes // the candidate adds (naming the values not yet issued for each, which is no refusal: a module's credential and boxes
// are made only once it is placed, ADR 0549 §5) or removes, and each refusal. The node's own composition is named // are made only once it is placed, ADR 0549 §5) or removes, and each refusal. The node's own composition is named
// "node" (HaveNodeWord). // "node" (HaveNodeWord).
type CandidateAnswer struct { type CandidateAnswer struct {
Head string `json:"head"` Proposal string `json:"proposal"`
Revision uint64 `json:"revision"` Revision uint64 `json:"revision"`
Verdict string `json:"verdict"` Verdict string `json:"verdict"`
Same []string `json:"same,omitempty"` Same []string `json:"same,omitempty"`
@@ -132,9 +123,8 @@ type CandidateAnswer struct {
Refusals []ModuleRefused `json:"refusals,omitempty"` Refusals []ModuleRefused `json:"refusals,omitempty"`
// Refused is why the node could not compose with the candidate at all; Refusals is then empty. // Refused is why the node could not compose with the candidate at all; Refusals is then empty.
Refused string `json:"refused,omitempty"` Refused string `json:"refused,omitempty"`
// Returns is, for a head that deletes the node's placement file, what the node does then: it returns to the // Returns is, for a proposal that takes the node's placement key away, what the node does then: it returns to the
// controller's compositions (ReturnsToController), as a node the catalogue does not place composes none of its own // controller's compositions (ReturnsToController), as a node with no placement key composes none of its own. Same, Differs, Added and Removed then compare the controller's compositions at
// (issue 1126: the seed's revert). Same, Differs, Added and Removed then compare the controller's compositions at
// the node's wants with what it runs, and the verdict is theirs; a want the controller holds no composition for is // the node's wants with what it runs, and the verdict is theirs; a want the controller holds no composition for is
// refused (Refused, without Returns). // refused (Refused, without Returns).
Returns string `json:"returns,omitempty"` Returns string `json:"returns,omitempty"`
@@ -147,8 +137,8 @@ type CandidateAnswer struct {
ValuesDiffer int `json:"values_differ,omitempty"` ValuesDiffer int `json:"values_differ,omitempty"`
} }
// ReturnsToController is what a node answers a head that deletes its placement file. // ReturnsToController is what a node answers a proposal that takes its placement key away.
const ReturnsToController = "the head deletes this node's placement: it returns to the controller's compositions" const ReturnsToController = "the proposal takes this node's placement away: it returns to the controller's compositions"
// ModuleDiffers is one module the node runs and the candidate still places whose composition is not the one it runs: // ModuleDiffers is one module the node runs and the candidate still places whose composition is not the one it runs:
// both digests (Composed with the candidate, Running what runs), the first resource that differs by its id, or empty // both digests (Composed with the candidate, Running what runs), the first resource that differs by its id, or empty
@@ -179,7 +169,7 @@ type ResourceBodies struct {
// The bounds of a module's shown bodies, and of one answer as the node's own key holds it: an answer over // The bounds of a module's shown bodies, and of one answer as the node's own key holds it: an answer over
// MaxAnswerBytes is fitted (FitAnswer). The node's key (HaveBucket) takes a value of about 1 MiB // MaxAnswerBytes is fitted (FitAnswer). The node's key (HaveBucket) takes a value of about 1 MiB
// (buscompose.ChainValueBytes) and holds every group's answer beside the node's facts, so one answer keeps to a sixth. // (buscompose.ChainValueBytes) and holds every proposal's answer beside the node's facts, so one answer keeps to a sixth.
const ( const (
MaxBodies = 20 MaxBodies = 20
MaxBody = 8192 MaxBody = 8192
@@ -215,8 +205,8 @@ func VerdictOf(a CandidateAnswer) string {
} }
func (a CandidateAnswer) check() error { func (a CandidateAnswer) check() error {
if a.Head == "" { if CheckProposalID(a.Proposal) != nil || a.Revision == 0 {
return fmt.Errorf("delivery: a candidate answer names no head") return fmt.Errorf("delivery: a candidate answer names no proposal or no revision of its candidate")
} }
switch a.Verdict { switch a.Verdict {
case CandidateSame, CandidateDiffers, CandidateRefused: case CandidateSame, CandidateDiffers, CandidateRefused:
+471
View File
@@ -0,0 +1,471 @@
package delivery
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"time"
)
// A placement changes only by a proposal (novox/hq ADR 0567 §2): `mesh-placement.propose` takes why and a list of
// changes; the holder lays them over the current keys, names the revision of each key it laid them over, and either
// refuses at once with every finding or writes the proposal under its id in its state `proposals` and a candidate for
// each node it must hear from (PlacementCandidate). The proposal's key leaves the state when it is applied, withdrawn,
// declined or expired; its whole content stays in the event that ends it (PlacementEvent), so no proposal is kept in
// the state once it has ended.
const (
// PlacementProposalsState is the seat's state of open proposals, PlacementProposalsBucket its bucket and
// PlacementProposalsRead the name a reader's manifest declares; a key is the proposal's id.
PlacementProposalsState = "proposals"
PlacementProposalsBucket = AssignerModule + "_" + PlacementProposalsState
PlacementProposalsRead = AssignerModule + "." + PlacementProposalsState
// PlacementProposalsHistory is the revisions the bucket keeps per key.
PlacementProposalsHistory = 5
)
// The operations a proposal's change names (ADR 0567 §2).
const (
OpPlace = "place" // place a module on a node, with its host ports and bindings
OpTakeOff = "take-off" // take a module off a node
OpPin = "pin" // pin the provider of a provision on a node
OpUnpin = "unpin" // remove a pin
OpHold = "hold" // a module placed on the node holds a seat
OpUnhold = "unhold" // it holds it no more
OpDecide = "decide" // set a decided field of the node (NodeDecided by its JSON name), its address among them
OpConfig = "config" // set a module's config placement or values on the node
OpSeed = "seed" // the node's whole placement: the take-over (adopt, §7), never given by a caller
)
// The states of an open proposal.
const (
// ProposalAnswering: its candidates are written and the nodes it must hear from have not all answered.
ProposalAnswering = "answering"
// ProposalFailed: its verdict failed; it stays open, for its proposer to read and withdraw, or for a node's later
// answer to judge it again.
ProposalFailed = "failed"
// ProposalAsked: its verdict passed and the operator is asked to approve it.
ProposalAsked = "asked"
)
// VerdictWaits is a proposal's verdict while a node it must hear from has not answered within the bound; it passes
// (VerdictPass) or fails (VerdictFail) as a check's verdict does (ADR 0567 §4).
const VerdictWaits = "waits"
// PlacementProposalChange is one change of a proposal. Node is always named; the rest is the operation's.
type PlacementProposalChange struct {
Op string `json:"op"`
Node string `json:"node"`
Module string `json:"module,omitempty"`
Ports map[string]int `json:"ports,omitempty"`
Bindings map[string]Provider `json:"bindings,omitempty"`
Provision string `json:"provision,omitempty"`
Provider *Provider `json:"provider,omitempty"`
// Claim, Scope, Kind and Site are a holding's (OpHold, OpUnhold).
Claim string `json:"claim,omitempty"`
Scope string `json:"scope,omitempty"`
Kind string `json:"kind,omitempty"`
Site string `json:"site,omitempty"`
// Field and Value are a decided field's (OpDecide): its JSON name in NodeDecided and its value.
Field string `json:"field,omitempty"`
Value json.RawMessage `json:"value,omitempty"`
// MeshWide, Layer, MeshValues and Values are a module's config placement and values (OpConfig), as PlacedModule
// carries them; each given replaces what the module has.
MeshWide map[string]any `json:"mesh-wide,omitempty"`
Layer map[string]any `json:"layer,omitempty"`
MeshValues map[string]any `json:"mesh-wide-values,omitempty"`
Values map[string]any `json:"values,omitempty"`
// Whole is the node's whole placement (OpSeed).
Whole *Placement `json:"whole,omitempty"`
}
// BeforeLease is why the change cannot be proposed before the lease write, or "" (ADR 0567 §8): until then the holder
// keeps the controller's assignments in step through its verbs assign, unassign, pin and unpin, so only those are
// proposable — a module placed or taken off with no host port or binding, a pin set or removed, and the seed. A host
// port, a binding, a holding, a decided field and a config layer's places or accesses wait for the lease write, which
// the refusal names.
func (c PlacementProposalChange) BeforeLease() string {
const lease = "which the controller's verbs cannot take from a caller, so it waits for the lease write (ADR 0567 §8)"
switch c.Op {
case OpPlace:
if len(c.Ports)+len(c.Bindings) > 0 {
return fmt.Sprintf("the place of %s on %s names host ports or bindings, %s", c.Module, c.Node, lease)
}
case OpTakeOff, OpPin, OpUnpin, OpSeed:
default:
return fmt.Sprintf("a change %s of %s is no assignment or pin, %s", c.Op, c.Node, lease)
}
return ""
}
func (c PlacementProposalChange) check() error {
if err := checkName("node", c.Node); err != nil {
return err
}
needs := func(what string, ok bool) error {
if !ok {
return fmt.Errorf("delivery: a %s change of %s names no %s", c.Op, c.Node, what)
}
return nil
}
switch c.Op {
case OpPlace, OpTakeOff, OpConfig:
return needs("module", checkName("module", c.Module) == nil)
case OpPin:
return needs("provision or provider", c.Provision != "" && c.Provider != nil && c.Provider.Node != "" && c.Provider.Module != "")
case OpUnpin:
return needs("provision", c.Provision != "")
case OpHold, OpUnhold:
return needs("module or claim", checkName("module", c.Module) == nil && c.Claim != "")
case OpDecide:
return needs("field or value", c.Field != "" && len(c.Value) > 0)
case OpSeed:
if c.Whole == nil || c.Whole.Node != c.Node {
return fmt.Errorf("delivery: the seed of %s names no placement of that node", c.Node)
}
return c.Whole.check()
}
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide or config", c.Op)
}
// LayChanges is the placements of the nodes the changes name with the changes laid over current, in the order given;
// current is every node's placement now (absent: the node has no key). It returns, by node, the laid placement, and
// every finding: a change that does not fit its node (a module taken off that is not placed, configured or holding
// there; a field NodeDecided does not have) or a placement DecodePlacement refuses. What CheckPlacements refuses
// across nodes is the caller's to ask of the result laid over current.
func LayChanges(current map[string]Placement, changes []PlacementProposalChange) (map[string]Placement, []string) {
laid := map[string]Placement{}
var findings []string
of := func(node string) Placement {
if p, ok := laid[node]; ok {
return p
}
p, ok := current[node]
if !ok {
return Placement{V: Version, Node: node, Modules: []PlacedModule{}}
}
return clonePlacement(p)
}
for i, c := range changes {
if err := c.check(); err != nil {
findings = append(findings, fmt.Sprintf("change %d: %v", i+1, err))
continue
}
p := of(c.Node)
at := -1
for j, m := range p.Modules {
if m.Module == c.Module {
at = j
}
}
notPlaced := func() {
findings = append(findings, fmt.Sprintf("change %d: %s is not placed on %s", i+1, c.Module, c.Node))
}
switch c.Op {
case OpSeed:
p = clonePlacement(*c.Whole)
case OpPlace:
m := PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings}
if at >= 0 {
m.MeshWide, m.HasMesh, m.Layer, m.HasLayer = p.Modules[at].MeshWide, p.Modules[at].HasMesh, p.Modules[at].Layer, p.Modules[at].HasLayer
m.MeshValues, m.Values = p.Modules[at].MeshValues, p.Modules[at].Values
p.Modules[at] = m
} else {
p.Modules = append(p.Modules, m)
}
case OpTakeOff:
if at < 0 {
notPlaced()
continue
}
p.Modules = append(p.Modules[:at:at], p.Modules[at+1:]...)
var holds []Holding
for _, h := range p.Holds {
if h.Module != c.Module {
holds = append(holds, h)
}
}
p.Holds = holds
case OpConfig:
if at < 0 {
notPlaced()
continue
}
m := &p.Modules[at]
if c.MeshWide != nil {
m.MeshWide, m.HasMesh = c.MeshWide, true
}
if c.Layer != nil {
m.Layer, m.HasLayer = c.Layer, true
}
if c.MeshValues != nil {
m.MeshValues = c.MeshValues
}
if c.Values != nil {
m.Values = c.Values
}
case OpPin:
if p.Pins == nil {
p.Pins = map[string]Provider{}
}
p.Pins[c.Provision] = *c.Provider
case OpUnpin:
delete(p.Pins, c.Provision)
if len(p.Pins) == 0 {
p.Pins = nil
}
case OpHold:
if at < 0 {
notPlaced()
continue
}
p.Holds = append(p.Holds, Holding{Claim: c.Claim, Scope: c.Scope, Module: c.Module, Site: c.Site, Kind: c.Kind})
case OpUnhold:
kept := p.Holds[:0:0]
for _, h := range p.Holds {
if h.Module != c.Module || h.Claim != c.Claim {
kept = append(kept, h)
}
}
if len(kept) == len(p.Holds) {
findings = append(findings, fmt.Sprintf("change %d: %s holds no %s on %s", i+1, c.Module, c.Claim, c.Node))
continue
}
p.Holds = kept
case OpDecide:
d, err := decide(p.Decided, c.Field, c.Value)
if err != nil {
findings = append(findings, fmt.Sprintf("change %d: %v", i+1, err))
continue
}
p.Decided = d
}
laid[c.Node] = p
}
for _, node := range sortedKeys(laid) {
if err := laid[node].check(); err != nil {
findings = append(findings, err.Error())
}
}
return laid, findings
}
// decide is d with the field named by its JSON name set to value, refused when NodeDecided has no such field or the
// value does not fit it.
func decide(d NodeDecided, field string, value json.RawMessage) (NodeDecided, error) {
raw, _ := json.Marshal(d)
var fields map[string]json.RawMessage
_ = json.Unmarshal(raw, &fields)
if fields == nil {
fields = map[string]json.RawMessage{}
}
if !decidedFields[field] {
return d, fmt.Errorf("a node has no decided field %q", field)
}
fields[field] = value
raw, _ = json.Marshal(fields)
var out NodeDecided
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
if err := dec.Decode(&out); err != nil {
return d, fmt.Errorf("the decided field %q does not take %s: %v", field, value, err)
}
return out, nil
}
// decidedFields are NodeDecided's fields by their JSON names.
var decidedFields = func() map[string]bool {
raw, _ := json.Marshal(NodeDecided{Endpoint: "x", Site: "x", Hub: true, Address: "x", PublicDomain: "x", Account: "x",
AccountHome: "x", AgentAccount: "x", AgentHome: "x", Adopted: true, Taken: []string{"x"}, Tunnel: &RecordTunnel{},
PeerNames: map[string]string{"x": "x"}})
var m map[string]any
_ = json.Unmarshal(raw, &m)
out := map[string]bool{}
for k := range m {
out[k] = true
}
return out
}()
func clonePlacement(p Placement) Placement {
raw, _ := json.Marshal(p)
var out Placement
_ = json.Unmarshal(raw, &out)
if out.Modules == nil {
out.Modules = []PlacedModule{}
}
return out
}
var proposalIDOK = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,62}$`)
// CheckProposalID refuses an id that is no proposal's: lower-case letters, digits and dashes, 3 to 63 of them, so it is
// one key token.
func CheckProposalID(id string) error {
if !proposalIDOK.MatchString(id) {
return fmt.Errorf("delivery: %q is no proposal id (lower-case letters, digits and dashes, one key token)", id)
}
return nil
}
// NewProposalID is a fresh proposal id: its time in UTC and six random hex digits, `p-20261028t183000-1a2b3c`.
func NewProposalID(at time.Time) string {
var b [3]byte
_, _ = rand.Read(b[:])
return "p-" + strings.ToLower(at.UTC().Format("20060102T150405")) + "-" + hex.EncodeToString(b[:])
}
// PlacementVerdict is the holder's judgement of a proposal's answers (ADR 0567 §4). It fails only on a refusal by a
// node that must answer or on its silence past the bound (and, for the seed alone, on any difference); every difference
// is shown by node: Intended is what the holder computed the proposal changes, Unintended what a node answered beside
// it, marked as such in the brief.
type PlacementVerdict struct {
State string `json:"state"`
At time.Time `json:"at"`
Fails []string `json:"fails,omitempty"`
Silent []string `json:"silent,omitempty"`
Until time.Time `json:"until,omitempty"`
Intended map[string][]string `json:"intended,omitempty"`
Unintended map[string][]string `json:"unintended,omitempty"`
Answers map[string]CandidateAnswer `json:"answers,omitempty"`
Notes []string `json:"notes,omitempty"`
}
// PlacementProposal is an open proposal, the value of its key in PlacementProposalsBucket.
type PlacementProposal struct {
V int `json:"v"`
ID string `json:"id"`
By string `json:"by"`
Why string `json:"why"`
// At is when it was proposed, Written when its newest candidate was written (the answers' bound runs from it).
At time.Time `json:"at"`
Written time.Time `json:"written,omitempty"`
// Changes are as proposed; Seed says they are the take-over (adopt, §7), ExportDigest the digest of the export it
// proposed (ExportDigest), checked again at the warrant.
Changes []PlacementProposalChange `json:"changes"`
Seed bool `json:"seed,omitempty"`
ExportDigest string `json:"export-digest,omitempty"`
// Base is, by node whose key it changes, the revision of the key it was laid over (0: the node had none).
Base map[string]uint64 `json:"base"`
// Must is every node it must hear from (§3): each whose key it changes and each whose composition it changes
// through another's, sorted; set by the holder once it composed, before then the nodes of Base.
Must []string `json:"must,omitempty"`
// State is ProposalAnswering, ProposalFailed or ProposalAsked; Verdict is the latest judgement, Ask the authorising
// ask raised on a passing one.
State string `json:"state"`
Verdict *PlacementVerdict `json:"verdict,omitempty"`
Ask string `json:"ask,omitempty"`
// Words is what happened last, in words (laid again over moved keys, asked again).
Words string `json:"words,omitempty"`
}
// Nodes is every node whose key the proposal changes, sorted.
func (p PlacementProposal) Nodes() []string { return sortedKeys(p.Base) }
func (p PlacementProposal) check() error {
if err := CheckProposalID(p.ID); err != nil {
return err
}
if strings.TrimSpace(p.By) == "" || strings.TrimSpace(p.Why) == "" {
return fmt.Errorf("delivery: the proposal %s names no proposer or no why", p.ID)
}
if len(p.Changes) == 0 || len(p.Base) == 0 {
return fmt.Errorf("delivery: the proposal %s changes nothing", p.ID)
}
for i, c := range p.Changes {
if err := c.check(); err != nil {
return fmt.Errorf("delivery: the proposal %s, change %d: %w", p.ID, i+1, err)
}
if (c.Op == OpSeed) != p.Seed {
return fmt.Errorf("delivery: the proposal %s mixes the seed with other changes", p.ID)
}
if _, ok := p.Base[c.Node]; !ok {
return fmt.Errorf("delivery: the proposal %s changes %s and names no base revision of its key", p.ID, c.Node)
}
}
switch p.State {
case ProposalAnswering, ProposalFailed:
case ProposalAsked:
if p.Ask == "" || p.Verdict == nil || p.Verdict.State != VerdictPass {
return fmt.Errorf("delivery: the proposal %s is asked with no ask or no passing verdict", p.ID)
}
default:
return fmt.Errorf("delivery: the proposal %s is in no state %q", p.ID, p.State)
}
if p.Seed && p.ExportDigest == "" {
return fmt.Errorf("delivery: the seed %s names no export digest", p.ID)
}
return nil
}
// EncodePlacementProposal is p as JSON at Version; it refuses what DecodePlacementProposal refuses.
func EncodePlacementProposal(p PlacementProposal) ([]byte, error) {
if err := p.check(); err != nil {
return nil, err
}
p.V = Version
return json.Marshal(p)
}
// DecodePlacementProposal reads a proposal; it refuses an unknown version, an id that is none, no proposer or why, no
// change, a change it does not take or of a node with no base revision, the seed mixed with other changes or without its
// export digest, and a state it does not know.
func DecodePlacementProposal(data []byte) (PlacementProposal, error) {
var p PlacementProposal
if err := decode(data, &p); err != nil {
return PlacementProposal{}, err
}
if err := p.check(); err != nil {
return PlacementProposal{}, err
}
return p, nil
}
// ExportDigest is the digest of an export's answer, every node's placement, as the seed proposes it: the same
// placements give the same digest, whatever order they came in.
func ExportDigest(placements map[string]Placement) string {
h := sha256.New()
for _, node := range sortedKeys(placements) {
raw, _ := EncodePlacement(placements[node])
fmt.Fprintf(h, "%s\n%s\n", node, raw)
}
return "sha256:" + hex.EncodeToString(h.Sum(nil))
}
// ChangesDigest is the digest of a proposal's changes, which its authorising ask is bound to (§5).
func ChangesDigest(p PlacementProposal) string {
raw, _ := json.Marshal(struct {
ID string `json:"id"`
Changes []PlacementProposalChange `json:"changes"`
Base map[string]uint64 `json:"base"`
}{p.ID, p.Changes, p.Base})
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:])
}
// PlacementWarrant is who approved a proposal, through which channel and when (§5, §6), and the ask it answered.
type PlacementWarrant struct {
Ask string `json:"ask"`
Who string `json:"who"`
Channel string `json:"channel"`
At time.Time `json:"at"`
}
// PlacementEvent is what each of the seat's events carries (§6): the proposal whole (its id, by, why, changes, base
// revisions and verdict with every node's answer); for placement-changed, by node the revisions before and after and
// the modules added, removed or changed, and the warrant; for placement-withdrawn, why it ended.
type PlacementEvent struct {
Proposal PlacementProposal `json:"proposal"`
Nodes []PlacementChange `json:"nodes,omitempty"`
Warrant *PlacementWarrant `json:"warrant,omitempty"`
Ended string `json:"ended,omitempty"`
}
// SortChanges orders a placement-changed event's nodes by node.
func SortChanges(c []PlacementChange) {
sort.Slice(c, func(i, j int) bool { return c[i].Node < c[j].Node })
}
+142 -46
View File
@@ -9,6 +9,7 @@ import (
"slices" "slices"
"strings" "strings"
"testing" "testing"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/catalogue"
) )
@@ -32,15 +33,18 @@ func TestPlacement(t *testing.T) {
if _, err := PlacementKey("a.b"); err == nil { if _, err := PlacementKey("a.b"); err == nil {
t.Error("PlacementKey took a dotted node") t.Error("PlacementKey took a dotted node")
} }
if PlacementBucket != "mesh-delivery-indexer_placement" || PlacementFile("ace") != "placement/ace.json" { // ADR 0567 §1: the seat's three states are named after the holding module, and every reader takes the names here.
t.Fatalf("bucket %q, file %q", PlacementBucket, PlacementFile("ace")) for got, want := range map[string]string{PlacementBucket: "mesh-delivery-assigner_placement",
} PlacementProposalsBucket: "mesh-delivery-assigner_proposals", PlacementCandidatesBucket: "mesh-delivery-assigner_candidates",
for p, want := range map[string]string{"placement/ace.json": "ace", "./placement/ace.json": "ace", PlacementRead: "mesh-delivery-assigner.placement", PlacementCandidatesRead: "mesh-delivery-assigner.candidates",
"placement/x/ace.json": "", "modules/ace.json": "", "placement/ace.yaml": ""} { PlacementProposalsRead: "mesh-delivery-assigner.proposals"} {
if n, ok := PlacementFileNode(p); n != want || ok != (want != "") { if got != want {
t.Errorf("PlacementFileNode(%q) = %q, %v", p, n, ok) t.Errorf("a bucket is named %q, want %q", got, want)
} }
} }
if PlacementHistory != 64 || PlacementStreamDays != 400 {
t.Errorf("history %d, stream %d days", PlacementHistory, PlacementStreamDays)
}
}) })
g14 := Placement{Node: "g14", Revision: "abc", g14 := Placement{Node: "g14", Revision: "abc",
@@ -129,10 +133,9 @@ func TestPlacement(t *testing.T) {
t.Run("what a change says", func(t *testing.T) { t.Run("what a change says", func(t *testing.T) {
after := g14 after := g14
after.Revision = "def"
after.Modules = []PlacedModule{{Module: "app", Ports: map[string]int{"8080": 18081}}, {Module: "new"}} after.Modules = []PlacedModule{{Module: "app", Ports: map[string]int{"8080": 18081}}, {Module: "new"}}
got := ChangeOf(g14, after) got := ChangeOf(g14, after)
want := PlacementChange{Node: "g14", Revision: "def", Added: []string{"new"}, Removed: []string{"zsh"}, Changed: []string{"app"}} want := PlacementChange{Node: "g14", Added: []string{"new"}, Removed: []string{"zsh"}, Changed: []string{"app"}}
if !reflect.DeepEqual(got, want) { if !reflect.DeepEqual(got, want) {
t.Errorf("ChangeOf = %+v, want %+v", got, want) t.Errorf("ChangeOf = %+v, want %+v", got, want)
} }
@@ -171,7 +174,7 @@ func TestPlacement(t *testing.T) {
twice.Decided.Address = "10.77.0.2" twice.Decided.Address = "10.77.0.2"
r := CheckPlacements(map[string]Placement{"g14": clash, "novox": novox, "stranger": twice}, held, enrolled) 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", 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", "bound to ace/postgres", "pin of shell names g14/bash", "stranger is a node with no record",
"mesh-store, held once for the mesh, is held on novox and stranger", "mesh-store, held once for the mesh, is held on novox and stranger",
"g14 and stranger both have the address 10.77.0.2", "novox, stranger are each placed as it"} { "g14 and stranger both have the address 10.77.0.2", "novox, stranger are each placed as it"} {
found := false found := false
@@ -184,58 +187,56 @@ func TestPlacement(t *testing.T) {
} }
}) })
// Issue 1126: a catalogue pull request's placement, one candidate key per node, and each node's answer in its have. // ADR 0567 §3: a proposal's candidates, one key `<proposal>.<node>` per node it must hear from, and each node's answer
// in its have.
t.Run("candidates", func(t *testing.T) { t.Run("candidates", func(t *testing.T) {
key, err := PlacementCandidateKey("feat/1.2", "g14") const id = "p-20261028t183000-1a2b3c"
if err != nil || key != "feat/1=2e2.g14" { key, err := PlacementCandidateKey(id, "g14")
if err != nil || key != id+".g14" {
t.Fatalf("PlacementCandidateKey = %q, %v", key, err) t.Fatalf("PlacementCandidateKey = %q, %v", key, err)
} }
if g, n, err := ParsePlacementCandidateKey(key); err != nil || g != "feat/1.2" || n != "g14" { if p, n, err := ParsePlacementCandidateKey(key); err != nil || p != id || n != "g14" {
t.Fatalf("ParsePlacementCandidateKey = %q, %q, %v", g, n, err) t.Fatalf("ParsePlacementCandidateKey = %q, %q, %v", p, n, err)
} }
for _, bad := range []string{"g14", ".g14", "feat.g.14", "feat.", "fe=t.g14"} { for _, bad := range []string{"g14", ".g14", "p-x.g.14", "p-x.", "P-X.g14", "feat/1.g14"} {
if _, _, err := ParsePlacementCandidateKey(bad); err == nil { if _, _, err := ParsePlacementCandidateKey(bad); err == nil {
t.Errorf("ParsePlacementCandidateKey(%q) took it", bad) t.Errorf("ParsePlacementCandidateKey(%q) took it", bad)
} }
} }
if PlacementCandidatesBucket != "mesh-delivery-indexer_placement-candidates" {
t.Errorf("bucket %q", PlacementCandidatesBucket)
}
pull := Head{Repository: CatalogueRepository, Number: 7, Head: "h1"}
for name, c := range map[string]PlacementCandidate{ for name, c := range map[string]PlacementCandidate{
"placed": {Group: "feat/x", Pull: pull, Placement: &g14}, "placed": {Proposal: id, Placement: &g14},
"removed": {Group: "feat/x", Pull: pull}, "removed": {Proposal: id},
"reached": {Proposal: id, Reached: true, Placement: &g14},
} { } {
raw, err := EncodePlacementCandidate(c) raw, err := EncodePlacementCandidate(c)
if err != nil { if err != nil {
t.Fatalf("%s: %v", name, err) t.Fatalf("%s: %v", name, err)
} }
back, err := DecodePlacementCandidate(raw) back, err := DecodePlacementCandidate(raw)
if err != nil || back.Group != c.Group || back.Pull != pull || (back.Placement == nil) != (c.Placement == nil) || if err != nil || back.Proposal != id || back.Reached != c.Reached || (back.Placement == nil) != (c.Placement == nil) ||
(c.Placement != nil && !SamePlacement(*back.Placement, g14)) { (c.Placement != nil && !SamePlacement(*back.Placement, g14)) {
t.Errorf("%s: round trip %+v, %v", name, back, err) t.Errorf("%s: round trip %+v, %v", name, back, err)
} }
} }
for name, c := range map[string]PlacementCandidate{ for name, c := range map[string]PlacementCandidate{
"no group": {Pull: pull}, "no proposal": {},
"no head": {Group: "g", Pull: Head{Repository: CatalogueRepository, Number: 7}}, "a group": {Proposal: "feat/x"},
"bad place": {Group: "g", Pull: pull, Placement: &Placement{Node: "a.b"}}, "bad place": {Proposal: id, Placement: &Placement{Node: "a.b"}},
"no repo form": {Group: "g", Pull: Head{Repository: "mesh-catalog", Number: 7, Head: "h"}},
} { } {
if _, err := EncodePlacementCandidate(c); err == nil { if _, err := EncodePlacementCandidate(c); err == nil {
t.Errorf("%s: taken", name) t.Errorf("%s: taken", name)
} }
} }
answers := map[string]CandidateAnswer{ answers := map[string]CandidateAnswer{
"same": {Head: "h1", Revision: 3, Verdict: CandidateSame, Same: []string{"app", "node"}}, "same": {Proposal: id, Revision: 3, Verdict: CandidateSame, Same: []string{"app", "node"}},
"differs": {Head: "h1", Revision: 3, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}}, "differs": {Proposal: id, Revision: 3, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}},
"refused": {Head: "h1", Revision: 3, Verdict: CandidateRefused, Refused: "unbound postgres"}, "refused": {Proposal: id, Revision: 3, Verdict: CandidateRefused, Refused: "unbound postgres"},
"added": {Head: "h1", Verdict: CandidateDiffers, Same: []string{"node"}, Added: []ModuleAdded{{Module: "app", Unissued: []string{"app/token"}}}, Removed: []string{"old"}}, "added": {Proposal: id, Revision: 1, Verdict: CandidateDiffers, Same: []string{"node"}, Added: []ModuleAdded{{Module: "app", Unissued: []string{"app/token"}}}, Removed: []string{"old"}},
"a value": {Head: "h1", Verdict: CandidateRefused, Same: []string{"node"}, Refusals: []ModuleRefused{{Module: "db", Why: "stands in for db/password"}}}, "a value": {Proposal: id, Revision: 1, Verdict: CandidateRefused, Same: []string{"node"}, Refusals: []ModuleRefused{{Module: "db", Why: "stands in for db/password"}}},
// A head that deletes the node's placement file: the node returns to the controller's compositions (issue 1126, // A head that deletes the node's placement file: the node returns to the controller's compositions (issue 1126,
// the seed's revert, mesh-catalog #526). // the seed's revert, mesh-catalog #526).
"returns": {Head: "h1", Verdict: CandidateSame, Returns: ReturnsToController, Same: []string{"app", "node"}}, "returns": {Proposal: id, Revision: 1, Verdict: CandidateSame, Returns: ReturnsToController, Same: []string{"app", "node"}},
"returns, the controller's differs": {Head: "h1", Verdict: CandidateDiffers, Returns: ReturnsToController, Same: []string{"node"}, "returns, the controller's differs": {Proposal: id, Revision: 1, Verdict: CandidateDiffers, Returns: ReturnsToController, Same: []string{"node"},
Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}, Removed: []string{"old"}}, Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}, Removed: []string{"old"}},
} }
n := HaveNode{Composed: "sha256:" + strings.Repeat("a", 64), Sealed: []string{"box"}, Candidates: answers, n := HaveNode{Composed: "sha256:" + strings.Repeat("a", 64), Sealed: []string{"box"}, Candidates: answers,
@@ -398,7 +399,7 @@ func TestPlacement(t *testing.T) {
t.Errorf("a difference past the cut: %+v", far.Differs) t.Errorf("a difference past the cut: %+v", far.Differs)
} }
// And an answer too large for the node's key is fitted: the texts go first, the verdict and the paths stay. // And an answer too large for the node's key is fitted: the texts go first, the verdict and the paths stay.
big := CandidateAnswer{Head: "h", Verdict: CandidateDiffers, Differs: far.Differs} big := CandidateAnswer{Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Differs: far.Differs}
if fitted := FitAnswer(big, 4<<10); len(fitted.Differs[0].Bodies[0].Composed) != 0 || len(fitted.Differs[0].Bodies[0].Paths) != 2 || if fitted := FitAnswer(big, 4<<10); len(fitted.Differs[0].Bodies[0].Composed) != 0 || len(fitted.Differs[0].Bodies[0].Paths) != 2 ||
fitted.Verdict != CandidateDiffers || !strings.Contains(fitted.Note, "cut") || big.Differs[0].Bodies[0].Composed == "" { fitted.Verdict != CandidateDiffers || !strings.Contains(fitted.Note, "cut") || big.Differs[0].Bodies[0].Composed == "" {
t.Errorf("fitted as %+v", fitted) t.Errorf("fitted as %+v", fitted)
@@ -419,7 +420,7 @@ func TestPlacement(t *testing.T) {
[]Composed{comp(sealedFile("nats.needs-jwt", "box-2", "0600"), sealedFile("nats.needs-salt", "box-5", "0600"), []Composed{comp(sealedFile("nats.needs-jwt", "box-2", "0600"), sealedFile("nats.needs-salt", "box-5", "0600"),
sealedFile("nats.conf", "box-7", "0600"), sealedFile("other.needs-key", "box-9", "0600"))}, sealedFile("nats.conf", "box-7", "0600"), sealedFile("other.needs-key", "box-9", "0600"))},
func(a, b string) (same, known bool) { return a == "box-4", true }) func(a, b string) (same, known bool) { return a == "box-4", true })
got := CandidateAnswer{Head: "h", Differs: drift.Differs} got := CandidateAnswer{Proposal: "p-x1", Revision: 1, Differs: drift.Differs}
if own := OwnValuesDiffer(got); len(own) != 1 || own[0] != (OwnValueDiffers{Module: "nats", Name: "jwt"}) { if own := OwnValuesDiffer(got); len(own) != 1 || own[0] != (OwnValueDiffers{Module: "nats", Name: "jwt"}) {
t.Errorf("the own secrets with another value are %+v, want nats jwt alone", own) t.Errorf("the own secrets with another value are %+v, want nats jwt alone", own)
} }
@@ -443,16 +444,16 @@ func TestPlacement(t *testing.T) {
} }
for name, a := range map[string]CandidateAnswer{ for name, a := range map[string]CandidateAnswer{
"no head": {Verdict: CandidateSame}, "no head": {Verdict: CandidateSame},
"same with a diff": {Head: "h", Verdict: CandidateSame, Differs: answers["differs"].Differs}, "same with a diff": {Proposal: "p-x1", Revision: 1, Verdict: CandidateSame, Differs: answers["differs"].Differs},
"differs with none": {Head: "h", Verdict: CandidateDiffers}, "differs with none": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers},
"refused no reason": {Head: "h", Verdict: CandidateRefused}, "refused no reason": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused},
"unknown verdict": {Head: "h", Verdict: "maybe"}, "unknown verdict": {Proposal: "p-x1", Revision: 1, Verdict: "maybe"},
"diff with no module": {Head: "h", Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Why: "x"}}}, "diff with no module": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Why: "x"}}},
"same with an added": {Head: "h", Verdict: CandidateSame, Added: []ModuleAdded{{Module: "a"}}}, "same with an added": {Proposal: "p-x1", Revision: 1, Verdict: CandidateSame, Added: []ModuleAdded{{Module: "a"}}},
"differs refusing": {Head: "h", Verdict: CandidateDiffers, Removed: []string{"a"}, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}}, "differs refusing": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Removed: []string{"a"}, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
"refused and modules": {Head: "h", Verdict: CandidateRefused, Refused: "x", Same: []string{"a"}}, "refused and modules": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Refused: "x", Same: []string{"a"}},
"returns refusing": {Head: "h", Verdict: CandidateRefused, Returns: ReturnsToController, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}}, "returns refusing": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToController, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
"returns refused": {Head: "h", Verdict: CandidateRefused, Returns: ReturnsToController, Refused: "x"}, "returns refused": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToController, Refused: "x"},
} { } {
if _, err := EncodeHaveNode(HaveNode{Candidates: map[string]CandidateAnswer{"g": a}}); err == nil { if _, err := EncodeHaveNode(HaveNode{Candidates: map[string]CandidateAnswer{"g": a}}); err == nil {
t.Errorf("%s: taken", name) t.Errorf("%s: taken", name)
@@ -460,4 +461,99 @@ func TestPlacement(t *testing.T) {
} }
}) })
// ADR 0567 §2, §8: a proposal's changes laid over the current keys, what waits for the lease write, and the proposal's
// encoding and refusals.
t.Run("proposal", func(t *testing.T) {
current := map[string]Placement{"g14": g14, "novox": novox}
laid, findings := LayChanges(current, []PlacementProposalChange{
{Op: OpPlace, Node: "g14", Module: "new"},
{Op: OpTakeOff, Node: "g14", Module: "zsh"},
{Op: OpUnpin, Node: "g14", Provision: "shell"},
{Op: OpDecide, Node: "novox", Field: "site", Value: json.RawMessage(`"attic"`)},
})
if len(findings) != 0 || len(laid) != 2 || !reflect.DeepEqual(laid["g14"].Assigned(), []string{"app", "new"}) ||
len(laid["g14"].Holds) != 0 || laid["g14"].Pins != nil || laid["novox"].Decided.Site != "attic" || !laid["novox"].Decided.Hub {
t.Fatalf("laid %+v, %v", laid, findings)
}
if !reflect.DeepEqual(current["g14"].Assigned(), []string{"zsh", "app"}) {
t.Error("laying changed the current placement")
}
if fresh, f := LayChanges(current, []PlacementProposalChange{{Op: OpPlace, Node: "pi", Module: "zsh"}}); len(f) != 0 || fresh["pi"].Node != "pi" {
t.Errorf("a node with no key: %+v, %v", fresh, f)
}
for name, c := range map[string]struct {
change PlacementProposalChange
want string
}{
"not placed": {PlacementProposalChange{Op: OpTakeOff, Node: "g14", Module: "nope"}, "not placed"},
"no such field": {PlacementProposalChange{Op: OpDecide, Node: "g14", Field: "colour", Value: json.RawMessage(`"x"`)}, "no decided field"},
"wrong type": {PlacementProposalChange{Op: OpDecide, Node: "g14", Field: "hub", Value: json.RawMessage(`"x"`)}, "does not take"},
"unknown op": {PlacementProposalChange{Op: "move", Node: "g14"}, "no change of placement"},
"pin of nobody": {PlacementProposalChange{Op: OpPin, Node: "g14", Provision: "x"}, "provider"},
"not held": {PlacementProposalChange{Op: OpUnhold, Node: "g14", Module: "app", Claim: "x"}, "holds no"},
"bad port": {PlacementProposalChange{Op: OpPlace, Node: "g14", Module: "x", Ports: map[string]int{"a": 1}}, "port"},
"seed of another": {PlacementProposalChange{Op: OpSeed, Node: "g14", Whole: &novox}, "seed"},
} {
if _, f := LayChanges(current, []PlacementProposalChange{c.change}); len(f) != 1 || !strings.Contains(f[0], c.want) {
t.Errorf("%s: %v, want a finding naming %q", name, f, c.want)
}
}
for change, waits := range map[*PlacementProposalChange]bool{
{Op: OpPlace, Node: "g14", Module: "x"}: false, {Op: OpTakeOff, Node: "g14", Module: "x"}: false,
{Op: OpPin, Node: "g14"}: false, {Op: OpUnpin, Node: "g14"}: false, {Op: OpSeed, Node: "g14"}: false,
{Op: OpPlace, Node: "g14", Module: "x", Ports: map[string]int{"80": 80}}: true,
{Op: OpPlace, Node: "g14", Module: "x", Bindings: map[string]Provider{"p": {}}}: true,
{Op: OpHold, Node: "g14"}: true, {Op: OpDecide, Node: "g14"}: true, {Op: OpConfig, Node: "g14"}: true,
} {
if got := change.BeforeLease(); (got != "") != waits || (waits && !strings.Contains(got, "lease write")) {
t.Errorf("%+v before the lease write: %q", *change, got)
}
}
const id = "p-20261028t183000-1a2b3c"
if got := NewProposalID(time.Date(2026, 10, 28, 18, 30, 0, 0, time.UTC)); !strings.HasPrefix(got, "p-20261028t183000-") || CheckProposalID(got) != nil {
t.Errorf("NewProposalID = %q", got)
}
p := PlacementProposal{ID: id, By: "operator@anchor", Why: "move the app", At: time.Unix(1, 0).UTC(),
Changes: []PlacementProposalChange{{Op: OpTakeOff, Node: "g14", Module: "zsh"}}, Base: map[string]uint64{"g14": 4},
State: ProposalAnswering}
raw, err := EncodePlacementProposal(p)
if err != nil {
t.Fatal(err)
}
if back, err := DecodePlacementProposal(raw); err != nil || back.ID != id || back.Base["g14"] != 4 || !reflect.DeepEqual(back.Nodes(), []string{"g14"}) {
t.Fatalf("round trip %+v, %v", back, err)
}
seed := p
seed.Seed, seed.ExportDigest = true, ExportDigest(current)
seed.Changes = []PlacementProposalChange{{Op: OpSeed, Node: "g14", Whole: &g14}}
if _, err := EncodePlacementProposal(seed); err != nil {
t.Errorf("the seed: %v", err)
}
if ExportDigest(current) != ExportDigest(map[string]Placement{"novox": novox, "g14": g14}) || ExportDigest(current) == ExportDigest(nil) {
t.Error("the export digest depends on order or not on the placements")
}
if ChangesDigest(p) == ChangesDigest(seed) {
t.Error("two proposals' changes digest alike")
}
for name, mut := range map[string]func(*PlacementProposal){
"no id": func(p *PlacementProposal) { p.ID = "" },
"no proposer": func(p *PlacementProposal) { p.By = "" },
"no why": func(p *PlacementProposal) { p.Why = " " },
"no change": func(p *PlacementProposal) { p.Changes = nil },
"no base": func(p *PlacementProposal) { p.Base = map[string]uint64{"novox": 1} },
"no state": func(p *PlacementProposal) { p.State = "" },
"asked, no ask": func(p *PlacementProposal) { p.State = ProposalAsked },
"seed mixed": func(p *PlacementProposal) { p.Seed, p.ExportDigest = true, "d" },
"bad change": func(p *PlacementProposal) { p.Changes[0].Op = "swap" },
"seed, no digest": func(p *PlacementProposal) { *p = seed; p.ExportDigest = "" },
} {
q := p
q.Changes = append([]PlacementProposalChange(nil), p.Changes...)
mut(&q)
if _, err := EncodePlacementProposal(q); err == nil {
t.Errorf("%s: taken", name)
}
}
})
} }