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"
)
// 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
// 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
// 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
// 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 {
a := delivery.CandidateAnswer{Head: head, Revision: revision}
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{Proposal: proposal, Revision: revision}
refuse := func(err error) delivery.CandidateAnswer {
a.Refused = err.Error()
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) })
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{}
for node, p := range s.Placements {
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.
b := mk("lemurs", "web", "api", "spiegel")
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
for _, r := range a.Refusals {
refused = append(refused, r.Module)
@@ -141,14 +141,14 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
if string(gotOwn) != string(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 {
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
// 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})
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)
}
// (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)
}
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)
}
// 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"
b.placements["n1"] = p
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
for _, r := range a.Refusals {
refused = append(refused, r.Module)
@@ -469,7 +469,7 @@ func otherNodeNotResolvedIsNamed(t *testing.T) {
p := 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") {
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
}{
"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,
func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
"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" {
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) {
t.Errorf("%s: %+v", name, a)
}
+2 -2
View File
@@ -413,11 +413,11 @@ type HaveNode struct {
Sealed []string `json:"sealed,omitempty"`
// SealedAt is Sealed by slot (Have.SealedAt).
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
// candidate.
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
// 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.
+49 -53
View File
@@ -3,7 +3,6 @@ package delivery
import (
"encoding/json"
"fmt"
"path"
"sort"
"strconv"
"strings"
@@ -11,29 +10,38 @@ import (
"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.
// Placement of modules on nodes is the state of the seat mesh-placement (novox/hq ADR 0567), held once for the mesh by
// the assigner: one key `placement.<node>` per node in its state `placement`, written only on the operator's Approve of a
// proposal the nodes it touches answered (§2-§5), by compare-and-set at the revision the proposal was laid over. No
// repository holds it. The node agents, the grants part, mesh-config and the status view read it (§9); nothing in it is
// 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 (
// 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"
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"
PlacementBucket = AssignerModule + "_" + PlacementState
PlacementRead = AssignerModule + "." + PlacementState
// PlacementHistory is the revisions the placement bucket keeps per key: the bus's most (§6).
PlacementHistory = 64
// PlacementStream is the holder's stream of the seat's events, kept PlacementStreamDays (§6), and declared as the
// 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"
// 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>`.
func PlacementKey(node string) (string, error) {
if err := checkName("node", node); err != nil {
@@ -51,20 +59,6 @@ func ParsePlacementKey(key string) (node string, err error) {
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"`
@@ -109,8 +103,8 @@ type Holding struct {
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
// Placement is one node's placement, the value of its key. Modules are in the order the composition reads the node's
// 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.
type Placement struct {
V int `json:"v"`
@@ -122,13 +116,13 @@ type Placement struct {
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
// NodeDecided is what a person decides of the node, kept in its placement key (issue 1126's K2: what a person decides
// 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
// 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.
// to that tunnel's peers. The address is a decision written once, at enrolment, by an
// 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.
type NodeDecided struct {
Endpoint string `json:"endpoint,omitempty"`
@@ -211,8 +205,8 @@ func DecodePlacement(data []byte) (Placement, error) {
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).
// SamePlacement says whether two placements are the same value, so the holder writes only when they differ. The
// revision is not compared.
func SamePlacement(a, b Placement) bool {
a.Revision, b.Revision = "", ""
ja, errA := EncodePlacement(a)
@@ -220,21 +214,23 @@ func SamePlacement(a, b Placement) bool {
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.
// PlacementChange is what `placement-changed` says of one node (ADR 0567 §6): the node, the revision of its key before
// (0: it had none) and after (0: the key was deleted), and its modules added, removed or changed against the placement
// before.
type 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"`
Node string `json:"node"`
Before uint64 `json:"before"`
After uint64 `json:"after"`
Added []string `json:"added,omitempty"`
Removed []string `json:"removed,omitempty"`
Changed []string `json:"changed,omitempty"`
}
// 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}
c := PlacementChange{Node: after.Node}
if c.Node == "" {
c.Node = before.Node
}
@@ -368,8 +364,8 @@ func (p Placement) Held() []catalogue.Held {
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
// CheckPlacements is the holder's check of a proposal laid over every placement (ADR 0567 §2), over the placements by
// 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
// 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;
@@ -401,7 +397,7 @@ func CheckPlacements(placements map[string]Placement, held func(module string) b
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))
refusals = append(refusals, fmt.Sprintf("the placement of %s names the node %q", node, p.Node))
}
if a := strings.TrimSpace(p.Decided.Address); a != "" {
if other, twice := addresses[a]; twice {
@@ -413,12 +409,12 @@ func CheckPlacements(placements map[string]Placement, held func(module string) b
hubs = append(hubs, 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{}
for _, m := range p.Modules {
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) {
at := m.Ports[port]
+43 -53
View File
@@ -6,70 +6,62 @@ import (
"strings"
)
// Placement candidates (issue 1126; ADR 0549 §4: a placement change passes the composition check before any node sees
// it). A catalogue pull request that changes a placement file carries every placement file at its head on its pull
// message (Forge.Placements). The indexer writes, for each node whose file at the head differs from the trunk's, the
// key `<group>.<node>` of its state `placement-candidates`, and deletes it when the pull request merges or closes. It is
// a state of its own, since the readers of `placement` refuse any key that is not `placement.<node>`.
// Placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): for each node a proposal must hear from, the seat's holder
// writes one key `<proposal>.<node>` in its state `candidates`: the node's placement with the proposal laid over it for a
// node whose key it changes, or the node's current key for one whose composition it changes only through another node
// (Reached: a binding, a firewall or a resolver entry reaching it). It deletes them when the proposal ends. A state of its
// 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
// compares the result with what it runs; it applies nothing. Its answer rides in its own have key, `<node>.node`
// (HaveNode.Candidates, by group), which that node alone writes; the assigner's check of the group reads every placed
// node's answer at the head and fails on any difference, refusal or silence. Event-driven both ways: the candidate key's
// write is the node's event, the `<node>.node` change the check's.
// The node watches `*.<node>`, composes in shadow with every candidate of its proposal laid over the current placement
// (its own sealed values, the boxes it already runs) and compares the result with what it runs; it applies nothing. Its
// answer rides in its own have key, `<node>.node` (HaveNode.Candidates, by proposal), which that node alone writes; the
// holder reads it and judges (ADR 0567 §4). Event-driven both ways: the candidate key's write is the node's event, the
// `<node>.node` change the holder's.
const (
// PlacementCandidatesState is the indexer's state of placement candidates, and PlacementCandidatesBucket its bucket.
PlacementCandidatesState = "placement-candidates"
PlacementCandidatesBucket = IndexerModule + "_" + PlacementCandidatesState
// PlacementCandidatesState is the seat's state of placement candidates, PlacementCandidatesBucket its bucket and
// PlacementCandidatesRead the name a reader's manifest declares.
PlacementCandidatesState = "candidates"
PlacementCandidatesBucket = AssignerModule + "_" + PlacementCandidatesState
PlacementCandidatesRead = AssignerModule + "." + PlacementCandidatesState
// PlacementCandidatesHistory is the revisions the bucket keeps per key.
PlacementCandidatesHistory = 5
)
// PlacementCandidateKey is the key, in PlacementCandidatesBucket, of one node's candidate placement in one group:
// `<group>.<node>`, the group escaped as CandidatesKey escapes it, so a node watches `*.<node>` and a group's keys are
// `<group>.*`.
func PlacementCandidateKey(group, node string) (string, error) {
g, err := groupKey(group)
if err != nil {
// PlacementCandidateKey is the key, in PlacementCandidatesBucket, of one node's candidate in one proposal:
// `<proposal>.<node>`, so a node watches `*.<node>` and a proposal's keys are `<proposal>.*`.
func PlacementCandidateKey(proposal, node string) (string, error) {
if err := CheckProposalID(proposal); err != nil {
return "", err
}
if err := checkName("node", node); err != nil {
return "", err
}
return g + "." + node, nil
return proposal + "." + node, nil
}
// ParsePlacementCandidateKey is the inverse of PlacementCandidateKey; it refuses any other key, naming it.
func ParsePlacementCandidateKey(key string) (group, node string, err error) {
g, node, ok := strings.Cut(key, ".")
if !ok || checkName("node", node) != nil {
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key, <group>.<node>", key)
func ParsePlacementCandidateKey(key string) (proposal, node string, err error) {
proposal, node, ok := strings.Cut(key, ".")
if !ok || checkName("node", node) != nil || CheckProposalID(proposal) != nil {
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key, <proposal>.<node>", key)
}
if group, err = parseGroupKey(g); err != nil {
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key: %w", key, err)
}
return group, node, nil
return proposal, node, nil
}
// PlacementCandidate is the value of a candidate key: the group, the catalogue pull request and the head its placement
// files were read at, and the node's placement at that head; nil Placement says the head removes the node's file.
// PlacementCandidate is the value of a candidate key: the proposal it is of, and the node's placement with the proposal
// 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 {
V int `json:"v"`
Group string `json:"group"`
Pull Head `json:"pull"`
Proposal string `json:"proposal"`
Reached bool `json:"reached,omitempty"`
Placement *Placement `json:"placement,omitempty"`
}
func (c PlacementCandidate) check() error {
if c.Group == "" {
return fmt.Errorf("delivery: a placement candidate names no group")
}
if err := checkRepository(c.Pull.Repository); err != nil {
if err := CheckProposalID(c.Proposal); err != nil {
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 {
return c.Placement.check()
}
@@ -90,8 +82,8 @@ func EncodePlacementCandidate(c PlacementCandidate) ([]byte, error) {
return json.Marshal(c)
}
// DecodePlacementCandidate reads a candidate; it refuses an unknown version, no group, a pull request without number or
// head, and a placement DecodePlacement refuses.
// DecodePlacementCandidate reads a candidate; it refuses an unknown version, a proposal id that is not one, and a
// placement DecodePlacement refuses.
func DecodePlacementCandidate(data []byte) (PlacementCandidate, error) {
var c PlacementCandidate
if err := decode(data, &c); err != nil {
@@ -115,14 +107,13 @@ const (
CandidateRefused = "refused"
)
// CandidateAnswer is a node's answer to its group's candidate keys, in HaveNode.Candidates (ADR 0559 §4): the head
// 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
// 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
// CandidateAnswer is a node's answer to its proposal's candidate keys, in HaveNode.Candidates (ADR 0567 §3): the
// 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
// 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
// "node" (HaveNodeWord).
type CandidateAnswer struct {
Head string `json:"head"`
Proposal string `json:"proposal"`
Revision uint64 `json:"revision"`
Verdict string `json:"verdict"`
Same []string `json:"same,omitempty"`
@@ -132,9 +123,8 @@ type CandidateAnswer struct {
Refusals []ModuleRefused `json:"refusals,omitempty"`
// Refused is why the node could not compose with the candidate at all; Refusals is then empty.
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
// controller's compositions (ReturnsToController), as a node the catalogue does not place composes none of its own
// (issue 1126: the seed's revert). Same, Differs, Added and Removed then compare the controller's compositions at
// 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 with no placement key composes none of its own. 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
// refused (Refused, without Returns).
Returns string `json:"returns,omitempty"`
@@ -147,8 +137,8 @@ type CandidateAnswer struct {
ValuesDiffer int `json:"values_differ,omitempty"`
}
// ReturnsToController is what a node answers a head that deletes its placement file.
const ReturnsToController = "the head deletes this node's placement: it returns to the controller's compositions"
// ReturnsToController is what a node answers a proposal that takes its placement key away.
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:
// 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
// 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 (
MaxBodies = 20
MaxBody = 8192
@@ -215,8 +205,8 @@ func VerdictOf(a CandidateAnswer) string {
}
func (a CandidateAnswer) check() error {
if a.Head == "" {
return fmt.Errorf("delivery: a candidate answer names no head")
if CheckProposalID(a.Proposal) != nil || a.Revision == 0 {
return fmt.Errorf("delivery: a candidate answer names no proposal or no revision of its candidate")
}
switch a.Verdict {
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"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
@@ -32,15 +33,18 @@ func TestPlacement(t *testing.T) {
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)
// ADR 0567 §1: the seat's three states are named after the holding module, and every reader takes the names here.
for got, want := range map[string]string{PlacementBucket: "mesh-delivery-assigner_placement",
PlacementProposalsBucket: "mesh-delivery-assigner_proposals", PlacementCandidatesBucket: "mesh-delivery-assigner_candidates",
PlacementRead: "mesh-delivery-assigner.placement", PlacementCandidatesRead: "mesh-delivery-assigner.candidates",
PlacementProposalsRead: "mesh-delivery-assigner.proposals"} {
if got != want {
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",
@@ -129,10 +133,9 @@ func TestPlacement(t *testing.T) {
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"}}
want := PlacementChange{Node: "g14", Added: []string{"new"}, Removed: []string{"zsh"}, Changed: []string{"app"}}
if !reflect.DeepEqual(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"
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",
"bound to ace/postgres", "pin of shell names g14/bash", "stranger is a node with no record",
"mesh-store, held once for the mesh, is held on novox and stranger",
"g14 and stranger both have the address 10.77.0.2", "novox, stranger are each placed as it"} {
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) {
key, err := PlacementCandidateKey("feat/1.2", "g14")
if err != nil || key != "feat/1=2e2.g14" {
const id = "p-20261028t183000-1a2b3c"
key, err := PlacementCandidateKey(id, "g14")
if err != nil || key != id+".g14" {
t.Fatalf("PlacementCandidateKey = %q, %v", key, err)
}
if g, n, err := ParsePlacementCandidateKey(key); err != nil || g != "feat/1.2" || n != "g14" {
t.Fatalf("ParsePlacementCandidateKey = %q, %q, %v", g, n, err)
if p, n, err := ParsePlacementCandidateKey(key); err != nil || p != id || n != "g14" {
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 {
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{
"placed": {Group: "feat/x", Pull: pull, Placement: &g14},
"removed": {Group: "feat/x", Pull: pull},
"placed": {Proposal: id, Placement: &g14},
"removed": {Proposal: id},
"reached": {Proposal: id, Reached: true, Placement: &g14},
} {
raw, err := EncodePlacementCandidate(c)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
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)) {
t.Errorf("%s: round trip %+v, %v", name, back, err)
}
}
for name, c := range map[string]PlacementCandidate{
"no group": {Pull: pull},
"no head": {Group: "g", Pull: Head{Repository: CatalogueRepository, Number: 7}},
"bad place": {Group: "g", Pull: pull, Placement: &Placement{Node: "a.b"}},
"no repo form": {Group: "g", Pull: Head{Repository: "mesh-catalog", Number: 7, Head: "h"}},
"no proposal": {},
"a group": {Proposal: "feat/x"},
"bad place": {Proposal: id, Placement: &Placement{Node: "a.b"}},
} {
if _, err := EncodePlacementCandidate(c); err == nil {
t.Errorf("%s: taken", name)
}
}
answers := map[string]CandidateAnswer{
"same": {Head: "h1", 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"}}},
"refused": {Head: "h1", 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"}},
"a value": {Head: "h1", Verdict: CandidateRefused, Same: []string{"node"}, Refusals: []ModuleRefused{{Module: "db", Why: "stands in for db/password"}}},
"same": {Proposal: id, Revision: 3, Verdict: CandidateSame, Same: []string{"app", "node"}},
"differs": {Proposal: id, Revision: 3, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}},
"refused": {Proposal: id, Revision: 3, Verdict: CandidateRefused, Refused: "unbound postgres"},
"added": {Proposal: id, Revision: 1, Verdict: CandidateDiffers, Same: []string{"node"}, Added: []ModuleAdded{{Module: "app", Unissued: []string{"app/token"}}}, Removed: []string{"old"}},
"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,
// the seed's revert, mesh-catalog #526).
"returns": {Head: "h1", Verdict: CandidateSame, Returns: ReturnsToController, Same: []string{"app", "node"}},
"returns, the controller's differs": {Head: "h1", Verdict: CandidateDiffers, Returns: ReturnsToController, Same: []string{"node"},
"returns": {Proposal: id, Revision: 1, Verdict: CandidateSame, Returns: ReturnsToController, Same: []string{"app", "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"}},
}
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)
}
// 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 ||
fitted.Verdict != CandidateDiffers || !strings.Contains(fitted.Note, "cut") || big.Differs[0].Bodies[0].Composed == "" {
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"),
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 })
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"}) {
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{
"no head": {Verdict: CandidateSame},
"same with a diff": {Head: "h", Verdict: CandidateSame, Differs: answers["differs"].Differs},
"differs with none": {Head: "h", Verdict: CandidateDiffers},
"refused no reason": {Head: "h", Verdict: CandidateRefused},
"unknown verdict": {Head: "h", Verdict: "maybe"},
"diff with no module": {Head: "h", Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Why: "x"}}},
"same with an added": {Head: "h", Verdict: CandidateSame, Added: []ModuleAdded{{Module: "a"}}},
"differs refusing": {Head: "h", Verdict: CandidateDiffers, Removed: []string{"a"}, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
"refused and modules": {Head: "h", Verdict: CandidateRefused, Refused: "x", Same: []string{"a"}},
"returns refusing": {Head: "h", Verdict: CandidateRefused, Returns: ReturnsToController, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
"returns refused": {Head: "h", Verdict: CandidateRefused, Returns: ReturnsToController, Refused: "x"},
"same with a diff": {Proposal: "p-x1", Revision: 1, Verdict: CandidateSame, Differs: answers["differs"].Differs},
"differs with none": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers},
"refused no reason": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused},
"unknown verdict": {Proposal: "p-x1", Revision: 1, Verdict: "maybe"},
"diff with no module": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Why: "x"}}},
"same with an added": {Proposal: "p-x1", Revision: 1, Verdict: CandidateSame, Added: []ModuleAdded{{Module: "a"}}},
"differs refusing": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Removed: []string{"a"}, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
"refused and modules": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Refused: "x", Same: []string{"a"}},
"returns refusing": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToController, Refusals: []ModuleRefused{{Module: "b", Why: "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 {
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)
}
}
})
}