Let placement take an add-node with its enrolment's warrant, so one Approve lets a machine join
ADR 0587 §7: at redeem mesh-enrolment proposes one change, the node with its decided fields (the approved address, never the hub) and the public halves of its keys, carrying the issue's warrant; mesh-placement checks the warrant names this node and address and that the caller is mesh-enrolment, and writes the key with no ask of its own (changing ADR 0567 §2 and §5 for this one change by this one caller). The proposal codec refuses an add-node beside other changes, without a warrant, or whose warrant names another node or address, and a warrant on any other proposal; AddNodeRefusal adds the caller's check for the assigner; LayChanges adds the node and refuses one placed already. Tested as rows of TestPlacement's proposal subtest, no new test function. (issue 774, 774/S42) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -31,7 +31,8 @@ func placementVerbs() []Verb {
|
||||
"place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " +
|
||||
"unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " +
|
||||
"(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " +
|
||||
"layer, mesh-wide-values, values)",
|
||||
"layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the " +
|
||||
"proposal's one change, with the enrolment's warrant; ADR 0587 §7)",
|
||||
"items": map[string]any{"type": "object", "required": []string{"op", "node"}}}
|
||||
return []Verb{
|
||||
{Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " +
|
||||
@@ -52,11 +53,17 @@ func placementVerbs() []Verb {
|
||||
"on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " +
|
||||
"hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " +
|
||||
"it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " +
|
||||
"caller, as the runtime proves it.",
|
||||
"caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, " +
|
||||
"is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).",
|
||||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"},
|
||||
"properties": map[string]any{
|
||||
"why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"},
|
||||
"changes": changes,
|
||||
// ADR 0587 §7: an add-node carries the warrant of the enrolment that issued the node's token, whose
|
||||
// Approve is the proposal's; the holder writes it with no ask of its own.
|
||||
"warrant": map[string]any{"type": "object", "description": "for an add-node alone: the enrolment's " +
|
||||
"warrant {node, address, ask, warrant}, naming the change's node and address",
|
||||
"required": []string{"node", "address", "ask", "warrant"}},
|
||||
}}},
|
||||
{Name: PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " +
|
||||
"candidates are deleted and placement-withdrawn carries it whole. With why.",
|
||||
|
||||
@@ -65,6 +65,9 @@ const (
|
||||
OpDecide = "decide" // set a decided field of the node (NodeDecided by its JSON name), its address among them
|
||||
OpConfig = "config" // set a module's config placement or values on the node
|
||||
OpSeed = "seed" // the node's whole placement: the take-over (adopt, §7), never given by a caller
|
||||
// OpAddNode adds a joining node with its decided fields and the public halves of its keys, on the warrant of the
|
||||
// enrolment that issued its token (novox/hq ADR 0587 §7): mesh-enrolment's alone, the proposal's one change.
|
||||
OpAddNode = "add-node"
|
||||
)
|
||||
|
||||
// The states of an open proposal.
|
||||
@@ -107,6 +110,32 @@ type PlacementProposalChange struct {
|
||||
Values map[string]any `json:"values,omitempty"`
|
||||
// Whole is the node's whole placement (OpSeed).
|
||||
Whole *Placement `json:"whole,omitempty"`
|
||||
// Decided and Keys are a joining node's (OpAddNode): its decided fields, the address the operator approved among
|
||||
// them, and the public halves of the keys it presented at redeem.
|
||||
Decided *NodeDecided `json:"decided,omitempty"`
|
||||
Keys *JoiningKeys `json:"keys,omitempty"`
|
||||
}
|
||||
|
||||
// JoiningKeys are the public halves of the keys a joining node presented at redeem (ADR 0587 §6, §7): its identity,
|
||||
// private network, sealing and serving keys, as Redeem carries them. Public halves alone, never a private key.
|
||||
type JoiningKeys struct {
|
||||
Identity string `json:"identity"`
|
||||
Overlay string `json:"overlay,omitempty"`
|
||||
Sealing string `json:"sealing"`
|
||||
Serving string `json:"serving,omitempty"`
|
||||
}
|
||||
|
||||
// Digest is the keys' digest, as the enrolment's key holds it (KeysDigest).
|
||||
func (k JoiningKeys) Digest() string { return KeysDigest(k.Identity, k.Overlay, k.Sealing, k.Serving) }
|
||||
|
||||
// AddNodeWarrant is the warrant an add-node carries (ADR 0587 §7): the enrolment's node and address as its issue's ask
|
||||
// was bound to them, the ask's id, and the warrant's id as the teller wrote it. mesh-placement checks it names the
|
||||
// change's node and address, and that the caller is mesh-enrolment as the runtime proves it (AddNodeRefusal).
|
||||
type AddNodeWarrant struct {
|
||||
Node string `json:"node"`
|
||||
Address string `json:"address"`
|
||||
Ask string `json:"ask"`
|
||||
Warrant string `json:"warrant"`
|
||||
}
|
||||
|
||||
func (c PlacementProposalChange) check() error {
|
||||
@@ -135,8 +164,19 @@ func (c PlacementProposalChange) check() error {
|
||||
return fmt.Errorf("delivery: the seed of %s names no placement of that node", c.Node)
|
||||
}
|
||||
return c.Whole.check()
|
||||
case OpAddNode:
|
||||
if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Identity == "" || c.Keys.Sealing == "" {
|
||||
return fmt.Errorf("delivery: the add-node of %s names no address or no identity and sealing key", c.Node)
|
||||
}
|
||||
if c.Decided.Hub {
|
||||
return fmt.Errorf("delivery: the add-node of %s makes it the hub, which a joining node never is", c.Node)
|
||||
}
|
||||
if c.Module != "" || len(c.Ports)+len(c.Bindings) > 0 || c.Whole != nil {
|
||||
return fmt.Errorf("delivery: the add-node of %s places a module; a module is placed by a proposal of its own", c.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide or config", c.Op)
|
||||
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide, config or add-node", c.Op)
|
||||
}
|
||||
|
||||
// LayChanges is the placements of the nodes the changes name with the changes laid over current, in the order given;
|
||||
@@ -175,6 +215,13 @@ func LayChanges(current map[string]Placement, changes []PlacementProposalChange)
|
||||
switch c.Op {
|
||||
case OpSeed:
|
||||
p = clonePlacement(*c.Whole)
|
||||
case OpAddNode:
|
||||
// A joining node has no key yet (ADR 0587 §7): one that has is no joining node.
|
||||
if _, has := current[c.Node]; has {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s is placed already, so it does not join", i+1, c.Node))
|
||||
continue
|
||||
}
|
||||
p = Placement{V: Version, Node: c.Node, Decided: *c.Decided, Modules: []PlacedModule{}}
|
||||
case OpPlace:
|
||||
m := PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings}
|
||||
if at >= 0 {
|
||||
@@ -378,6 +425,9 @@ type PlacementProposal struct {
|
||||
// Again is when `again` asked the holder to judge it now and raise its ask again (§10); the holder does once per
|
||||
// value.
|
||||
Again time.Time `json:"again,omitempty"`
|
||||
// Warrant is the enrolment's warrant an add-node carries (ADR 0587 §7): its Approve is the issue's, so the holder
|
||||
// writes it with no ask of its own. Only an add-node carries one.
|
||||
Warrant *AddNodeWarrant `json:"warrant,omitempty"`
|
||||
// Withdrawn is who withdrew it and why (`withdraw`, §2): the holder then deletes it and its candidates and says
|
||||
// placement-withdrawn, which only the holder's process can say on its stream.
|
||||
Withdrawn string `json:"withdrawn,omitempty"`
|
||||
@@ -419,9 +469,51 @@ func (p PlacementProposal) check() error {
|
||||
if p.Seed && p.ExportDigest == "" {
|
||||
return fmt.Errorf("delivery: the seed %s names no export digest", p.ID)
|
||||
}
|
||||
return p.checkAddNode()
|
||||
}
|
||||
|
||||
// checkAddNode refuses an add-node that is not the proposal's one change, carries no warrant, or whose warrant names
|
||||
// another node or address than the change; and a warrant on any other proposal (ADR 0587 §7).
|
||||
func (p PlacementProposal) checkAddNode() error {
|
||||
adds := 0
|
||||
for _, c := range p.Changes {
|
||||
if c.Op == OpAddNode {
|
||||
adds++
|
||||
}
|
||||
}
|
||||
if adds == 0 {
|
||||
if p.Warrant != nil {
|
||||
return fmt.Errorf("delivery: the proposal %s carries an enrolment's warrant and adds no node", p.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if adds != 1 || len(p.Changes) != 1 || p.HandBack || p.Seed {
|
||||
return fmt.Errorf("delivery: the proposal %s adds a node beside other changes; an add-node is a proposal's one change", p.ID)
|
||||
}
|
||||
c, w := p.Changes[0], p.Warrant
|
||||
if w == nil || w.Ask == "" || w.Warrant == "" {
|
||||
return fmt.Errorf("delivery: the add-node of %s carries no warrant of its enrolment", c.Node)
|
||||
}
|
||||
if w.Node != c.Node || w.Address != c.Decided.Address {
|
||||
return fmt.Errorf("delivery: the add-node of %s at %s carries the warrant of %s at %s", c.Node, c.Decided.Address,
|
||||
w.Node, w.Address)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddNodeRefusal is why the holder of mesh-placement refuses an add-node from caller, the module the runtime proved
|
||||
// called (ADR 0587 §7), or "": only mesh-enrolment adds a node; what the proposal itself must hold, DecodePlacementProposal
|
||||
// refuses.
|
||||
func AddNodeRefusal(p PlacementProposal, caller string) string {
|
||||
if err := p.checkAddNode(); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if p.Warrant != nil && caller != EnrolmentModule {
|
||||
return fmt.Sprintf("an add-node is mesh-enrolment's alone, and %s proposed it", caller)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// EncodePlacementProposal is p as JSON at Version; it refuses what DecodePlacementProposal refuses.
|
||||
func EncodePlacementProposal(p PlacementProposal) ([]byte, error) {
|
||||
if err := p.check(); err != nil {
|
||||
|
||||
@@ -689,6 +689,55 @@ func TestPlacement(t *testing.T) {
|
||||
if ChangesDigest(p) == ChangesDigest(seed) {
|
||||
t.Error("two proposals' changes digest alike")
|
||||
}
|
||||
// 774/S42/AC1 (ADR 0587 §7): an add-node carries its enrolment's warrant naming its node and address, as its one
|
||||
// change, from mesh-enrolment alone; a warrant naming another node or address is refused.
|
||||
joining := PlacementProposalChange{Op: OpAddNode, Node: "lab", Decided: &NodeDecided{Address: "10.0.0.7"},
|
||||
Keys: &JoiningKeys{Identity: "id", Sealing: "seal"}}
|
||||
add := PlacementProposal{ID: id, By: "ace.mesh-enrolment", Why: "lab joins", At: time.Unix(1, 0).UTC(),
|
||||
Changes: []PlacementProposalChange{joining}, Base: map[string]uint64{"lab": 0}, State: ProposalAnswering,
|
||||
Warrant: &AddNodeWarrant{Node: "lab", Address: "10.0.0.7", Ask: "a-1", Warrant: "w-1"}}
|
||||
if raw, err := EncodePlacementProposal(add); err != nil {
|
||||
t.Errorf("an add-node: %v", err)
|
||||
} else if back, err := DecodePlacementProposal(raw); err != nil || back.Warrant == nil || back.Changes[0].Keys.Digest() != KeysDigest("id", "", "seal", "") {
|
||||
t.Errorf("an add-node round trip: %+v, %v", back, err)
|
||||
}
|
||||
if AddNodeRefusal(add, EnrolmentModule) != "" || !strings.Contains(AddNodeRefusal(add, AssignerModule), "mesh-enrolment's alone") {
|
||||
t.Errorf("add-node by caller: %q, %q", AddNodeRefusal(add, EnrolmentModule), AddNodeRefusal(add, AssignerModule))
|
||||
}
|
||||
if laid, f := LayChanges(current, add.Changes); len(f) != 0 || laid["lab"].Decided.Address != "10.0.0.7" || len(laid["lab"].Modules) != 0 {
|
||||
t.Errorf("an add-node laid: %+v, %v", laid, f)
|
||||
}
|
||||
if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpAddNode, Node: "g14", Decided: &NodeDecided{Address: "10.0.0.9"},
|
||||
Keys: &JoiningKeys{Identity: "id", Sealing: "seal"}}}); len(f) != 1 || !strings.Contains(f[0], "placed already") {
|
||||
t.Errorf("an add-node of a placed node: %v", f)
|
||||
}
|
||||
for name, mut := range map[string]func(*PlacementProposal){
|
||||
"warrant of another node": func(p *PlacementProposal) {
|
||||
p.Warrant = &AddNodeWarrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"}
|
||||
},
|
||||
"warrant of another address": func(p *PlacementProposal) {
|
||||
p.Warrant = &AddNodeWarrant{Node: "lab", Address: "10.0.0.8", Ask: "a", Warrant: "w"}
|
||||
},
|
||||
"no warrant": func(p *PlacementProposal) { p.Warrant = nil },
|
||||
"beside another change": func(p *PlacementProposal) {
|
||||
p.Changes = append(p.Changes, PlacementProposalChange{Op: OpPlace, Node: "lab", Module: "zsh"})
|
||||
},
|
||||
"the hub": func(p *PlacementProposal) {
|
||||
c := joining
|
||||
c.Decided = &NodeDecided{Address: "10.0.0.7", Hub: true}
|
||||
p.Changes = []PlacementProposalChange{c}
|
||||
},
|
||||
"a warrant on a move": func(p *PlacementProposal) {
|
||||
p.Changes, p.Base = []PlacementProposalChange{{Op: OpTakeOff, Node: "g14", Module: "zsh"}}, map[string]uint64{"g14": 4}
|
||||
},
|
||||
} {
|
||||
q := add
|
||||
q.Changes = append([]PlacementProposalChange(nil), add.Changes...)
|
||||
mut(&q)
|
||||
if _, err := EncodePlacementProposal(q); err == nil {
|
||||
t.Errorf("an add-node with %s: taken", name)
|
||||
}
|
||||
}
|
||||
for name, mut := range map[string]func(*PlacementProposal){
|
||||
"no id": func(p *PlacementProposal) { p.ID = "" },
|
||||
"no proposer": func(p *PlacementProposal) { p.By = "" },
|
||||
|
||||
Reference in New Issue
Block a user