package delivery import ( "encoding/json" "fmt" "slices" "strings" ) // 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 `.` 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.`. // // The node watches `*.`, 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` (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` change the holder's. const ( // 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 in one proposal: // `.`, so a node watches `*.` and a proposal's keys are `.*`. 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 proposal + "." + node, nil } // ParsePlacementCandidateKey is the inverse of PlacementCandidateKey; it refuses any other key, naming it. 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, .", key) } return proposal, node, nil } // 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"` Proposal string `json:"proposal"` Reached bool `json:"reached,omitempty"` Placement *Placement `json:"placement,omitempty"` } func (c PlacementCandidate) check() error { if err := CheckProposalID(c.Proposal); err != nil { return err } if c.Placement != nil { return c.Placement.check() } return nil } // EncodePlacementCandidate is c as JSON at Version; it refuses what DecodePlacementCandidate refuses. func EncodePlacementCandidate(c PlacementCandidate) ([]byte, error) { if err := c.check(); err != nil { return nil, err } c.V = Version if c.Placement != nil { p := *c.Placement p.V = Version c.Placement = &p } return json.Marshal(c) } // 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 { return PlacementCandidate{}, err } if err := c.check(); err != nil { return PlacementCandidate{}, err } return c, nil } // The verdicts of a node's answer to a candidate. const ( // CandidateSame: every composition the node composes with the candidate is the one it runs, and it runs no other. CandidateSame = "same" // CandidateDiffers: some composition differs (Differs), is added by the candidate (Added) or removed by it (Removed); // whether the change intends it is the assigner's to judge (ADR 0559 §5). CandidateDiffers = "differs" // CandidateRefused: a value is missing for a module the node runs (Refusals), or the node cannot compose with the // candidate at all (Refused says why: an unbound requirement, a manifest it cannot read). CandidateRefused = "refused" ) // 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 { Proposal string `json:"proposal"` Revision uint64 `json:"revision"` Verdict string `json:"verdict"` Same []string `json:"same,omitempty"` Differs []ModuleDiffers `json:"differs,omitempty"` Added []ModuleAdded `json:"added,omitempty"` Removed []string `json:"removed,omitempty"` Refusals []ModuleRefused `json:"refusals,omitempty"` // Held is each module the candidate cannot compose that the node holds today too, with why: held as today, which // changes nothing and counts for no verdict (issue 1126: a seed reproduces today). Held []ModuleRefused `json:"held,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 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"` // Note is what the node says of how it answered, in words, beside the verdict: a pass that ran over a minute, inputs // it waited for (issue 1126: the shadow shows its work). It changes no verdict. Note string `json:"note,omitempty"` // ValuesDiffer is how many shown bodies hold a value the node opened on both sides to two values (ValuesDiffer, // MaskedAnotherValue): the vault's value and the one that runs drifted apart (issue 1126, cause C4). It changes no // verdict. ValuesDiffer int `json:"values_differ,omitempty"` } // 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 // when every resource is equal, and why in words. type ModuleDiffers struct { Module string `json:"module"` Composed string `json:"composed,omitempty"` Running string `json:"running,omitempty"` Resource string `json:"resource,omitempty"` Why string `json:"why"` // Bodies is every resource that differs, both sides, each value a secret could be in masked (issue 1126: the shadow // shows its work, so why a module differs is read, not guessed). At most MaxBodies, each side cut at MaxBody bytes. Bodies []ResourceBodies `json:"bodies,omitempty"` } // ResourceBodies is one differing resource of a module: its id, why, and its body composed and as it runs, masked // (MaskedPair); a side that does not hold it is empty. type ResourceBodies struct { ID string `json:"id"` Why string `json:"why"` // Paths is every JSON path at which the two bodies differ (DifferingPaths: `sealed`, `content.url`, `links.0`), found // on the whole bodies before they are cut, a JSON value written in a string read as JSON; a path names keys alone, // never a value. At most MaxPaths, the last then saying how many more. Paths []string `json:"paths,omitempty"` Composed string `json:"composed,omitempty"` Running string `json:"running,omitempty"` } // 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 proposal's answer beside the node's facts, so one answer keeps to a sixth. const ( MaxBodies = 20 MaxBody = 8192 MaxPaths = 64 MaxAnswerBytes = 160 << 10 ) // ModuleAdded is one module the candidate places on the node and the node does not run: its composition's digest when // it composed, and the values not yet issued for it (its own secrets, its grants, a host port), each in the words the // composition stood in for it with. type ModuleAdded struct { Module string `json:"module"` Composed string `json:"composed,omitempty"` Unissued []string `json:"unissued,omitempty"` } // ModuleRefused is a value missing for a module the node runs, in the words the composition stood in for it with. Today, // in CandidateAnswer.Held, is how the node keeps it today (held, failed, runs a build of the old form, held behind // another), which the candidate keeps. type ModuleRefused struct { Module string `json:"module"` Why string `json:"why"` Today string `json:"today,omitempty"` } // VerdictOf is the verdict of an answer's lists: refused on any refusal, differs on any difference, addition or // removal, same otherwise. func VerdictOf(a CandidateAnswer) string { switch { case a.Refused != "" || len(a.Refusals) > 0: return CandidateRefused case len(a.Differs)+len(a.Added)+len(a.Removed) > 0: return CandidateDiffers } return CandidateSame } func (a CandidateAnswer) check() error { 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: default: return fmt.Errorf("delivery: the candidate verdict %q is not same, differs or refused", a.Verdict) } if got := VerdictOf(a); got != a.Verdict { return fmt.Errorf("delivery: a candidate answered %s names what makes it %s", a.Verdict, got) } if a.Refused != "" && len(a.Same)+len(a.Differs)+len(a.Added)+len(a.Removed)+len(a.Refusals) > 0 { return fmt.Errorf("delivery: a candidate the node could not compose names modules") } if a.Returns != "" && (a.Refused != "" || len(a.Refusals) > 0) { return fmt.Errorf("delivery: a candidate the node returns to the controller's compositions on is compared, never refused: " + "a composition the controller holds none of is refused without returning") } for _, d := range a.Differs { if d.Module == "" || d.Why == "" { return fmt.Errorf("delivery: a candidate difference names no module or no why") } } for _, d := range a.Added { if d.Module == "" { return fmt.Errorf("delivery: a module the candidate adds is not named") } } for _, d := range append(slices.Clone(a.Refusals), a.Held...) { if d.Module == "" || d.Why == "" { return fmt.Errorf("delivery: a candidate refusal names no module or no why") } } for _, m := range a.Removed { if m == "" { return fmt.Errorf("delivery: a module the candidate removes is not named") } } return nil }