405 lines
15 KiB
Go
405 lines
15 KiB
Go
package placement
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
client "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
|
|
seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
|
|
"git.novox.be/novox/mesh-sdk/go/keys"
|
|
)
|
|
|
|
// A placement changes only by a proposal (novox/hq ADR 0567 §2): `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.
|
|
|
|
// VerdictWaits is a proposal's verdict while a node it must hear from has not answered within the bound; it passes or
|
|
// fails as a check's verdict does (ADR 0567 §4).
|
|
const VerdictWaits = "waits"
|
|
|
|
// VerdictPass and VerdictFail are the other two states of a proposal's verdict.
|
|
const (
|
|
VerdictPass = "pass"
|
|
VerdictFail = "fail"
|
|
)
|
|
|
|
// Nodes is every node whose key the proposal changes, sorted.
|
|
func Nodes(p seat.Proposal) []string { return sortedKeys(p.Base) }
|
|
|
|
// RemovedNodes are the nodes whose key a proposal deletes (its remove-node), which LayChanges leaves out of what it lays.
|
|
func RemovedNodes(p seat.Proposal) []string {
|
|
var out []string
|
|
for _, c := range p.Changes {
|
|
if c.Op == client.OpRemoveNode {
|
|
out = append(out, c.Node)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// CheckChange refuses a change that names what its operation needs not, or an operation that is none.
|
|
func CheckChange(c seat.ProposalChange) error {
|
|
if err := keys.CheckName("node", c.Node); err != nil {
|
|
return err
|
|
}
|
|
needs := func(what string, ok bool) error {
|
|
if !ok {
|
|
return fmt.Errorf("placement: a %s change of %s names no %s", c.Op, c.Node, what)
|
|
}
|
|
return nil
|
|
}
|
|
switch c.Op {
|
|
case client.OpPlace, client.OpTakeOff, client.OpConfig:
|
|
return needs("module", keys.CheckName("module", c.Module) == nil)
|
|
case client.OpPin:
|
|
return needs("provision or provider", c.Provision != "" && c.Provider != nil && c.Provider.Node != "" && c.Provider.Module != "")
|
|
case client.OpUnpin:
|
|
return needs("provision", c.Provision != "")
|
|
case client.OpHold, client.OpUnhold:
|
|
return needs("module or claim", keys.CheckName("module", c.Module) == nil && c.Claim != "")
|
|
case client.OpDecide:
|
|
return needs("field or value", c.Field != "" && c.Value != nil)
|
|
case client.OpSeed:
|
|
if c.Whole == nil || c.Whole.Node != c.Node {
|
|
return fmt.Errorf("placement: the seed of %s names no placement of that node", c.Node)
|
|
}
|
|
return client.CheckPlacement(*c.Whole)
|
|
case client.OpAddNode:
|
|
if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Overlay == "" {
|
|
return fmt.Errorf("placement: the add-node of %s names no address or no tunnel key", c.Node)
|
|
}
|
|
// The identity keys come at redeem, whole or not at all: an identity with no sealing key, or the reverse.
|
|
if (c.Keys.Identity == "") != (c.Keys.Sealing == "") || (c.Keys.Identity == "" && c.Keys.Serving != "") {
|
|
return fmt.Errorf("placement: the add-node of %s names part of its identity keys; they come whole at redeem", c.Node)
|
|
}
|
|
if c.Decided.Hub {
|
|
return fmt.Errorf("placement: 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("placement: the add-node of %s places a module; a module is placed by a proposal of its own", c.Node)
|
|
}
|
|
return nil
|
|
case client.OpRemoveNode:
|
|
if c.Module != "" || c.Decided != nil || c.Keys != nil || c.Whole != nil {
|
|
return fmt.Errorf("placement: the remove-node of %s names more than its node", c.Node)
|
|
}
|
|
return nil
|
|
}
|
|
return fmt.Errorf("placement: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide, config, add-node or remove-node", 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 the client refuses. What CheckPlacements refuses across nodes
|
|
// is the caller's to ask of the result laid over current.
|
|
func LayChanges(current map[string]seat.Placement, changes []seat.ProposalChange) (map[string]seat.Placement, []string) {
|
|
laid := map[string]seat.Placement{}
|
|
var findings []string
|
|
of := func(node string) seat.Placement {
|
|
if p, ok := laid[node]; ok {
|
|
return p
|
|
}
|
|
p, ok := current[node]
|
|
if !ok {
|
|
return seat.Placement{V: keys.Version, Node: node, Modules: []seat.PlacedModule{}}
|
|
}
|
|
return clonePlacement(p)
|
|
}
|
|
for i, c := range changes {
|
|
if err := CheckChange(c); 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 client.OpSeed:
|
|
p = clonePlacement(*c.Whole)
|
|
case client.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 = seat.Placement{V: keys.Version, Node: c.Node, Decided: *c.Decided, Modules: []seat.PlacedModule{}}
|
|
case client.OpRemoveNode:
|
|
// Only a joining node never confirmed goes this way: one with no module and no holding (a node that runs
|
|
// anything is taken off module by module, by a proposal the operator approves).
|
|
cur, has := current[c.Node]
|
|
if !has {
|
|
findings = append(findings, fmt.Sprintf("change %d: %s is not placed, so there is nothing to remove", i+1, c.Node))
|
|
} else if len(cur.Modules)+len(cur.Holds) > 0 {
|
|
findings = append(findings, fmt.Sprintf("change %d: %s runs modules or holds seats, so it is no joining node to remove", i+1, c.Node))
|
|
}
|
|
continue
|
|
case client.OpPlace:
|
|
m := seat.PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings}
|
|
if at >= 0 {
|
|
o := p.Modules[at]
|
|
m.MeshWide, m.HasMeshWide, m.Layer, m.HasLayer = o.MeshWide, o.HasMeshWide, o.Layer, o.HasLayer
|
|
m.MeshWideValues, m.Values = o.MeshWideValues, o.Values
|
|
p.Modules[at] = m
|
|
} else {
|
|
p.Modules = append(p.Modules, m)
|
|
}
|
|
case client.OpTakeOff:
|
|
if at < 0 {
|
|
notPlaced()
|
|
continue
|
|
}
|
|
p.Modules = append(p.Modules[:at:at], p.Modules[at+1:]...)
|
|
var holds []seat.Holding
|
|
for _, h := range p.Holds {
|
|
if h.Module != c.Module {
|
|
holds = append(holds, h)
|
|
}
|
|
}
|
|
p.Holds = holds
|
|
case client.OpConfig:
|
|
if at < 0 {
|
|
notPlaced()
|
|
continue
|
|
}
|
|
m := &p.Modules[at]
|
|
if c.MeshWide != nil {
|
|
m.MeshWide, m.HasMeshWide = c.MeshWide, true
|
|
}
|
|
if c.Layer != nil {
|
|
m.Layer, m.HasLayer = c.Layer, true
|
|
}
|
|
if c.MeshWideValues != nil {
|
|
m.MeshWideValues = c.MeshWideValues
|
|
}
|
|
if c.Values != nil {
|
|
m.Values = c.Values
|
|
}
|
|
case client.OpPin:
|
|
if p.Pins == nil {
|
|
p.Pins = map[string]seat.Provider{}
|
|
}
|
|
p.Pins[c.Provision] = *c.Provider
|
|
case client.OpUnpin:
|
|
delete(p.Pins, c.Provision)
|
|
if len(p.Pins) == 0 {
|
|
p.Pins = nil
|
|
}
|
|
case client.OpHold:
|
|
if at < 0 {
|
|
notPlaced()
|
|
continue
|
|
}
|
|
p.Holds = append(p.Holds, seat.Holding{Claim: c.Claim, Scope: c.Scope, Module: c.Module, Site: c.Site, Kind: c.Kind})
|
|
case client.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 client.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 := client.CheckPlacement(laid[node]); 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 seat.NodeDecided, field string, value any) (seat.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)
|
|
}
|
|
set, err := json.Marshal(value)
|
|
if err != nil {
|
|
return d, fmt.Errorf("the decided field %q does not take %v: %v", field, value, err)
|
|
}
|
|
fields[field] = set
|
|
raw, _ = json.Marshal(fields)
|
|
var out seat.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, set, err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// decidedFields are NodeDecided's fields by their JSON names.
|
|
var decidedFields = func() map[string]bool {
|
|
raw, _ := json.Marshal(seat.NodeDecided{Endpoint: "x", Site: "x", Hub: true, Address: "x", PublicDomain: "x", Account: "x",
|
|
AccountHome: "x", AgentAccount: "x", AgentAccountHome: "x", Adopted: true, Taken: []string{"x"}, Tunnel: &seat.FoundTunnel{Interface: "x"},
|
|
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 seat.Placement) seat.Placement {
|
|
raw, _ := json.Marshal(p)
|
|
var out seat.Placement
|
|
_ = json.Unmarshal(raw, &out)
|
|
if out.Modules == nil {
|
|
out.Modules = []seat.PlacedModule{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// NewProposalID is a fresh proposal id: its time in UTC and six random hex digits, `p-20261028t183000-1a2b3c`. The
|
|
// client's CheckProposalID says what an id is.
|
|
func NewProposalID(at time.Time) string {
|
|
var b [3]byte
|
|
_, _ = rand.Read(b[:])
|
|
return "p-" + strings.ToLower(at.UTC().Format("20060102T150405")) + "-" + hex.EncodeToString(b[:])
|
|
}
|
|
|
|
// CheckProposal refuses a proposal that is none: an id that is none, no proposer or why, no change (but a hand-back), 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, a state it does not know, and an add-node or remove-node that is not as ADR 0587 §7 says.
|
|
func CheckProposal(p seat.Proposal) error {
|
|
if err := client.CheckProposalID(p.ID); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(p.By) == "" || strings.TrimSpace(p.Why) == "" {
|
|
return fmt.Errorf("placement: the proposal %s names no proposer or no why", p.ID)
|
|
}
|
|
if len(p.Base) == 0 || (len(p.Changes) == 0) != p.HandBack || (p.HandBack && p.Seed) {
|
|
return fmt.Errorf("placement: the proposal %s changes nothing, or is a hand-back with changes", p.ID)
|
|
}
|
|
for i, c := range p.Changes {
|
|
if err := CheckChange(c); err != nil {
|
|
return fmt.Errorf("placement: the proposal %s, change %d: %w", p.ID, i+1, err)
|
|
}
|
|
if (c.Op == client.OpSeed) != p.Seed {
|
|
return fmt.Errorf("placement: the proposal %s mixes the seed with other changes", p.ID)
|
|
}
|
|
if _, ok := p.Base[c.Node]; !ok {
|
|
return fmt.Errorf("placement: the proposal %s changes %s and names no base revision of its key", p.ID, c.Node)
|
|
}
|
|
}
|
|
switch p.State {
|
|
case client.ProposalAnswering, client.ProposalFailed:
|
|
case client.ProposalAsked:
|
|
if p.Ask == "" || p.Verdict == nil || p.Verdict.State != VerdictPass {
|
|
return fmt.Errorf("placement: the proposal %s is asked with no ask or no passing verdict", p.ID)
|
|
}
|
|
default:
|
|
return fmt.Errorf("placement: the proposal %s is in no state %q", p.ID, p.State)
|
|
}
|
|
if p.Seed && p.ExportDigest == "" {
|
|
return fmt.Errorf("placement: the seed %s names no export digest", p.ID)
|
|
}
|
|
return checkAddNode(p)
|
|
}
|
|
|
|
// checkAddNode refuses an add-node or a remove-node that is not the proposal's one change, carries no warrant, or whose
|
|
// warrant names another node (or, for an add-node, another address) than the change; and a warrant on any other
|
|
// proposal (ADR 0587 §7).
|
|
func checkAddNode(p seat.Proposal) error {
|
|
adds := 0
|
|
for _, c := range p.Changes {
|
|
if c.Op == client.OpAddNode || c.Op == client.OpRemoveNode {
|
|
adds++
|
|
}
|
|
}
|
|
if adds == 0 {
|
|
if p.Warrant != nil {
|
|
return fmt.Errorf("placement: 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("placement: 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("placement: the add-node of %s carries no warrant of its enrolment", c.Node)
|
|
}
|
|
if c.Op == client.OpRemoveNode {
|
|
if w.Node != c.Node {
|
|
return fmt.Errorf("placement: the remove-node of %s carries the warrant of %s", c.Node, w.Node)
|
|
}
|
|
return nil
|
|
}
|
|
if w.Node != c.Node || w.Address != c.Decided.Address {
|
|
return fmt.Errorf("placement: 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 the seat refuses an add-node or a remove-node from caller, the module the runtime
|
|
// proved called (ADR 0587 §7), or "": only the enroller (the module holding the seat mesh-node-enroller, which the caller
|
|
// of this names) adds a node; what the proposal itself must hold, CheckProposal refuses.
|
|
func AddNodeRefusal(p seat.Proposal, caller, enroller string) string {
|
|
if err := checkAddNode(p); err != nil {
|
|
return err.Error()
|
|
}
|
|
if p.Warrant != nil && caller != enroller {
|
|
return fmt.Sprintf("an add-node or a remove-node is %s's alone, and %s proposed it", enroller, caller)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// EncodeProposal is p as JSON at the keys' Version; it refuses what DecodeProposal refuses.
|
|
func EncodeProposal(p seat.Proposal) ([]byte, error) {
|
|
if err := CheckProposal(p); err != nil {
|
|
return nil, err
|
|
}
|
|
p.V = keys.Version
|
|
return json.Marshal(p)
|
|
}
|
|
|
|
// DecodeProposal reads a proposal; it refuses an unknown version, a field the contract does not have, and what
|
|
// CheckProposal refuses.
|
|
func DecodeProposal(data []byte) (seat.Proposal, error) {
|
|
if err := keys.CheckVersion(data); err != nil {
|
|
return seat.Proposal{}, err
|
|
}
|
|
p, err := seat.DecodeProposalsByProposalValue(data)
|
|
if err != nil {
|
|
return seat.Proposal{}, fmt.Errorf("placement: a proposal does not fit its shape: %w", err)
|
|
}
|
|
if err := CheckProposal(p); err != nil {
|
|
return seat.Proposal{}, err
|
|
}
|
|
return p, nil
|
|
}
|