diff --git a/go/catalogue/placement_seat.go b/go/catalogue/placement_seat.go index 7fa69de..615d256 100644 --- a/go/catalogue/placement_seat.go +++ b/go/catalogue/placement_seat.go @@ -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.", diff --git a/go/delivery/placement_proposal.go b/go/delivery/placement_proposal.go index 66f6cb1..9fbb374 100644 --- a/go/delivery/placement_proposal.go +++ b/go/delivery/placement_proposal.go @@ -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 { diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go index 59d8ae6..fa9b9e7 100644 --- a/go/delivery/placement_test.go +++ b/go/delivery/placement_test.go @@ -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 = "" },