package assignment import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "slices" "strings" "time" client "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" enroller "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller" "git.novox.be/novox/mesh-sdk/go/keys" ) // Assignments 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("assignments: 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("assignments: the seed of %s names no assignments of that node", c.Node) } return client.CheckAssignments(*c.Whole) case client.OpAddNode: if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Overlay == "" { return fmt.Errorf("assignments: 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("assignments: 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("assignments: 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("assignments: 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("assignments: the remove-node of %s names more than its node", c.Node) } return nil } return fmt.Errorf("assignments: %q is no change of assignments: place, take-off, pin, unpin, hold, unhold, decide, config, add-node or remove-node", c.Op) } // LayChanges is the assignments of the nodes the changes name with the changes laid over current, in the order given; // current is every node's assignments now (absent: the node has no key). It returns, by node, the laid assignments, 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 assignments the client refuses. What CheckAssignments refuses across nodes // is the caller's to ask of the result laid over current. func LayChanges(current map[string]seat.Assignments, changes []seat.ProposalChange) (map[string]seat.Assignments, []string) { laid := map[string]seat.Assignments{} var findings []string of := func(node string) seat.Assignments { if p, ok := laid[node]; ok { return p } p, ok := current[node] if !ok { return seat.Assignments{V: keys.Version, Node: node, Modules: []seat.PlacedModule{}} } return cloneAssignments(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 = cloneAssignments(*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.Assignments{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.CheckAssignments(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 cloneAssignments(p seat.Assignments) seat.Assignments { raw, _ := json.Marshal(p) var out seat.Assignments _ = 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("assignments: 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("assignments: 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("assignments: the proposal %s, change %d: %w", p.ID, i+1, err) } if (c.Op == client.OpSeed) != p.Seed { return fmt.Errorf("assignments: the proposal %s mixes the seed with other changes", p.ID) } if _, ok := p.Base[c.Node]; !ok { return fmt.Errorf("assignments: 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("assignments: the proposal %s is asked with no ask or no passing verdict", p.ID) } default: return fmt.Errorf("assignments: the proposal %s is in no state %q", p.ID, p.State) } if p.Seed && p.ExportDigest == "" { return fmt.Errorf("assignments: 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("assignments: 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("assignments: 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("assignments: 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("assignments: 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("assignments: 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 a holder of the seat mesh-node-enroller (enrollers, EnrollersOf) adds a node; // what the proposal itself must hold, CheckProposal refuses. func AddNodeRefusal(p seat.Proposal, caller string, enrollers []string) string { if err := checkAddNode(p); err != nil { return err.Error() } if p.Warrant != nil && !slices.Contains(enrollers, caller) { if len(enrollers) == 0 { return fmt.Sprintf("an add-node or a remove-node is the holder's of the seat %s alone, nobody holds it, and %s proposed it", enroller.Seat, caller) } return fmt.Sprintf("an add-node or a remove-node is %s's alone, and %s proposed it", strings.Join(enrollers, ", "), caller) } return "" } // EnrollersOf is the modules that hold the seat mesh-node-enroller on the assignments given, sorted: the state seat-holders // composed from them (SeatHoldersOf), read by the client's HoldersOf. func EnrollersOf(assignments map[string]seat.Assignments) []string { holders, _ := client.HoldersOf(rawHolders(SeatHoldersOf(assignments)), enroller.Seat, "") var out []string for _, h := range holders { if !slices.Contains(out, h.Module) { out = append(out, h.Module) } } slices.Sort(out) return out } func rawHolders(held map[string]seat.SeatHolders) map[string][]byte { out := map[string][]byte{} for k, v := range held { out[k], _ = json.Marshal(v) } return out } // 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("assignments: a proposal does not fit its shape: %w", err) } if err := CheckProposal(p); err != nil { return seat.Proposal{}, err } return p, nil }