diff --git a/go/delivery/answer_paths.go b/go/delivery/answer_paths.go deleted file mode 100644 index f797166..0000000 --- a/go/delivery/answer_paths.go +++ /dev/null @@ -1,136 +0,0 @@ -package delivery - -import ( - "bytes" - "encoding/json" - "sort" - "strconv" - "strings" -) - -// DifferingPaths is every JSON path at which two bodies of one resource differ, sorted (issue 1126, T1: the shadow -// shows its work): object keys and list places joined by dots (`content.url`, `links.0`), a string that holds a JSON -// object or list read as that JSON and walked on (a runtime's config carried as text), a key or place one side alone -// holds named as it is. A key a secret could be under (secretWords) is named and not walked into. A body that is not -// JSON differs at "" (the whole), and a resource one side alone holds has none. At most MaxPaths: the last then says how many more there are. A path names keys -// alone, never a value, so it holds no secret. -func DifferingPaths(composed, running json.RawMessage) []string { - if composed == nil || running == nil { - return nil // one side alone holds it: its why says so - } - var a, b any - okA := unmarshalNumbers(composed, &a) == nil - okB := unmarshalNumbers(running, &b) == nil - if !okA || !okB { - if bytes.Equal(composed, running) { - return nil - } - return []string{""} - } - var out []string - walkDiffering(a, b, "", &out) - sort.Strings(out) - if len(out) > MaxPaths { - more := len(out) - (MaxPaths - 1) - out = append(out[:MaxPaths-1], "… "+strconv.Itoa(more)+" more") - } - return out -} - -func unmarshalNumbers(raw []byte, into *any) error { - d := json.NewDecoder(bytes.NewReader(raw)) - d.UseNumber() - return d.Decode(into) -} - -// asJSON is a string holding a JSON object or list read as that value; v itself otherwise. -func asJSON(v any) any { - s, ok := v.(string) - if !ok { - return v - } - t := strings.TrimSpace(s) - if t == "" || (t[0] != '{' && t[0] != '[') { - return v - } - var parsed any - if unmarshalNumbers([]byte(t), &parsed) != nil { - return v - } - return parsed -} - -func join(at, key string) string { - if at == "" { - return key - } - return at + "." + key -} - -func walkDiffering(a, b any, at string, out *[]string) { - a, b = asJSON(a), asJSON(b) - ma, isMA := a.(map[string]any) - mb, isMB := b.(map[string]any) - if isMA && isMB { - keys := map[string]bool{} - for k := range ma { - keys[k] = true - } - for k := range mb { - keys[k] = true - } - for k := range keys { - x, inA := ma[k] - y, inB := mb[k] - switch { - case !inA || !inB: - *out = append(*out, join(at, k)) - case secretKey(k): - // A file's secrets (SecretsField) are named entry by entry, each entry never entered; any other - // object under a secret key, a vault box included, is one path. - sx, isSX := x.(map[string]any) - sy, isSY := y.(map[string]any) - if k != SecretsField || !isSX || !isSY { - if !sameAny(x, y) { - *out = append(*out, join(at, k)) - } - continue - } - for name := range sx { - if v, in := sy[name]; !in || !sameAny(sx[name], v) { - *out = append(*out, join(join(at, k), name)) - } - } - for name := range sy { - if _, in := sx[name]; !in { - *out = append(*out, join(join(at, k), name)) - } - } - default: - walkDiffering(x, y, join(at, k), out) - } - } - return - } - la, isLA := a.([]any) - lb, isLB := b.([]any) - if isLA && isLB { - for i := range max(len(la), len(lb)) { - if i >= len(la) || i >= len(lb) { - *out = append(*out, join(at, strconv.Itoa(i))) - continue - } - walkDiffering(la[i], lb[i], join(at, strconv.Itoa(i)), out) - } - return - } - if !sameAny(a, b) { - *out = append(*out, at) - } -} - -func sameAny(a, b any) bool { - x, errA := json.Marshal(a) - y, errB := json.Marshal(b) - return errA == nil && errB == nil && bytes.Equal(x, y) -} diff --git a/go/delivery/assigner.go b/go/delivery/assigner.go deleted file mode 100644 index 1e26c9c..0000000 --- a/go/delivery/assigner.go +++ /dev/null @@ -1,564 +0,0 @@ -package delivery - -import ( - "bytes" - "encoding/json" - "fmt" - "reflect" - "strings" -) - -// The assigner's keys (novox/hq ADR 0528 clauses 1 and 4, ADR 0429 §6, issue 1297). The assigner writes, by the six -// rules of ADR 0429 §6, what each node should run: `want..` in its bucket wants, and beside it -// `held..` for a fingerprint it holds after a failed health check. A module that leaves a node's -// modules gets its want written removed, never deleted (ADR 0408 §4). For each node and module it compares the want with -// what the controller sends (the build the controller's assignment names) and writes every difference under -// `.` in its bucket differences, with the rule that explains it or unexplained. In release 5 wants are -// only compared; the node agent applying them is release 6 (ADR 0429 §7). Only the assigner's credential writes these -// buckets; the watchman, the view and the controller read them. -// -// **Release 6** (ADR 0539 clauses 1, 6 and 16): a want also names the digest of the one composition it allows, written -// only once that composition exists (the seventh rule, RuleComposed), and carries the answer to a failed backup; the -// node's own want `want..node` names the digest of the node's own composition (NodeWant). -// -// **The bucket is the first word, the key the rest** (as the builder's `builds..` is the key -// `.` in its builds bucket): the differences bucket's keys are `.`. The wants bucket holds -// two kinds of key, so each keeps its word: `want..` and `held..`. -const ( - // AssignerModule is the module of the assigner part. - AssignerModule = "mesh-delivery-assigner" - // AssignerWantsBucket is the assigner's bucket of wants and holds, `_`. - AssignerWantsBucket = AssignerModule + "_wants" - // AssignerWantsHistory is the revisions the wants bucket keeps per key, so a want's history shows each move and - // the operator's manual write (ADR 0429 §6, the one escape). - AssignerWantsHistory = 20 - // AssignerDifferencesBucket is the assigner's bucket of differences between a want and what the controller sends. - AssignerDifferencesBucket = AssignerModule + "_differences" - // AssignerDifferencesHistory is the revisions the differences bucket keeps per key: acceptance test 17 of design 47 - // reads a day of them. - AssignerDifferencesHistory = 64 -) - -// The words that open the wants bucket's two kinds of key. -const ( - wantWord = "want" - heldWord = "held" -) - -// WantKey is the key, in AssignerWantsBucket, of a node's want for a module: `want..`. A module named -// `node` is refused, since `want..node` is the node's own key (ADR 0429 §4), as HaveKey refuses it. -func WantKey(node, module string) (string, error) { - have, err := HaveKey(node, module) - if err != nil { - return "", err - } - return wantWord + "." + have, nil -} - -// WantNodeKey is the key, in AssignerWantsBucket, of a node's own want: `want..node`, naming the digest of the -// node's own composition (ADR 0539 clause 6; ADR 0429 §4's node key). The assigner writes it once the node's own -// composition exists. -func WantNodeKey(node string) (string, error) { - own, err := HaveNodeKey(node) - if err != nil { - return "", err - } - return wantWord + "." + own, nil -} - -// ParseWantNodeKey is the inverse of WantNodeKey; it refuses a module's want key and any key of another bucket, -// naming it. -func ParseWantNodeKey(key string) (node string, err error) { - rest, ok := strings.CutPrefix(key, wantWord+".") - if !ok { - return "", fmt.Errorf("delivery: %q is not a node's own want key, want..node", key) - } - node, _, isNode, err := ParseHaveKey(rest) - if err != nil || !isNode { - return "", fmt.Errorf("delivery: %q is not a node's own want key, want..node", key) - } - return node, nil -} - -// ParseWantKey is the inverse of WantKey; it refuses a held key, a have key, the node's own want key and any key of -// another bucket, naming it. -func ParseWantKey(key string) (node, module string, err error) { - rest, ok := strings.CutPrefix(key, wantWord+".") - if !ok { - return "", "", fmt.Errorf("delivery: %q is not a want key, want..", key) - } - node, module, isNode, err := ParseHaveKey(rest) - if err != nil || isNode { - return "", "", fmt.Errorf("delivery: %q is not a want key, want..", key) - } - return node, module, nil -} - -// HeldKey is the key, in AssignerWantsBucket, of a module's fingerprint the assigner holds: `held..`, -// the hex being the fingerprint's key part (FingerprintKeyPart). -func HeldKey(module, fingerprint string) (string, error) { - key, err := BuilderBuildKey(module, fingerprint) - if err != nil { - return "", err - } - return heldWord + "." + key, nil -} - -// ParseHeldKey is the inverse of HeldKey: the module and the fingerprint (`src2:` and the hex); it refuses a want key -// and any key of another bucket, naming it. -func ParseHeldKey(key string) (module, fingerprint string, err error) { - rest, ok := strings.CutPrefix(key, heldWord+".") - if !ok { - return "", "", fmt.Errorf("delivery: %q is not a held key, held..", key) - } - if module, fingerprint, err = ParseBuilderBuildKey(rest); err != nil { - return "", "", fmt.Errorf("delivery: %q is not a held key: %w", key, err) - } - return module, fingerprint, nil -} - -// DifferenceKey is the key, in AssignerDifferencesBucket, of the difference for a module on a node: `.`. -func DifferenceKey(node, module string) (string, error) { - return HaveKey(node, module) -} - -// ParseDifferenceKey is the inverse of DifferenceKey; it refuses a want or held key and a node's own key, naming it. -func ParseDifferenceKey(key string) (node, module string, err error) { - node, module, isNode, err := ParseHaveKey(key) - if err != nil || isNode { - return "", "", fmt.Errorf("delivery: %q is not a difference key, .", key) - } - return node, module, nil -} - -// The rules of ADR 0429 §6 and ADR 0539 clause 6, by number, as a want or a difference names the one that holds a node -// back. -const ( - RuleBuildDone = 1 // the build of F is done - RuleNotHeld = 2 // F is not held - RuleCanary = 3 // the node is the module's canary, or the canary runs F healthy - RuleStep = 4 // at most step other nodes are ahead (step 0: no limit, ADR 0528 §3) - RuleProviders = 5 // every provider the module requires runs healthy on that node, or at its canary - RuleGrant = 6 // the module's credential for F exists (grants..) - RuleComposed = 7 // the module's composition for that node at F exists (ADR 0539 §6) - // RuleProvisions: every provision the module requires on that node is provided (novox/hq ADR 0570 §4): a want's - // words name the key and its state that hold it, as "the database of keycloak on novox is asked of postgres". - RuleProvisions = 8 -) - -// ruleRead is the highest rule a reader takes (novox/hq ADR 0614 §2, §4): 9 and 10 are kept for the verb wait and the -// capability hold, which a later release gives numbers of their own, so a want of that release reads on every node -// that runs this one. Nothing writes 9 or 10 yet: the verb wait is held under RuleProvisions and the capability hold -// under RuleComposed, each with words naming its edge. -const ruleRead = 10 - -func checkRule(rule int, of string) error { - if rule < RuleBuildDone || rule > ruleRead { - return fmt.Errorf("delivery: %s names the rule %d, which is not one of 1 to %d", of, rule, ruleRead) - } - return nil -} - -// The answers a want carries to a failed backup before an apply (ADR 0539 clause 16): the node agent reads them from -// its own want and from nothing else. -const ( - // BackupAgain is the answer to back the module up again before the apply. - BackupAgain = "again" - // BackupSkip is the answer to go on without a backup. - BackupSkip = "skip" -) - -func checkFingerprint(fp, of string) error { - if _, err := FingerprintKeyPart(fp); err != nil { - return fmt.Errorf("delivery: %s: %w", of, err) - } - return nil -} - -// Want is a node's want for a module: the fingerprint FP the node should run and, before ADR 0547's second milestone, -// the digest Composed of the one composition of it the node may apply (ADR 0539 clause 1; from that milestone the -// node composes its own, ADR 0547 §2, and a want names none), or Removed for a module that left the node's modules (then -// both are empty). When the module's newest fingerprint is not yet allowed on this node, Newest names it and Rule the -// rule that holds the node back; both are empty when FP is the newest. Backup is the answer to a failed backup before -// the apply, BackupAgain or BackupSkip (clause 16), empty when none was asked. Words say why in a sentence. The -// entry's own time says since when. -type Want struct { - V int `json:"v"` - FP string `json:"fp,omitempty"` - Composed string `json:"composed,omitempty"` - Removed bool `json:"removed,omitempty"` - Newest string `json:"newest,omitempty"` - Rule int `json:"rule,omitempty"` - Backup string `json:"backup,omitempty"` - Words string `json:"words,omitempty"` - // Put says the want was written by a put (ADR 0546), and Hold that the put holds it: the rules pass over it until it - // is released. A put's want names no newest fingerprint or rule. - Put bool `json:"put,omitempty"` - Hold bool `json:"hold,omitempty"` - // Yes is the digest of a composition a person allowed the node to move to (novox/hq ADR 0547 §3): a move of a module - // of policy record that the node agent judged to recreate a container or move its data is applied only when its - // want names the composition so allowed. Empty when none was asked. - Yes string `json:"yes,omitempty"` - // Release names the release whose Approve answered the record ask of this want's build (novox/hq ADR 0601 §1): the - // group of pull requests mesh-delivery's gate merged on that Approve. Set with the yes it gave; empty when a person's - // answer to a record ask gave it, or none was needed. - Release string `json:"release,omitempty"` - // Self says the node composes this module itself (ADR 0547 §2): the want names the build alone, and no composition. - // A want that is not Self names the one composition it allows (ADR 0539 clause 1), as before. - Self bool `json:"self,omitempty"` -} - -func (w Want) check() error { - if w.Removed { - if w.FP != "" || w.Composed != "" || w.Newest != "" || w.Rule != 0 || w.Backup != "" || w.Put || w.Hold || w.Self || w.Yes != "" || w.Release != "" { - return fmt.Errorf("delivery: a removed want names no fingerprint, composition, rule, backup answer or put") - } - return nil - } - if w.Hold && !w.Put { - return fmt.Errorf("delivery: only a put holds a want") - } - if w.Put && (w.Newest != "" || w.Rule != 0) { - return fmt.Errorf("delivery: a put's want is held back by no rule") - } - if err := checkFingerprint(w.FP, "a want's fingerprint"); err != nil { - return err - } - // From ADR 0547's second milestone a node the catalogue places composes its own modules (§2): its want is Self and - // names the build alone. Any other want names the one composition it allows (ADR 0539 clause 1). - switch { - case w.Self && w.Composed != "": - return fmt.Errorf("delivery: the want of %s says the node composes it, and names a composition", w.FP) - case !w.Self && w.Composed == "": - return fmt.Errorf("delivery: the want of %s names no composition: a want names the digest of the one composition it allows (ADR 0539 clause 1), unless the node composes it itself", w.FP) - case w.Composed != "": - if err := checkDigest(w.Composed, "a want's composition digest"); err != nil { - return err - } - } - if w.Yes != "" { - if err := checkDigest(w.Yes, "the digest a want's yes allows"); err != nil { - return err - } - } else if w.Release != "" { - return fmt.Errorf("delivery: the want of %s names the release %s and no yes it gave", w.FP, w.Release) - } - switch w.Backup { - case "", BackupAgain, BackupSkip: - default: - return fmt.Errorf("delivery: the backup answer %q is not %s or %s", w.Backup, BackupAgain, BackupSkip) - } - if w.Newest == "" && w.Rule == 0 { - return nil - } - if err := checkFingerprint(w.Newest, "a want's newest fingerprint"); err != nil { - return err - } - if w.Newest == w.FP { - return fmt.Errorf("delivery: a want at its newest fingerprint names no rule holding it back") - } - return checkRule(w.Rule, "a want held back") -} - -// Held is a fingerprint the assigner holds: the node whose health check failed on it, and the check's words. -type Held struct { - V int `json:"v"` - Node string `json:"node"` - Words string `json:"words,omitempty"` -} - -func (h Held) check() error { - return checkName("node", h.Node) -} - -// Difference is where a want and what the controller sends differ for a module on a node (ADR 0528 clause 4): the -// want's fingerprint (Want, empty when the node has no want for the module, WantRemoved when the want is removed), the -// fingerprint of the build the controller's assignment names (Sent, empty when it sends none or the build names no -// fingerprint; SentBuild names that build), and the rule that explains the difference, or Unexplained. -type Difference struct { - V int `json:"v"` - Want string `json:"want,omitempty"` - WantRemoved bool `json:"want-removed,omitempty"` - Sent string `json:"sent,omitempty"` - SentBuild string `json:"sent-build,omitempty"` - Rule int `json:"rule,omitempty"` - Unexplained bool `json:"unexplained,omitempty"` - Words string `json:"words,omitempty"` -} - -func (d Difference) check() error { - if d.WantRemoved && d.Want != "" { - return fmt.Errorf("delivery: a difference with a removed want names no wanted fingerprint") - } - if d.Want != "" { - if err := checkFingerprint(d.Want, "a difference's want"); err != nil { - return err - } - } - if d.Sent != "" { - if err := checkFingerprint(d.Sent, "a difference's sent fingerprint"); err != nil { - return err - } - } - if !d.WantRemoved && d.Want == d.Sent && (d.Want != "" || d.SentBuild == "") { - return fmt.Errorf("delivery: a difference whose want and send are both %q is no difference", d.Want) - } - if d.Unexplained { - if d.Rule != 0 { - return fmt.Errorf("delivery: an unexplained difference names no rule") - } - return nil - } - return checkRule(d.Rule, "a difference") -} - -// EncodeWant is w as JSON at Version; it refuses a fingerprint not of the new form, a removed want naming one, and a -// want held back without its rule. -func EncodeWant(w Want) ([]byte, error) { - if err := w.check(); err != nil { - return nil, err - } - w.V = Version - return json.Marshal(w) -} - -// DecodeWant reads a want; it refuses an unknown version (ErrUnknownVersion) and what EncodeWant refuses. -func DecodeWant(data []byte) (Want, error) { - var w Want - if err := decode(data, &w); err != nil { - return Want{}, err - } - if err := w.check(); err != nil { - return Want{}, err - } - return w, nil -} - -// SameWant says whether two wants are the same value, so the assigner writes only when they differ. -func SameWant(a, b Want) bool { return a == b } - -// NodeWant is a node's own want (WantNodeKey): the digest Composed of the node's own composition it may apply. Words -// say why in a sentence. -type NodeWant struct { - V int `json:"v"` - Composed string `json:"composed"` - Words string `json:"words,omitempty"` - // Self says the node composes its own composition (ADR 0547 §2) and the want names none. - Self bool `json:"self,omitempty"` -} - -// EncodeNodeWant is w as JSON at Version; it refuses a digest not of the sha256 form, and a want that names no -// composition unless it says the node composes its own (Self, ADR 0547 §2). -func EncodeNodeWant(w NodeWant) ([]byte, error) { - if err := w.check(); err != nil { - return nil, err - } - w.V = Version - return json.Marshal(w) -} - -// DecodeNodeWant reads a node's own want; it refuses an unknown version (ErrUnknownVersion), a field a node's own want -// does not have (a fingerprint among them), and what EncodeNodeWant refuses. -func DecodeNodeWant(data []byte) (NodeWant, error) { - if err := checkVersion(data); err != nil { - return NodeWant{}, err - } - var w NodeWant - d := json.NewDecoder(bytes.NewReader(data)) - d.DisallowUnknownFields() - if err := d.Decode(&w); err != nil { - return NodeWant{}, fmt.Errorf("delivery: a node's own want does not fit its shape: %w", err) - } - if err := w.check(); err != nil { - return NodeWant{}, err - } - return w, nil -} - -// check refuses a node's own want that names no composition and does not say the node composes its own, or does both. -func (w NodeWant) check() error { - switch { - case w.Self && w.Composed != "": - return fmt.Errorf("delivery: the node's own want says the node composes it, and names a composition") - case w.Self: - return nil - } - return checkDigest(w.Composed, "the node's own want's composition digest") -} - -// EncodeHeld is h as JSON at Version; it refuses a node name a key refuses. -func EncodeHeld(h Held) ([]byte, error) { - if err := h.check(); err != nil { - return nil, err - } - h.V = Version - return json.Marshal(h) -} - -// DecodeHeld reads a hold; it refuses an unknown version (ErrUnknownVersion) and what EncodeHeld refuses. -func DecodeHeld(data []byte) (Held, error) { - var h Held - if err := decode(data, &h); err != nil { - return Held{}, err - } - if err := h.check(); err != nil { - return Held{}, err - } - return h, nil -} - -// SameHeld says whether two holds are the same value. -func SameHeld(a, b Held) bool { return a == b } - -// EncodeDifference is d as JSON at Version; it refuses a difference with neither a rule nor unexplained, one with both, -// a rule outside the six, and a want equal to the send. -func EncodeDifference(d Difference) ([]byte, error) { - if err := d.check(); err != nil { - return nil, err - } - d.V = Version - return json.Marshal(d) -} - -// DecodeDifference reads a difference; it refuses an unknown version (ErrUnknownVersion) and what EncodeDifference -// refuses. -func DecodeDifference(data []byte) (Difference, error) { - var d Difference - if err := decode(data, &d); err != nil { - return Difference{}, err - } - if err := d.check(); err != nil { - return Difference{}, err - } - return d, nil -} - -// SameDifference says whether two differences are the same value. -func SameDifference(a, b Difference) bool { return reflect.DeepEqual(a, b) } - -// The assigner's composition check (novox/hq ADR 0446 clauses 1 to 5, ADR 0528 clause 5, ADR 0482 clauses 3 and 9): for -// each group the checker judges, the assigner composes the machines the change touches and writes its verdict under -// the checker's key of that check, `.` (CheckerVerdictKey's form), in its bucket -// compositions. The checker watches that key and reads it into its verdict's composition (Composition); a key not yet -// written is a composition still running. -const ( - // AssignerCompositionsBucket is the assigner's bucket of composition verdicts. - AssignerCompositionsBucket = AssignerModule + "_compositions" - // AssignerCompositionsHistory is the revisions the bucket keeps per key. - AssignerCompositionsHistory = 5 - // CompositionWaits is the state of a composition that waits for what it composes from (ADR 0446 clause 3: the - // indexer holds no candidates for the group yet), its Words saying what; it is no verdict. - CompositionWaits = "waits" - // CompositionRebuildWarning is the most modules a merge may rebuild before the composition warns (ADR 0446 clause 1). - CompositionRebuildWarning = 12 -) - -// CompositionKey is the key, in AssignerCompositionsBucket, of the composition of a group's check at its heads: the -// checker's verdict key of the same check (CheckerVerdictKey). -func CompositionKey(group, headsHash string) (string, error) { - return CheckerVerdictKey(group, headsHash) -} - -// ParseCompositionKey is the inverse of CompositionKey; it refuses a want, held or difference key, naming it. -func ParseCompositionKey(key string) (group, headsHash string, err error) { - if group, headsHash, err = ParseCheckerVerdictKey(key); err != nil { - return "", "", fmt.Errorf("delivery: %q is not a composition key, .: %w", key, err) - } - return group, headsHash, nil -} - -// CompositionUnanswered is the Words of a composition that failed only because a node placed at the head or on the -// trunk did not answer the group's placement candidate within its bound (novox/hq ADR 0559 §5): the checker takes a -// later passing composition of the same heads in without a new check, since the failure was a part of the mesh not -// heard, not the change (ADR 0548 §1). -const CompositionUnanswered = "did not answer the placement candidate within its bound; judged again when it answers" - -// CompositionVerdict is the value of a composition key: the group and its heads hash, the State (pass, fail or waits), -// the Words of a refusal or of what it waits for, the mesh-sdk commit it composed with (the assigner's own, or the -// group's mesh-sdk member's head, ADR 0482 clause 3), the machines composed, the first machine that failed, the -// warnings that fail nothing (more than twelve modules rebuilt; what was already broken before the change, ADR 0446 -// clause 1), and the time it took in integer milliseconds. -type CompositionVerdict struct { - V int `json:"v"` - Group string `json:"group"` - Heads string `json:"heads"` - State string `json:"state"` - Words string `json:"words,omitempty"` - SDK string `json:"sdk,omitempty"` - Machines []string `json:"machines,omitempty"` - Machine string `json:"machine,omitempty"` - Warnings []string `json:"warnings,omitempty"` - Took int64 `json:"took,omitempty"` -} - -func (c CompositionVerdict) check() error { - if c.Group == "" { - return fmt.Errorf("delivery: a composition names no group") - } - if !isHex12(c.Heads) { - return fmt.Errorf("delivery: the composition's heads hash %q is not 12 lowercase hex digits", c.Heads) - } - if c.Took < 0 { - return fmt.Errorf("delivery: the composition's time is negative") - } - switch c.State { - case CompositionPass: - if c.SDK == "" { - return fmt.Errorf("delivery: a composition that passed names no mesh-sdk commit") - } - if c.Machine != "" || c.Words != "" { - return fmt.Errorf("delivery: a composition that passed names no failure") - } - case CompositionFail: - if c.Words == "" { - return fmt.Errorf("delivery: a composition that failed gives no words") - } - if c.SDK == "" { - return fmt.Errorf("delivery: a composition that failed names no mesh-sdk commit") - } - case CompositionWaits: - if c.Words == "" { - return fmt.Errorf("delivery: a composition that waits does not say for what") - } - if c.Machine != "" || len(c.Machines) > 0 { - return fmt.Errorf("delivery: a composition that waits composed no machine") - } - default: - return fmt.Errorf("delivery: the composition state %q is not %s, %s or %s", c.State, CompositionPass, CompositionFail, CompositionWaits) - } - return nil -} - -// Composition is what the checker writes into its verdict from c: nil while c waits (the check is still running). -func (c CompositionVerdict) Composition() *Composition { - if c.State == CompositionWaits { - return nil - } - return &Composition{State: c.State, Words: c.Words, SDK: c.SDK, Machines: c.Machines} -} - -// EncodeCompositionVerdict is c as JSON at Version; it refuses an unknown state, a pass or fail naming no mesh-sdk -// commit, a fail without words, a pass naming a failure, and a wait that composed a machine. -func EncodeCompositionVerdict(c CompositionVerdict) ([]byte, error) { - if err := c.check(); err != nil { - return nil, err - } - c.V = Version - return json.Marshal(c) -} - -// DecodeCompositionVerdict reads a composition; it refuses an unknown version (ErrUnknownVersion) and what -// EncodeCompositionVerdict refuses. -func DecodeCompositionVerdict(data []byte) (CompositionVerdict, error) { - var c CompositionVerdict - if err := decode(data, &c); err != nil { - return CompositionVerdict{}, err - } - if err := c.check(); err != nil { - return CompositionVerdict{}, err - } - return c, nil -} - -// SameCompositionVerdict says whether two compositions are the same value. -func SameCompositionVerdict(a, b CompositionVerdict) bool { return reflect.DeepEqual(a, b) } diff --git a/go/delivery/assigner_test.go b/go/delivery/assigner_test.go deleted file mode 100644 index dfad9f2..0000000 --- a/go/delivery/assigner_test.go +++ /dev/null @@ -1,319 +0,0 @@ -package delivery - -import ( - "errors" - "strings" - "testing" - "time" -) - -// 1297/B1a: a want key round-trips through its parser; a held key, a have key and a node's own key are refused, -// naming the key. -func TestWantKey(t *testing.T) { - key, err := WantKey("ace", "mesh-mail") - if err != nil || key != "want.ace.mesh-mail" { - t.Fatalf("WantKey = %q, %v", key, err) - } - node, module, err := ParseWantKey(key) - if err != nil || node != "ace" || module != "mesh-mail" { - t.Errorf("ParseWantKey = %q, %q, %v", node, module, err) - } - for _, n := range [][2]string{{"ace", "node"}, {"a.b", "m"}, {"ace", ""}, {"", "m"}, {"ace", "m*"}} { - if _, err := WantKey(n[0], n[1]); err == nil { - t.Errorf("WantKey(%q, %q) accepted", n[0], n[1]) - } - } - held, _ := HeldKey("mesh-mail", fp2of('a')) - for _, bad := range []string{"ace.mesh-mail", "want.ace.node", "want.ace", "want.ace.m.x", held, "wants.ace.m", ""} { - if _, _, err := ParseWantKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ParseWantKey(%q) = %v, want a refusal naming it", bad, err) - } - } -} - -// 1297/B1a: a held key round-trips; a want key, a builder build key and a fingerprint not of the new form are refused. -func TestHeldKey(t *testing.T) { - fp := fp2of('b') - key, err := HeldKey("mesh-mail", fp) - if err != nil || key != "held.mesh-mail."+strings.Repeat("b", 64) { - t.Fatalf("HeldKey = %q, %v", key, err) - } - module, back, err := ParseHeldKey(key) - if err != nil || module != "mesh-mail" || back != fp { - t.Errorf("ParseHeldKey = %q, %q, %v", module, back, err) - } - src1 := SourcePrefix + strings.Repeat("1", 64) - if _, err := HeldKey("mesh-mail", src1); err == nil { - t.Errorf("a src1 fingerprint was held") - } - build, _ := BuilderBuildKey("mesh-mail", fp) - for _, bad := range []string{build, "want.ace.mesh-mail", "held.mesh-mail.abc", "held." + strings.Repeat("b", 64), ""} { - if _, _, err := ParseHeldKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ParseHeldKey(%q) = %v, want a refusal naming it", bad, err) - } - } -} - -// 1297/B1a: a difference key round-trips; a want or held key and a node's own key are refused. -func TestDifferenceKey(t *testing.T) { - key, err := DifferenceKey("g14", "mesh-mail") - if err != nil || key != "g14.mesh-mail" { - t.Fatalf("DifferenceKey = %q, %v", key, err) - } - node, module, err := ParseDifferenceKey(key) - if err != nil || node != "g14" || module != "mesh-mail" { - t.Errorf("ParseDifferenceKey = %q, %q, %v", node, module, err) - } - held, _ := HeldKey("mesh-mail", fp2of('a')) - for _, bad := range []string{"want.g14.mesh-mail", held, "g14.node", "g14", ""} { - if _, _, err := ParseDifferenceKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ParseDifferenceKey(%q) = %v, want a refusal naming it", bad, err) - } - } -} - -// 1297/B1a: wants, holds and differences round-trip through their encoders unchanged, at Version. -func TestAssignerValuesRoundTrip(t *testing.T) { - wants := []Want{ - {FP: fp2of('a'), Composed: hashOf('1')}, - {FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: RuleCanary, Words: "the canary ace does not run it healthy yet"}, - {Removed: true, Words: "the module left g14"}, - {FP: fp2of('a'), Composed: hashOf('1'), Put: true, Hold: true, Words: "put by the operator: the bus's user list"}, - // A rule a later release numbers (9, 10: the verb wait, the capability hold, ADR 0614) reads here already. - {FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 10, Words: "the verb x.y it invokes is served by no running build"}, - } - for _, w := range wants { - data, err := EncodeWant(w) - if err != nil { - t.Fatalf("EncodeWant(%+v): %v", w, err) - } - got, err := DecodeWant(data) - w.V = Version - if err != nil || !SameWant(got, w) { - t.Errorf("want %+v came back %+v, %v", w, got, err) - } - } - at := time.Date(2026, 10, 25, 21, 0, 0, 0, time.UTC) - p := Put{FP: fp2of('a'), Hold: true, Why: "the bus's user list", By: "shanks.node-tools", At: at}.Said(at, PutAsked, "put asked") - p = p.Said(at, PutAsked, "put asked").Said(at.Add(time.Second), PutWanted, "want written") - p.Composed = hashOf('1') - if len(p.Steps) != 2 || p.Step != PutWanted || PutEnded(p.Step) || !PutEnded(PutReleased) { - t.Errorf("a put's steps are %+v at %s", p.Steps, p.Step) - } - pkey, _ := PutKey("novox", "nats") - if n, m, err := ParsePutKey(pkey); err != nil || n != "novox" || m != "nats" { - t.Errorf("the put key %s reads %s %s, %v", pkey, n, m, err) - } - pdata, err := EncodePut(p) - if err != nil { - t.Fatal(err) - } - if gotP, err := DecodePut(pdata); err != nil || gotP.FP != p.FP || gotP.Step != PutWanted || len(gotP.Steps) != 2 || !gotP.Hold { - t.Errorf("the put came back %+v, %v", gotP, err) - } - h := Held{Node: "ace", Words: "its health check failed: connection refused"} - data, err := EncodeHeld(h) - if err != nil { - t.Fatal(err) - } - gotH, err := DecodeHeld(data) - h.V = Version - if err != nil || !SameHeld(gotH, h) { - t.Errorf("held came back %+v, %v", gotH, err) - } - diffs := []Difference{ - {Want: fp2of('a'), Sent: fp2of('b'), Rule: RuleCanary}, - {Sent: fp2of('b'), Rule: RuleBuildDone}, - {WantRemoved: true, Sent: fp2of('b'), Unexplained: true}, - {SentBuild: "build-17", Rule: RuleGrant}, - } - for _, d := range diffs { - data, err := EncodeDifference(d) - if err != nil { - t.Fatalf("EncodeDifference(%+v): %v", d, err) - } - got, err := DecodeDifference(data) - d.V = Version - if err != nil || !SameDifference(got, d) { - t.Errorf("difference %+v came back %+v, %v", d, got, err) - } - } -} - -// 1297/B1a: what a want, a hold and a difference may not say is refused on encode and on decode. -func TestAssignerValuesRefused(t *testing.T) { - src1 := SourcePrefix + strings.Repeat("1", 64) - for _, w := range []Want{ - {}, - {FP: src1}, - {Removed: true, FP: fp2of('a')}, - {FP: fp2of('a'), Newest: fp2of('b')}, - {FP: fp2of('a'), Newest: fp2of('a'), Rule: 3}, - {FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 11}, - {FP: fp2of('a'), Composed: hashOf('1'), Hold: true}, - {FP: fp2of('a'), Composed: hashOf('1'), Put: true, Newest: fp2of('b'), Rule: 3}, - {Removed: true, Put: true}, - } { - if _, err := EncodeWant(w); err == nil { - t.Errorf("EncodeWant(%+v) accepted", w) - } - } - if _, err := EncodeHeld(Held{Node: "a.b"}); err == nil { - t.Errorf("a held naming a dotted node was accepted") - } - for _, d := range []Difference{ - {Want: fp2of('a'), Sent: fp2of('b')}, - {Want: fp2of('a'), Sent: fp2of('b'), Rule: 3, Unexplained: true}, - {Want: fp2of('a'), Sent: fp2of('a'), Rule: 3}, - {}, - {Want: fp2of('a'), Sent: fp2of('b'), Rule: 11}, - {WantRemoved: true, Want: fp2of('a'), Rule: 1}, - } { - if _, err := EncodeDifference(d); err == nil { - t.Errorf("EncodeDifference(%+v) accepted", d) - } - } - for _, p := range []Put{ - {FP: fp2of('a'), Why: "w", Step: "walking"}, - {FP: fp2of('a'), Step: PutAsked}, - {FP: "src2:abc", Why: "w", Step: PutAsked}, - {FP: fp2of('a'), Why: "w", Step: PutWanted, Composed: "sha256:x"}, - } { - if _, err := EncodePut(p); err == nil { - t.Errorf("EncodePut(%+v) accepted", p) - } - } - if _, _, err := ParsePutKey("ace.node"); err == nil { - t.Errorf("a node's own key was read as a put") - } - if _, err := DecodeWant([]byte(`{"v":2,"fp":"` + fp2of('a') + `"}`)); !errors.Is(err, ErrUnknownVersion) { - t.Errorf("a want of version 2 was read: %v", err) - } - if _, err := DecodeDifference([]byte(`{"v":1,"want":"` + fp2of('a') + `","sent":"` + fp2of('b') + `"}`)); err == nil { - t.Errorf("a difference naming no rule was read") - } -} - -// 1297/B1b: a composition key is the checker's verdict key of the same check, and round-trips; a want, held or -// difference key is refused, naming it. -func TestCompositionKey(t *testing.T) { - key, err := CompositionKey("feat/r4g", "0123456789ab") - verdict, _ := CheckerVerdictKey("feat/r4g", "0123456789ab") - if err != nil || key != verdict { - t.Fatalf("CompositionKey = %q, %v; the checker's key is %q", key, err, verdict) - } - group, heads, err := ParseCompositionKey(key) - if err != nil || group != "feat/r4g" || heads != "0123456789ab" { - t.Errorf("ParseCompositionKey = %q, %q, %v", group, heads, err) - } - held, _ := HeldKey("mesh-mail", fp2of('a')) - for _, bad := range []string{"want.ace.mesh-mail", held, "ace.mesh-mail", "g.0123456789AB", ""} { - if _, _, err := ParseCompositionKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ParseCompositionKey(%q) = %v, want a refusal naming it", bad, err) - } - } -} - -// 1297/B1b: a composition in each state round-trips; the checker's composition of a pass or fail carries its state, -// words, mesh-sdk commit and machines, and a wait gives none. -func TestCompositionVerdictRoundTrip(t *testing.T) { - for _, c := range []CompositionVerdict{ - {Group: "g", Heads: "0123456789ab", State: CompositionPass, SDK: "7aacbc1", Machines: []string{"ace"}, Took: 120}, - {Group: "g", Heads: "0123456789ab", State: CompositionFail, SDK: "7aacbc1", Machines: []string{"ace", "g14"}, Machine: "g14", - Words: "g14 runs mesh-mail, which the change removes from its source", Warnings: []string{"the merge rebuilds 13 modules"}}, - {Group: "g", Heads: "0123456789ab", State: CompositionWaits, Words: "the indexer holds no candidates for the group yet"}, - } { - data, err := EncodeCompositionVerdict(c) - if err != nil { - t.Fatalf("EncodeCompositionVerdict(%+v): %v", c, err) - } - got, err := DecodeCompositionVerdict(data) - c.V = Version - if err != nil || !SameCompositionVerdict(got, c) { - t.Errorf("composition %+v came back %+v, %v", c, got, err) - } - comp := got.Composition() - if c.State == CompositionWaits { - if comp != nil { - t.Errorf("a wait gave the checker a composition %+v", comp) - } - continue - } - if comp == nil || comp.State != c.State || comp.SDK != c.SDK || comp.Words != c.Words || len(comp.Machines) != len(c.Machines) { - t.Errorf("the checker's composition of %+v is %+v", c, comp) - } - if err := comp.check(); err != nil { - t.Errorf("the checker's composition of %+v is refused by the verdict: %v", c, err) - } - } -} - -// 1297/B1b: what a composition may not say is refused. -func TestCompositionVerdictRefused(t *testing.T) { - for _, c := range []CompositionVerdict{ - {Heads: "0123456789ab", State: CompositionPass, SDK: "x"}, - {Group: "g", Heads: "0123", State: CompositionPass, SDK: "x"}, - {Group: "g", Heads: "0123456789ab", State: CompositionPass}, - {Group: "g", Heads: "0123456789ab", State: CompositionPass, SDK: "x", Machine: "ace"}, - {Group: "g", Heads: "0123456789ab", State: CompositionFail, SDK: "x"}, - {Group: "g", Heads: "0123456789ab", State: CompositionFail, Words: "w"}, - {Group: "g", Heads: "0123456789ab", State: CompositionWaits}, - {Group: "g", Heads: "0123456789ab", State: CompositionWaits, Words: "w", Machines: []string{"ace"}}, - {Group: "g", Heads: "0123456789ab", State: CompositionNotRun}, - {Group: "g", Heads: "0123456789ab", State: CompositionPass, SDK: "x", Took: -1}, - } { - if _, err := EncodeCompositionVerdict(c); err == nil { - t.Errorf("EncodeCompositionVerdict(%+v) accepted", c) - } - } -} - -// 1394/B1b: a grant key round-trips; a want or held key is refused, naming it; a grant round-trips and one naming an -// empty account is refused. -func TestTheGrantsIssued(t *testing.T) { - fp := fp2of('c') - key, err := PartGrantKey("mesh-delivery-assigner", fp) - if err != nil || key != "mesh-delivery-assigner."+strings.Repeat("c", 64) { - t.Fatalf("PartGrantKey = %q, %v", key, err) - } - module, back, err := ParsePartGrantKey(key) - if err != nil || module != "mesh-delivery-assigner" || back != fp { - t.Errorf("ParsePartGrantKey = %q, %q, %v", module, back, err) - } - held, _ := HeldKey("mesh-mail", fp) - for _, bad := range []string{"want.ace.mesh-mail", held, "ace.mesh-mail", ""} { - if _, _, err := ParsePartGrantKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ParsePartGrantKey(%q) = %v, want a refusal naming it", bad, err) - } - } - for _, g := range []Grant{{}, {Accounts: []string{"UABC", "UDEF"}}} { - data, err := EncodeGrant(g) - if err != nil { - t.Fatal(err) - } - got, err := DecodeGrant(data) - g.V = Version - if err != nil || !SameGrant(got, g) { - t.Errorf("grant %+v came back %+v, %v", g, got, err) - } - } - if _, err := EncodeGrant(Grant{Accounts: []string{""}}); err == nil { - t.Errorf("a grant naming an empty account was accepted") - } - g := Grant{Accounts: []string{"ace.mesh-build-go", "shanks.mesh-mail.reader"}} - for _, c := range []struct { - g Grant - node, module string - names bool - }{ - {g, "ace", "mesh-build-go", true}, - {g, "shanks", "mesh-mail", true}, - {g, "g14", "mesh-build-go", false}, - {g, "ace", "mesh-build", false}, - {Grant{}, "novox", "nats", true}, - } { - if got := c.g.NamesNode(c.node, c.module); got != c.names { - t.Errorf("%v names %s.%s: %v, want %v", c.g.Accounts, c.node, c.module, got, c.names) - } - } -} diff --git a/go/delivery/builder.go b/go/delivery/builder.go deleted file mode 100644 index 185a90d..0000000 --- a/go/delivery/builder.go +++ /dev/null @@ -1,348 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "reflect" - "slices" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// The builder's keys (novox/hq ADR 0429 clauses 5 and 16, ADR 0437 clause 5, ADR 0405 clauses 2 and 7, ADR 0453 -// clause 4): a build is `.` in the builder's bucket, one key per fingerprint, since equal -// fingerprints are equal content and the same source is never built twice. They are written only by the builder part -// from R4c and read by the checker, the assigner and the view. They are not the ended controller's build records -// (buildrecord.go: BuildKey). -// -// **The rules its writer keeps.** A builder claims a fingerprint that has no build key by creating it queued or -// building, a create that fails when the key exists, and moves it by compare-and-set; a builder reclaims only a build -// whose `by` is offline in presence. A failed key is built again as a new revision of the same key (ADR 0429 clause -// 18). Only the builder's credential writes the bucket (clause 5); every other part reads it. -const ( - // BuilderModule is the module of the builder part. - BuilderModule = "mesh-delivery-builder" - // BuilderBuildsBucket is the builder's bucket of builds: `_`. - BuilderBuildsBucket = BuilderModule + "_builds" - // BuilderBuildsHistory is the revisions the bucket keeps per key. - BuilderBuildsHistory = 5 -) - -// The states of a build. -const ( - BuildQueued = "queued" - BuildBuilding = "building" - BuildDone = "done" - BuildFailed = "failed" -) - -// BuilderBuildKey is the key, in BuilderBuildsBucket, of a module's build at a fingerprint: `.`, the hex -// being the fingerprint's key part (FingerprintKeyPart), since a bus key may not hold ':'. A module holding a dot or a -// character a key refuses, and a fingerprint not of the new form, are refused, naming them. -func BuilderBuildKey(module, fingerprint string) (string, error) { - if err := checkName("module", module); err != nil { - return "", err - } - part, err := FingerprintKeyPart(fingerprint) - if err != nil { - return "", err - } - return module + "." + part, nil -} - -// ParseBuilderBuildKey is the inverse of BuilderBuildKey: the module and the fingerprint (`src2:` and the hex). -func ParseBuilderBuildKey(key string) (module, fingerprint string, err error) { - module, part, ok := strings.Cut(key, ".") - if !ok { - return "", "", fmt.Errorf("delivery: %q is not a builder build key, .", key) - } - if err := checkName("module", module); err != nil { - return "", "", fmt.Errorf("delivery: %q is not a builder build key: %w", key, err) - } - if fingerprint, err = ParseFingerprintKeyPart(part); err != nil { - return "", "", fmt.Errorf("delivery: %q is not a builder build key: %w", key, err) - } - return module, fingerprint, nil -} - -// BuiltArtifact is one artifact of a done build: its name in the manifest, its kind, its content digest (`sha256:` and -// 64 hex digits) and the reference that names where it is (novox/hq ADR 0498 clauses 1, 5 and 6; the lead's decision -// L1 of R4e), by kind: -// -// bundle, archive artifact-store:////blobs/ the blob pushed to the artifact store -// image, upstream //@ the image registry's reference, which -// artifact-store:///@ catalogue.Recorded keeps without its address -// package @ the version published from the trunk -// -// From the registry milestone a done key names the reference its seat answered (novox/hq ADR 0605 §9, design 51 *The -// done key names the reference the seat answered*): `//@`, the registry seat it -// came through (oci-artifact-registry for a bundle or an archive, container-image-registry for an image or an upstream -// copy), and `@` for a package as before. A key written in the older form keeps it; this reads both, and -// no build is built again for its form (catalogue.InArtifactStore reads both the same way). -// -// is Name and is Digest. A reference is empty for a candidate's image or package, which is never -// pushed or published (ADR 0498 clause 6), and in a key done before R4e; the controller delivers only from a key whose -// every artifact names one (Referenced, clause 8). A kind left empty (a key written before kinds) is not checked. -type BuiltArtifact struct { - Name string `json:"name"` - Kind string `json:"kind,omitempty"` - Digest string `json:"digest"` - Reference string `json:"reference,omitempty"` - // References are every form this artifact is named in, Reference among them (novox/hq ADR 0618, ADR 0624): a build - // keeps each form that a counted node-engine states, away nodes included, and Reference stays the form every one - // of them reads. A reader that does not know the field reads Reference alone. - References []string `json:"references,omitempty"` -} - -const artifactStore = "artifact-store://" - -// referenceProblem says what is wrong with a's reference for its kind, or "". -func (a BuiltArtifact) referenceProblem() string { - r := a.Reference - if r == "" || a.Kind == "" { - return "" - } - // An upstream copy may be named by the holder's own naming, upstream//@, in either form: the - // holder names it, not the module that asked for it (catalogue.InUpstream). - if path, kept := catalogue.InArtifactStore(r); kept && a.Kind == "upstream" { - if _, image, ok := catalogue.InUpstream(path); ok { - if _, digest, _ := strings.Cut(image, "@"); digest != a.Digest { - return "does not name its digest " + a.Digest - } - return "" - } - } - if seat, path, ok := catalogue.InRegistrySeat(r); ok { - want := "container-image-registry" - if a.Kind == "bundle" || a.Kind == "archive" { - want = "oci-artifact-registry" - } - module, rest, _ := strings.Cut(path, "/") - if a.Kind == "package" || seat != want || module == "" || rest != a.Name+"@"+a.Digest { - return "is not " + want + "//" + a.Name + "@" + a.Digest - } - return "" - } - switch a.Kind { - case "bundle", "archive": - path, ok := strings.CutPrefix(r, artifactStore) - module, rest, _ := strings.Cut(path, "/") - if !ok || module == "" || rest != a.Name+"/blobs/"+a.Digest { - return "is not artifact-store:///" + a.Name + "/blobs/" + a.Digest - } - case "image", "upstream": - path, digest, ok := strings.Cut(r, "@") - if !ok || digest != a.Digest { - return "does not name its digest " + a.Digest - } - var host, module, name string - if rest, kept := strings.CutPrefix(path, artifactStore); kept { - module, name, _ = strings.Cut(rest, "/") - host = "kept" - } else { - parts := strings.Split(path, "/") - if len(parts) == 3 { - host, module, name = parts[0], parts[1], parts[2] - } - } - if host == "" || !(host == "kept" || host == "localhost" || strings.ContainsAny(host, ".:")) || module == "" || name != a.Name { - return "is not //" + a.Name + "@" + a.Digest - } - case "package": - at := strings.LastIndex(r, "@") - if at <= 0 || at == len(r)-1 || r[:at] == "@" { - return "is not @" - } - } - return "" -} - -// Referenced says whether b is done and every artifact names where it is, which the controller delivers from (novox/hq -// ADR 0498 clause 8), and names the artifacts that name no reference. A done key with no artifact (a module that builds -// none, issue 1613) is referenced. -func (b BuilderBuild) Referenced() (ok bool, missing []string) { - for _, a := range b.Artifacts { - if a.Reference == "" { - missing = append(missing, a.Name) - } - } - return b.State == BuildDone && len(missing) == 0, missing -} - -// The prefixes of a failed key's error that a build module answers (novox/hq ADR 0498 clauses 2, 4 and 5; ADR 0478 -// clause 2), each followed by the artifact and the words of what refused it. -const ( - // NotStored: the push of a bundle, an archive or an upstream copy to the artifact store failed; built again. - NotStored = "not stored: " - // NotPinned: an upstream artifact names no digest. - NotPinned = "not pinned: " - // VersionTaken: the package registry holds the package's version with another packed file. - VersionTaken = "version taken: " - // RefusedKind: no build module builds this kind of artifact in its language. - RefusedKind = "refused kind: " - // WaitsFor: a fetch found the module's lock naming a package version the registry does not hold yet; the builder - // writes the key queued with Waits (novox/hq ADR 0500 clause 6), and it is built once the package is published. - WaitsFor = "waits for: " -) - -// FailedUntilChanged says whether a failed key's error is one that is not built again until the module's fingerprint -// changes (not pinned, version taken, refused kind: the same source fails the same way); every other failed key, a -// push that was not stored included, is built again as a new revision (ADR 0429 clause 18). -func FailedUntilChanged(err string) bool { - return strings.HasPrefix(err, NotPinned) || strings.HasPrefix(err, VersionTaken) || strings.HasPrefix(err, RefusedKind) -} - -// ToolchainUsed is what a build used for one language (ADR 0405 clauses 2 and 7): the installed toolchain's version and -// the build module's recipe version. -type ToolchainUsed struct { - Version string `json:"version"` - Recipe string `json:"recipe"` -} - -// BuilderBuild is the value of a builder build key: its State (queued, building, done or failed), By the builder -// instance that holds it, Step what it is doing or did last, Manifest the digest of the module's manifest (the key of -// the indexer's manifests bucket), Artifacts when done, Toolchains by language when it built or is building (a done -// build names them, so two builders that differ are seen: ADR 0405 clause 2), Error when failed, and Waits when queued for a -// package version. -type BuilderBuild struct { - V int `json:"v"` - State string `json:"state"` - By string `json:"by,omitempty"` - Step string `json:"step,omitempty"` - Manifest string `json:"manifest,omitempty"` - Artifacts []BuiltArtifact `json:"artifacts,omitempty"` - Toolchains map[string]ToolchainUsed `json:"toolchains,omitempty"` - Error string `json:"error,omitempty"` - // Waits is, on a queued key only, the package version `@` the module's lock names and the registry - // does not yet hold: the key is not claimed until that package's trunk key is done and published (novox/hq ADR 0500 - // clause 6). A reader that does not know the field reads the key as queued. - Waits string `json:"waits,omitempty"` - // Asks are, on a building key, the asks the builder put for it by build seat language (AskGo, AskNode, AskImage), each - // its id (novox/hq ADR 0525 §1; issue 1887 A4b): a builder that takes the key over at its start waits for those asks' - // steps and never puts them again. A reader that does not know the field reads the key as before. - Asks map[string]string `json:"asks,omitempty"` - // Imports are, on a done key, the mesh packages the built programs import, as the build module answered them - // (BuildAnswer.Imports; novox/hq ADR 0593 §2): the builder carries them onto the build's end (BuildLogEntry.Imports). - Imports []string `json:"imports,omitempty"` - // Needs are, on a done key only, the reader capabilities (`form:`) the build's reference forms need of a - // node-engine, beside the ones its manifest needs (novox/hq ADR 0618 §1). A reader that does not know the field - // reads the build as needing none. - Needs []string `json:"needs,omitempty"` -} - -func (b BuilderBuild) check() error { - if err := CheckImports(b.Imports); err != nil { - return err - } - switch b.State { - case BuildQueued, BuildBuilding, BuildDone, BuildFailed: - default: - return fmt.Errorf("delivery: the build state %q is not queued, building, done or failed", b.State) - } - if b.Manifest != "" { - if _, err := ManifestsKey(b.Manifest); err != nil { - return err - } - } - for language, t := range b.Toolchains { - if t.Version == "" { - return fmt.Errorf("delivery: the toolchain of %q has no version", language) - } - if t.Recipe == "" { - return fmt.Errorf("delivery: the toolchain of %q has no recipe version", language) - } - } - for i, a := range b.Artifacts { - if a.Name == "" { - return fmt.Errorf("delivery: artifact %d of the build has no name", i) - } - if a.Digest == "" { - return fmt.Errorf("delivery: the artifact %q of the build has no digest", a.Name) - } - if p := a.referenceProblem(); p != "" { - return fmt.Errorf("delivery: the reference %q of the %s %q %s", a.Reference, a.Kind, a.Name, p) - } - if len(a.References) > 0 && !slices.Contains(a.References, a.Reference) { - return fmt.Errorf("delivery: the references of the %s %q do not name its reference %q", a.Kind, a.Name, a.Reference) - } - for _, r := range a.References { - if p := (BuiltArtifact{Name: a.Name, Kind: a.Kind, Digest: a.Digest, Reference: r}).referenceProblem(); p != "" { - return fmt.Errorf("delivery: the reference %q of the %s %q %s", r, a.Kind, a.Name, p) - } - } - } - for _, n := range b.Needs { - if !strings.HasPrefix(n, "form:") || len(n) == len("form:") { - return fmt.Errorf("delivery: the need %q of the build is not form:", n) - } - } - if len(b.Needs) > 0 && b.State != BuildDone { - return fmt.Errorf("delivery: a %s build names needs; only a done key does", b.State) - } - for language, id := range b.Asks { - if b.State != BuildBuilding { - return fmt.Errorf("delivery: a %s build names the ask %s; only a building key does", b.State, id) - } - if err := checkAskLanguage(language); err != nil { - return err - } - if err := checkAskID(id); err != nil { - return err - } - } - if b.Waits != "" { - if b.State != BuildQueued { - return fmt.Errorf("delivery: a %s build waits for %q; only a queued key waits for a package", b.State, b.Waits) - } - if p := (BuiltArtifact{Kind: "package", Reference: b.Waits}).referenceProblem(); p != "" { - return fmt.Errorf("delivery: the package a key waits for, %q, %s", b.Waits, p) - } - } - switch b.State { - case BuildQueued, BuildBuilding: - if len(b.Artifacts) > 0 || b.Error != "" { - return fmt.Errorf("delivery: a %s build carries no artifact and no error", b.State) - } - case BuildDone: - // A module that builds no artifact gets a done key too, and is delivered from it (issue 1613). - if b.Manifest == "" { - return fmt.Errorf("delivery: a done build names no manifest digest") - } - if b.Error != "" { - return fmt.Errorf("delivery: a done build carries an error") - } - case BuildFailed: - if b.Error == "" { - return fmt.Errorf("delivery: a failed build says no error") - } - } - return nil -} - -// EncodeBuilderBuild is b as JSON at Version; it refuses a build its state does not allow (done without a manifest -// digest, failed without an error, an artifact without a name or digest, a toolchain without a version or a -// recipe version). -func EncodeBuilderBuild(b BuilderBuild) ([]byte, error) { - if err := b.check(); err != nil { - return nil, err - } - b.V = Version - return json.Marshal(b) -} - -// DecodeBuilderBuild reads a builder build value; it refuses an unknown version (ErrUnknownVersion) and what -// EncodeBuilderBuild refuses. -func DecodeBuilderBuild(data []byte) (BuilderBuild, error) { - var b BuilderBuild - if err := decode(data, &b); err != nil { - return BuilderBuild{}, err - } - if err := b.check(); err != nil { - return BuilderBuild{}, err - } - return b, nil -} - -// SameBuilderBuild says whether two builds are the same value, so the builder writes only when they differ. -func SameBuilderBuild(a, b BuilderBuild) bool { return reflect.DeepEqual(a, b) } diff --git a/go/delivery/builder_reference_test.go b/go/delivery/builder_reference_test.go deleted file mode 100644 index db1ef2a..0000000 --- a/go/delivery/builder_reference_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package delivery - -import ( - "strings" - "testing" -) - -func artifactOf(name, kind, digest, reference string) BuiltArtifact { - return BuiltArtifact{Name: name, Kind: kind, Digest: digest, Reference: reference} -} - -// A done key names where each artifact is (novox/hq ADR 0498 clauses 1, 5 and 6; the lead's decision L1 of R4e): a -// bundle or an archive by its artifact-store blob, an image or an upstream copy by the registry's reference to its -// digest (or the kept artifact-store form), a package by name and version. A reference of the wrong form for its kind, -// or naming another digest or artifact, is refused, naming the artifact. -func TestADoneKeyNamesEachArtifactsReferenceInTheFormOfItsKind(t *testing.T) { - d := "sha256:" + digestOf('6') - good := []BuiltArtifact{ - artifactOf("code", "bundle", d, "artifact-store://mail/code/blobs/"+d), - artifactOf("files", "archive", d, "artifact-store://mail/files/blobs/"+d), - artifactOf("server", "image", d, "10.0.0.1:5000/mail/server@"+d), - artifactOf("server", "image", d, "artifact-store://mail/server@"+d), - artifactOf("nats", "upstream", d, "registry.example/nats/nats@"+d), - artifactOf("lib", "package", d, "@novox/mesh-sdk@0.1.15"), - artifactOf("lib", "package", d, "left-pad@1.3.0"), - artifactOf("old", "", d, "anything at all"), - // The form a registry seat answers (ADR 0605 §9), beside the older one. - artifactOf("code", "bundle", d, "oci-artifact-registry/mail/code@"+d), - artifactOf("files", "archive", d, "oci-artifact-registry/mail/files@"+d), - artifactOf("server", "image", d, "container-image-registry/mail/server@"+d), - artifactOf("nats", "upstream", d, "container-image-registry/nats/nats@"+d), - // An upstream copy under the holder's own naming, upstream//, in either form (ADR 0624 §1). - artifactOf("app", "upstream", d, "container-image-registry/upstream/quay.io:443/org/app@"+d), - artifactOf("app", "upstream", d, "artifact-store://upstream/quay.io:443/org/app@"+d), - } - for _, a := range good { - b := doneBuild() - b.Artifacts = []BuiltArtifact{a} - if _, err := EncodeBuilderBuild(b); err != nil { - t.Errorf("%+v was refused: %v", a, err) - } - } - other := "sha256:" + digestOf('7') - bad := []BuiltArtifact{ - artifactOf("code", "bundle", d, "artifact-store://mail/code/blobs/"+other), - artifactOf("code", "bundle", d, "artifact-store://mail/other/blobs/"+d), - artifactOf("code", "bundle", d, "10.0.0.1:5000/mail/code@"+d), - artifactOf("server", "image", d, "10.0.0.1:5000/mail@"+d), - artifactOf("server", "image", d, "10.0.0.1:5000/mail/server@"+other), - artifactOf("server", "image", d, "mail/server@"+d), - artifactOf("server", "image", d, "artifact-store://mail/server/blobs/"+d), - artifactOf("lib", "package", d, "@novox/mesh-sdk"), - artifactOf("lib", "package", d, "0.1.15"), - artifactOf("code", "bundle", d, "container-image-registry/mail/code@"+d), - artifactOf("server", "image", d, "oci-artifact-registry/mail/server@"+d), - artifactOf("server", "image", d, "container-image-registry/mail/server@"+other), - artifactOf("server", "image", d, "container-image-registry/mail/other@"+d), - artifactOf("lib", "package", d, "container-image-registry/mail/lib@"+d), - artifactOf("app", "upstream", d, "container-image-registry/upstream/quay.io:443/org/app@"+other), - artifactOf("app", "upstream", d, "artifact-store://upstream/quay.io:443/org/app@"+other), - artifactOf("app", "upstream", d, "oci-artifact-registry/upstream/quay.io:443/org/app@"+d), - artifactOf("server", "image", d, "container-image-registry/upstream/quay.io:443/org/server@"+d), - } - for _, a := range bad { - b := doneBuild() - b.Artifacts = []BuiltArtifact{a} - if _, err := EncodeBuilderBuild(b); err == nil || !strings.Contains(err.Error(), `"`+a.Name+`"`) { - t.Errorf("%+v: %v, wanted a refusal naming the artifact", a, err) - } - } -} - -// The controller delivers only from a done key whose every artifact names a reference (ADR 0498 clause 8): Referenced -// says so and names the artifacts without one. -func TestReferencedNamesTheArtifactsWithoutAReference(t *testing.T) { - d := "sha256:" + digestOf('6') - b := doneBuild() - b.Artifacts = []BuiltArtifact{ - artifactOf("code", "bundle", d, "artifact-store://mail/code/blobs/"+d), - artifactOf("server", "image", d, ""), - } - if ok, missing := b.Referenced(); ok || len(missing) != 1 || missing[0] != "server" { - t.Fatalf("Referenced = %v, %v; want false, [server]", ok, missing) - } - b.Artifacts[1].Reference = "10.0.0.1:5000/mail/server@" + d - if ok, missing := b.Referenced(); !ok || len(missing) != 0 { - t.Fatalf("Referenced = %v, %v; want true", ok, missing) - } - if ok, _ := (BuilderBuild{State: BuildQueued}).Referenced(); ok { - t.Fatal("a queued key is referenced") - } -} - -// The errors a build module answers open with a fixed prefix (ADR 0498 clauses 2, 4 and 5; ADR 0478 clause 2), and three -// of them are not built again until the module's fingerprint changes; a failed push is. -func TestOnlyAPushFailureIsBuiltAgainBeforeTheFingerprintChanges(t *testing.T) { - for _, c := range []struct { - err string - again bool - }{ - {NotStored + "code: the store said 500", true}, - {NotPinned + "nats: upstream names no digest", false}, - {VersionTaken + "@novox/mesh-sdk@0.1.15 holds another file", false}, - {RefusedKind + "a package in go", false}, - {"go test: 2 failed", true}, - } { - if got := FailedUntilChanged(c.err); got == c.again { - t.Errorf("FailedUntilChanged(%q) = %v", c.err, got) - } - } - if NotStored != "not stored: " || NotPinned != "not pinned: " || VersionTaken != "version taken: " || RefusedKind != "refused kind: " { - t.Fatal("a prefix changed its words") - } - // A Node fetch that finds the lock naming a package version the registry does not hold fails with WaitsFor; the - // builder then writes the key queued with Waits, and the error is not one that waits for the fingerprint to change. - if WaitsFor != "waits for: " || FailedUntilChanged(WaitsFor+"@novox/mesh-sdk@0.1.15") { - t.Fatalf("WaitsFor = %q", WaitsFor) - } -} - -// A key whose module's lock names a version of the group's package not yet published waits, queued, saying which -// (ADR 0500 clause 6): an older reader reads it as queued, and only a queued key waits. -func TestAKeyWaitsQueuedForAPackageVersion(t *testing.T) { - b := BuilderBuild{State: BuildQueued, Manifest: digestOf('4'), Waits: "@novox/mesh-sdk@0.1.15"} - data, err := EncodeBuilderBuild(b) - if err != nil { - t.Fatal(err) - } - back, err := DecodeBuilderBuild(data) - if err != nil || back.Waits != b.Waits || back.State != BuildQueued { - t.Fatalf("decoded %+v, %v", back, err) - } - building := BuilderBuild{State: BuildBuilding, By: "x", Waits: "@novox/mesh-sdk@0.1.15"} - if _, err := EncodeBuilderBuild(building); err == nil { - t.Fatal("a building key that waits was accepted") - } - if _, err := EncodeBuilderBuild(BuilderBuild{State: BuildQueued, Waits: "mesh-sdk"}); err == nil { - t.Fatal("a wait naming no version was accepted") - } -} - -// A module that builds no artifact gets a done key too (issue 1613), and it is delivered: Referenced is ok for a done -// key with no artifact, since no artifact lacks a reference; a queued one with none still is not. -func TestADoneKeyWithNoArtifactIsReferenced(t *testing.T) { - b := BuilderBuild{State: BuildDone, Manifest: digestOf('4')} - data, err := EncodeBuilderBuild(b) - if err != nil { - t.Fatalf("a done key with no artifact was refused: %v", err) - } - back, err := DecodeBuilderBuild(data) - if err != nil { - t.Fatal(err) - } - if ok, missing := back.Referenced(); !ok || len(missing) != 0 { - t.Fatalf("Referenced = %v, %v; want true", ok, missing) - } -} diff --git a/go/delivery/builder_test.go b/go/delivery/builder_test.go deleted file mode 100644 index 52810ff..0000000 --- a/go/delivery/builder_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package delivery - -import ( - "encoding/json" - "errors" - "fmt" - "reflect" - "strings" - "testing" -) - -func doneBuild() BuilderBuild { - return BuilderBuild{ - State: BuildDone, - By: "builder.novox.1", - Step: "pushed", - Manifest: digestOf('4'), - Artifacts: []BuiltArtifact{ - {Name: "code", Kind: "bundle", Digest: "sha256:" + digestOf('5')}, - {Name: "server", Kind: "image", Digest: "sha256:" + digestOf('6'), Reference: "registry.example/mail/server@sha256:" + digestOf('6')}, - }, - Toolchains: map[string]ToolchainUsed{ - "go": {Version: "1.26.0", Recipe: "3"}, - "typescript": {Version: "5.9.2", Recipe: "1"}, - }, - } -} - -func builderSamples() map[string]BuilderBuild { - failed := BuilderBuild{State: BuildFailed, By: "builder.novox.1", Step: "test", Error: "go test: 2 failed"} - both := doneBuild() - both.Needs = []string{"form:artifact-registry"} - both.Artifacts[1].References = []string{both.Artifacts[1].Reference, "artifact-store://mail/server@" + both.Artifacts[1].Digest} - return map[string]BuilderBuild{ - "done with needs and both forms": both, - "queued": {State: BuildQueued, Manifest: digestOf('4')}, - "building": {State: BuildBuilding, By: "builder.novox.1", Step: "fetch", Manifest: digestOf('4')}, - "done": doneBuild(), - "failed": failed, - } -} - -// 1358/AC1: the key is ., parsing gives both back; a module with a dot, an empty one, and a fingerprint not -// of the new form are refused, naming it. -func TestBuilderBuildKey(t *testing.T) { - fp := fp2of('a') - key, err := BuilderBuildKey("mesh-mail", fp) - if err != nil || key != "mesh-mail."+strings.Repeat("a", 64) { - t.Fatalf("key = %q, %v", key, err) - } - module, back, err := ParseBuilderBuildKey(key) - if err != nil || module != "mesh-mail" || back != fp { - t.Errorf("parse = %q, %q, %v", module, back, err) - } - src1 := SourcePrefix + strings.Repeat("1", 64) - for module, fp := range map[string]string{"a.b": fp, "": fp, "mesh-mail": src1, "mail ": fp} { - if _, err := BuilderBuildKey(module, fp); err == nil { - t.Errorf("BuilderBuildKey(%q, %q) accepted", module, fp) - } - } - if _, err := BuilderBuildKey("a.b", fp); err == nil || !strings.Contains(err.Error(), "a.b") { - t.Errorf("a dotted module is not named: %v", err) - } - if _, err := BuilderBuildKey("mesh-mail", src1); err == nil || !strings.Contains(err.Error(), src1) { - t.Errorf("a src1 fingerprint is not named: %v", err) - } - for _, bad := range []string{"", "mesh-mail", "mesh-mail.", "mesh-mail.abc", "mesh-mail." + strings.Repeat("A", 64), ".x", "mesh-mail.build.7", "a.b." + strings.Repeat("a", 64)} { - if _, _, err := ParseBuilderBuildKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ParseBuilderBuildKey(%q) = %v, want a refusal naming it", bad, err) - } - } -} - -// 1358/AC2: a build value in each state encodes and decodes unchanged. -func TestBuilderBuildRoundTrip(t *testing.T) { - for name, b := range builderSamples() { - data, err := EncodeBuilderBuild(b) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if !strings.HasPrefix(string(data), `{"v":1,`) { - t.Errorf("%s at %s", name, data) - } - got, err := DecodeBuilderBuild(data) - b.V = Version - if err != nil || !SameBuilderBuild(got, b) { - t.Errorf("%s: %+v %v", name, got, err) - } - } -} - -// 1358/AC3: done without a manifest digest, failed without an error, an artifact without a digest -// and an unknown state are refused. -func TestBuilderBuildChecked(t *testing.T) { - cases := map[string]struct { - edit func(*BuilderBuild) - words []string - }{ - "done without a manifest": {func(b *BuilderBuild) { b.Manifest = "" }, []string{"manifest"}}, - "a malformed manifest": {func(b *BuilderBuild) { b.Manifest = "abc" }, []string{"abc"}}, - "an artifact without a digest": {func(b *BuilderBuild) { b.Artifacts[1].Digest = "" }, []string{"digest", "server"}}, - "an artifact without a name": {func(b *BuilderBuild) { b.Artifacts[0].Name = "" }, []string{"name"}}, - "a need that is not a form": {func(b *BuilderBuild) { b.Needs = []string{"word:x"} }, []string{"word:x", "form:"}}, - "a reference not among the references": {func(b *BuilderBuild) { - b.Artifacts[1].References = []string{"artifact-store://mail/server@" + b.Artifacts[1].Digest} - }, []string{"server", "references"}}, - "a reference of another artifact": {func(b *BuilderBuild) { - b.Artifacts[1].References = []string{b.Artifacts[1].Reference, "registry.example/mail/other@" + b.Artifacts[1].Digest} - }, []string{"server", "other"}}, - "an unknown state": {func(b *BuilderBuild) { b.State = "paused" }, []string{"paused"}}, - "a toolchain without a version": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Recipe: "3"} }, []string{"go", "version"}}, - "a toolchain without a recipe": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Version: "1.26.0"} }, []string{"go", "recipe"}}, - } - for name, c := range cases { - b := doneBuild() - c.edit(&b) - _, err := EncodeBuilderBuild(b) - if err == nil { - t.Errorf("%s accepted", name) - continue - } - for _, w := range c.words { - if !strings.Contains(err.Error(), w) { - t.Errorf("%s: error %q does not name %q", name, err, w) - } - } - } - f := builderSamples()["failed"] - f.Error = "" - if _, err := EncodeBuilderBuild(f); err == nil || !strings.Contains(err.Error(), "error") { - t.Errorf("failed without an error: %v", err) - } - // a queued or building value may not already carry an outcome - q := builderSamples()["queued"] - q.Needs = []string{"form:x"} - if _, err := EncodeBuilderBuild(q); err == nil { - t.Error("a queued build with needs accepted") - } - q.Needs = nil - q.Artifacts = doneBuild().Artifacts - if _, err := EncodeBuilderBuild(q); err == nil { - t.Error("a queued build with artifacts accepted") - } - // decode refuses what encode refuses - if _, err := DecodeBuilderBuild([]byte(`{"v":1,"state":"done"}`)); err == nil { - t.Error("decoded a done build without a manifest digest") - } -} - -// 1358/AC4: a done value names the toolchain version per language and the recipe version; two values that differ only -// there are not the same. -func TestBuilderBuildNamesItsToolchains(t *testing.T) { - a, b, c := doneBuild(), doneBuild(), doneBuild() - b.Toolchains["go"] = ToolchainUsed{Version: "1.26.1", Recipe: "3"} - c.Toolchains["go"] = ToolchainUsed{Version: "1.26.0", Recipe: "4"} - if SameBuilderBuild(a, b) || SameBuilderBuild(a, c) || SameBuilderBuild(b, c) { - t.Error("values differing only in a toolchain's version or recipe are the same") - } - if !SameBuilderBuild(a, doneBuild()) { - t.Error("equal values differ") - } - data, err := EncodeBuilderBuild(a) - if err != nil { - t.Fatal(err) - } - got, err := DecodeBuilderBuild(data) - if err != nil || !reflect.DeepEqual(got.Toolchains, a.Toolchains) { - t.Errorf("toolchains = %+v, %v", got.Toolchains, err) - } -} - -// 1358/AC5: a value of another version, or none, is refused with ErrUnknownVersion. -func TestBuilderBuildVersion(t *testing.T) { - for _, in := range []string{`{"v":2,"state":"queued"}`, `{"state":"queued"}`} { - if _, err := DecodeBuilderBuild([]byte(in)); !errors.Is(err, ErrUnknownVersion) { - t.Errorf("DecodeBuilderBuild(%s) = %v", in, err) - } - } - // A holder's question before it stores (ADR 0605 §4): asked at the builder's address with name, version and digest; - // an answer without recorded is an error, never a yes. - for answer, want := range map[string]string{`{"recorded":true}`: "true ", `{"recorded":false,"why":"other digest"}`: "false other digest", - `{"name":"x"}`: "error", `not json`: "error"} { - b := BuilderVersions{Ask: func(addr string, body any) (json.RawMessage, error) { - if a := body.(map[string]any); addr != BuilderVersionsAddress || a["name"] != "n" || a["version"] != "0.1.2" || a["digest"] != "sha256:d" { - t.Errorf("asked %s %v", addr, body) - } - return json.RawMessage(answer), nil - }} - ok, why, err := b.Recorded("n", "0.1.2", "sha256:d") - if got := fmt.Sprintf("%v %s", ok, why); err != nil && want != "error" || err == nil && got != want { - t.Errorf("answer %s: %s, %v; want %s", answer, got, err, want) - } - } -} - -// 1358/AC6: the builder's bucket and module do not collide with the controller's build layout (buildrecord.go), which -// this change leaves as it was. -func TestBuilderLayoutDoesNotCollideWithTheControllers(t *testing.T) { - if BuilderBuildsBucket != "mesh-delivery-builder_builds" || BuilderBuildsHistory != 5 || BuilderModule != "mesh-delivery-builder" { - t.Errorf("bucket %s history %d module %s", BuilderBuildsBucket, BuilderBuildsHistory, BuilderModule) - } - if BuildKey("m", "7") != "m.build.7" || NewestKey("m") != "m.newest" { - t.Error("the controller's layout changed") - } - // the two key forms are never read as each other - key, _ := BuilderBuildKey("m", fp2of('a')) - if key == BuildKey("m", "a") || key == NewestKey("m") { - t.Errorf("builder key %q equals a controller key", key) - } - if _, _, err := ParseBuilderBuildKey(BuildKey("m", digestOf('a'))); err == nil { - t.Error("a controller build key parsed as the builder's") - } - if _, _, err := ParseBuilderBuildKey(NewestKey("m")); err == nil { - t.Error("a controller newest key parsed as the builder's") - } -} diff --git a/go/delivery/builder_versions.go b/go/delivery/builder_versions.go deleted file mode 100644 index 86e5c10..0000000 --- a/go/delivery/builder_versions.go +++ /dev/null @@ -1,38 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" -) - -// The holder's question before it stores a publish (novox/hq ADR 0605 §4, design 51 *Publishing*): did the builder -// number this version with this digest? Every registry seat's holder asks it the same way, so the asking lives here -// once (ADR 0627 §2), beside the builder's layouts; no holder writes it again. -// -// The builder answers it today only as its module's tool builder_versions: no seat in the seat set names the builder's -// role yet. Once one does, BuilderVersionsAddress becomes that seat's verb and nothing else changes for a holder. -const BuilderVersionsAddress = BuilderModule + ".builder_versions" - -// AskFunc is the runtime's request and reply over the bus (mesh-sdk stdio.Ask), given by the holder so this package does -// not depend on the runtime. -type AskFunc func(address string, body any) (json.RawMessage, error) - -// BuilderVersions asks the builder whether it numbered a version with a digest. It answers the holder's Check: -// recorded, and why not when it is not. -type BuilderVersions struct{ Ask AskFunc } - -// Recorded asks the builder about name@version with digest. An answer without `recorded` is an error, never a yes. -func (b BuilderVersions) Recorded(name, version, digest string) (bool, string, error) { - raw, err := b.Ask(BuilderVersionsAddress, map[string]any{"name": name, "version": version, "digest": digest}) - if err != nil { - return false, "", err - } - var a struct { - Recorded *bool `json:"recorded"` - Why string `json:"why"` - } - if err := json.Unmarshal(raw, &a); err != nil || a.Recorded == nil { - return false, "", fmt.Errorf("builder_versions answered no recorded: %s", raw) - } - return *a.Recorded, a.Why, nil -} diff --git a/go/delivery/buildrecord.go b/go/delivery/buildrecord.go deleted file mode 100644 index 08fd35f..0000000 --- a/go/delivery/buildrecord.go +++ /dev/null @@ -1,47 +0,0 @@ -package delivery - -import "time" - -// BuildRecord is the layout the ended controller's chain wrote (mesh-controller internal/chain/records.go Build, line 91), -// the fields a reader outside it read (novox/hq ADR 0437 §4 §5, issue 1223). The controller's record carries more; a decode ignores -// what is not here. Path is the module's directory in its repository, as the controller writes it (Build.Path, records.go:100, -// set from the build request trimmed of slashes at internal/inventory/chain_requests.go:19); empty for a module whose -// manifest is the repository's root. SourceInputs has no json keys of its own: it is written under its Go field names, as the controller -// writes it. -type BuildRecord struct { - ID string `json:"id"` - State string `json:"state,omitempty"` - Failed string `json:"failed,omitempty"` - Repository string `json:"repository"` - Ref string `json:"ref,omitempty"` - Module string `json:"module,omitempty"` - Path string `json:"path,omitempty"` - Commit string `json:"commit,omitempty"` - At time.Time `json:"at,omitzero"` - SourceFingerprint string `json:"source-fingerprint,omitempty"` - SourceInputs *SourceInputs `json:"source-inputs,omitempty"` - // Made is what the build made (Build.Made, records.go:110); the digest is the tail of an artifact's Reference. - Made []Artifact `json:"made,omitempty"` -} - -// Artifact is one thing a build made: mesh-controller internal/chain/records.go Artifact, json keys name, kind and -// reference, nothing else (novox/hq ADR 0478 clause 8). -type Artifact struct { - Name string `json:"name"` - Kind string `json:"kind"` - Reference string `json:"reference"` -} - -// BuildKey is a build's record under its module, a build that knew none under the request's key: copied from -// mesh-controller internal/chain/keys.go:35-40 (BuildKey, with RequestKey at line 42). -func BuildKey(module, id string) string { - if module == "" { - return "req." + id - } - return module + ".build." + id -} - -// NewestKey is the key of a module's newest build, of any state: copied from mesh-controller internal/chain/keys.go:44. -// The key holds no record: its value is the newest build's id as raw bytes (internal/inventory/chain_builds.go:210 and -// :546), and the record lives at BuildKey(module, id). The pointer is a hint, checked against that record. -func NewestKey(module string) string { return module + ".newest" } diff --git a/go/delivery/buildrecord_test.go b/go/delivery/buildrecord_test.go deleted file mode 100644 index 6ec427c..0000000 --- a/go/delivery/buildrecord_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package delivery - -import ( - "encoding/json" - "os" - "testing" - "time" -) - -// The fixture's path is what the controller writes for a build request: strings.Trim(path, "/") at mesh-controller -// internal/inventory/chain_requests.go:19, the module's directory in its repository (Build.Path, internal/chain/records.go:100). -// The fixture started as a record the controller's own encoder wrote (json.Marshal of a chain.Build, mesh-controller -// internal/chain, a throwaway test run once and deleted) with build-agent's build-1792395447088936520 values, plus a -// manifest-digest and one made artifact (shape: mesh-controller internal/chain/records.go:91-117 Build, json tags, at commit -// 1f708e2d8d72e2c27dbeaba02f14c128d700eada; an outcome has no state key and a build asked after a merge no ref, records.go:57-59), which a BuildRecord does not carry. It was then edited by hand -// to the controller's layout for an outcome: state and ref removed, the repository a clone URL (issue 1281). -func loadBuildFixture(t *testing.T) []byte { - t.Helper() - raw, err := os.ReadFile("testdata/build-record-build-agent.json") - if err != nil { - t.Fatal(err) - } - return raw -} - -func TestBuildRecordDecodes(t *testing.T) { // 1223/AC1 - var r BuildRecord - if err := json.Unmarshal(loadBuildFixture(t), &r); err != nil { - t.Fatal(err) - } - for _, c := range []struct{ name, got, want string }{ - {"id", r.ID, "build-1792395447088936520"}, - {"module", r.Module, "build-agent"}, - {"repository", r.Repository, "http://novox.internal:20000/novox/mesh-controller.git"}, - {"ref", r.Ref, ""}, - {"commit", r.Commit, "7a74cee83f88e6585a5511c3cc53bdf0f5303d20"}, - {"state", r.State, ""}, - {"failed", r.Failed, ""}, - {"source-fingerprint", r.SourceFingerprint, "src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7"}, - } { - if c.got != c.want { - t.Errorf("%s = %q, want %q", c.name, c.got, c.want) - } - } - if want := time.Date(2026, 10, 19, 9, 38, 0, 0, time.UTC); !r.At.Equal(want) { - t.Errorf("at = %v, want %v", r.At, want) - } - if !r.IsTrunkBuild() { // 1281/AC7 - t.Error("the fixture, a record as the controller writes it, is not a trunk build") - } - if r.SourceInputs == nil { - t.Fatal("source-inputs missing") - } -} - -func TestBuildRecordPath(t *testing.T) { // 1233/AC1 - var r BuildRecord - if err := json.Unmarshal(loadBuildFixture(t), &r); err != nil { - t.Fatal(err) - } - if r.Path != "modules/build-agent" { - t.Errorf("path = %q, want modules/build-agent", r.Path) - } - // mesh-controller's manifest is its repository's root: the controller writes no path (omitempty), so the path is "". - var m map[string]json.RawMessage - if err := json.Unmarshal(loadBuildFixture(t), &m); err != nil { - t.Fatal(err) - } - delete(m, "path") - raw, _ := json.Marshal(m) - var root BuildRecord - if err := json.Unmarshal(raw, &root); err != nil { - t.Fatal(err) - } - if root.Path != "" { - t.Errorf("path of a root module = %q, want empty", root.Path) - } - // Every field 1223/AC1 checks is still equal in both. - if root.ID != r.ID || root.Module != r.Module || root.Repository != r.Repository || root.Ref != r.Ref || - root.Commit != r.Commit || root.State != r.State || root.SourceFingerprint != r.SourceFingerprint || !root.At.Equal(r.At) { - t.Errorf("fields differ: %+v vs %+v", root, r) - } - // And the path survives a round trip through the SDK's own type. - back, _ := json.Marshal(r) - var again BuildRecord - if err := json.Unmarshal(back, &again); err != nil || again.Path != r.Path { - t.Errorf("round trip path = %q (%v)", again.Path, err) - } -} - -func TestBuildRecordWithoutInputs(t *testing.T) { // 1223/AC2 - var m map[string]json.RawMessage - if err := json.Unmarshal(loadBuildFixture(t), &m); err != nil { - t.Fatal(err) - } - delete(m, "source-inputs") - raw, _ := json.Marshal(m) - var r BuildRecord - if err := json.Unmarshal(raw, &r); err != nil { - t.Fatal(err) - } - if r.SourceInputs != nil { - t.Errorf("source-inputs = %+v, want nil", r.SourceInputs) - } - if r.Module != "build-agent" { - t.Errorf("module = %q, want build-agent", r.Module) - } -} - -func TestBuildKeys(t *testing.T) { // 1223/AC3 - // Literal values of mesh-controller internal/broker/controller_buckets.go:101 (BucketName(ControllerSeat, ChainBuilds) - // is "mesh-controller_builds") and internal/chain/keys.go:35-44. - if got := BuildKey("build-agent", "build-1"); got != "build-agent.build.build-1" { - t.Errorf("BuildKey = %q", got) - } - if got := BuildKey("", "build-1"); got != "req.build-1" { - t.Errorf("BuildKey without a module = %q", got) - } - if got := NewestKey("build-agent"); got != "build-agent.newest" { - t.Errorf("NewestKey = %q", got) - } -} - -// The fixture's made artifacts are the controller's Artifact (mesh-controller internal/chain/records.go, json tags name, -// kind, reference; the controller writes no digest key, the digest is the tail of the reference). The kinds and references -// are those of the live record mesh-controller.builds printed for build-agent build-1792540091397811913 (2026-10-21 01:49). -func TestBuildRecordMade(t *testing.T) { // 1588/AC2 - var r BuildRecord - if err := json.Unmarshal(loadBuildFixture(t), &r); err != nil { - t.Fatal(err) - } - want := []Artifact{ - {Name: "server", Kind: "image", Reference: "artifact-store://build-agent/server@sha256:a79c13f1c727ff94959e80824cc704ea58b80d80ecdd00fd447698acebb35090"}, - {Name: "files", Kind: "archive", Reference: "artifact-store://build-agent/files/blobs/sha256:bccd3f47851436d1b45a3063cc2d7e34c5c109b2d030739bfbb4b9781703f9a9"}, - } - if len(r.Made) != len(want) { - t.Fatalf("made = %+v, want %+v", r.Made, want) - } - for i := range want { - if r.Made[i] != want[i] { - t.Errorf("made[%d] = %+v, want %+v", i, r.Made[i], want[i]) - } - } - var none BuildRecord - if err := json.Unmarshal([]byte(`{"id":"b","repository":"a/b"}`), &none); err != nil || none.Made != nil { - t.Errorf("a record without made: %+v, %v", none, err) - } -} diff --git a/go/delivery/candidate_paths_test.go b/go/delivery/candidate_paths_test.go deleted file mode 100644 index b8f2472..0000000 --- a/go/delivery/candidate_paths_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package delivery - -import ( - "strings" - "testing" -) - -// A candidate's module carries the git objects of its named paths at the preview (novox/hq ADR 0499 clause 2), so the -// builder's fetch request for a candidate names them; an older candidate decodes without them, and a path that is not -// clean and relative, or names no object, is refused. -func TestACandidatesModuleCarriesItsNamedPaths(t *testing.T) { - c := sampleCandidate() - paths := map[string]string{"src": strings.Repeat("a", 40), "package.json": strings.Repeat("b", 40)} - c.Pulls[0].Fps = map[string]CandidateModule{"mesh-mail": {FP2: SourcePrefix2 + strings.Repeat("1", 64), - Manifest: strings.Repeat("2", 64), Paths: paths}} - c.FPSet = FingerprintSet(c.Pulls) - data, err := EncodeCandidate(c) - if err != nil { - t.Fatal(err) - } - back, err := DecodeCandidate(data) - if err != nil || back.Pulls[0].Fps["mesh-mail"].Paths["src"] != paths["src"] { - t.Fatalf("decoded %+v, %v", back.Pulls[0].Fps, err) - } - old := sampleCandidate() - old.Pulls[0].Fps = map[string]CandidateModule{"mesh-mail": {FP2: SourcePrefix2 + strings.Repeat("1", 64)}} - old.FPSet = FingerprintSet(old.Pulls) - if data, err := EncodeCandidate(old); err != nil { - t.Fatal(err) - } else if b, err := DecodeCandidate(data); err != nil || b.Pulls[0].Fps["mesh-mail"].Paths != nil { - t.Fatalf("an older candidate: %+v, %v", b, err) - } - for _, bad := range []map[string]string{{"../x": "a"}, {"/src": "a"}, {"": "a"}, {"src": ""}} { - c.Pulls[0].Fps = map[string]CandidateModule{"mesh-mail": {FP2: SourcePrefix2 + strings.Repeat("1", 64), Paths: bad}} - c.FPSet = FingerprintSet(c.Pulls) - if _, err := EncodeCandidate(c); err == nil { - t.Errorf("%v was accepted", bad) - } - } -} diff --git a/go/delivery/checker.go b/go/delivery/checker.go deleted file mode 100644 index d0a9f9d..0000000 --- a/go/delivery/checker.go +++ /dev/null @@ -1,447 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "reflect" - "strings" - "time" -) - -// The checker's keys (novox/hq ADR 0447 clauses 1 to 3, ADR 0444, ADR 0446 clause 5, ADR 0453 clause 4): the verdicts of -// its checks and the passing verdicts it keeps per package and level. The checker writes them from R4c, dark, and -// writes the status `mesh/check` it decides as an owed act in its own bucket (CheckerOwedBucket, the gate's owed shape); -// the forge bridge, the only part that calls the forge, performs that act (unrequired until R4f), once per revision of -// the key (ADR 0484 clause 2). The gate reads the status from R4f, and the view and the watchman read the verdicts (a -// running check carries a pulse). -// -// **The verdict's key is the group and the heads, not the fingerprints** (the lead's limit for R4a). Research 063 -// section 01 drew `verdicts..check.`; ADR 0447 clause 1 judges a pull request's head merged onto its -// trunk's head, and a repository without modules has no fingerprint set, so the key is `.` as the -// heads hash of HeadsHash gives it. The fpset (FingerprintSet) is not part of it. -const ( - // CheckerModule is the module of the checker part. - CheckerModule = "mesh-delivery-checker" - // CheckerVerdictsBucket is the checker's bucket of verdicts, `_`. - CheckerVerdictsBucket = CheckerModule + "_verdicts" - // CheckerVerdictsHistory is the revisions the verdicts bucket keeps per key. - CheckerVerdictsHistory = 5 - // CheckerKeptBucket is the checker's bucket of kept verdicts. A key is written once, by a create that fails when - // the key exists, by the checker's credential alone, from the result the checker reads from the test run and - // never from anything the code under test writes (ADR 0444 clause 5); nothing changes it afterwards. - CheckerKeptBucket = CheckerModule + "_kept" - // CheckerKeptHistory is the revisions the kept bucket keeps per key. - CheckerKeptHistory = 1 -) - -// The test levels (ADR 0445 clause 2) and the states of a verdict. -const ( - LevelUnit = "unit" - LevelIntegration = "integration" - - VerdictRunning = "running" - VerdictPass = "pass" - VerdictFail = "fail" -) - -func checkLevel(level string) error { - if level != LevelUnit && level != LevelIntegration { - return fmt.Errorf("delivery: the level %q is not %s or %s", level, LevelUnit, LevelIntegration) - } - return nil -} - -func isHex12(s string) bool { return len(s) == 12 && strings.Trim(s, "0123456789abcdef") == "" } - -// CheckerVerdictKey is the key, in CheckerVerdictsBucket, of a group's check at its heads: `.`, the group escaped as CandidatesKey does and the heads hash as HeadsHash gives it (12 lowercase hex digits). -func CheckerVerdictKey(group, headsHash string) (string, error) { - g, err := groupKey(group) - if err != nil { - return "", err - } - if !isHex12(headsHash) { - return "", fmt.Errorf("delivery: %q is not a heads hash, 12 lowercase hex digits", headsHash) - } - return g + "." + headsHash, nil -} - -// ParseCheckerVerdictKey is the inverse of CheckerVerdictKey. -func ParseCheckerVerdictKey(key string) (group, headsHash string, err error) { - i := strings.LastIndex(key, ".") - if i < 0 { - return "", "", fmt.Errorf("delivery: %q is not a verdict key, .", key) - } - part, headsHash := key[:i], key[i+1:] - if !isHex12(headsHash) { - return "", "", fmt.Errorf("delivery: %q is not a verdict key: %q is not a heads hash, 12 lowercase hex digits", key, headsHash) - } - if group, err = parseGroupKey(part); err != nil { - return "", "", fmt.Errorf("delivery: %q is not a verdict key: %w", key, err) - } - if strings.Contains(part, ".") { - return "", "", fmt.Errorf("delivery: %q is not a verdict key: the group part holds a dot", key) - } - return group, headsHash, nil -} - -// LevelResult is a level's tests: how many ran in this check and how many were taken from kept verdicts (ADR 0444 -// clause 2). -type LevelResult struct { - Run int `json:"run"` - Kept int `json:"kept"` - // Skipped is the tests the run skipped, so a pass never hides them (issue 1251, ADR 0484 clause 7). - Skipped int `json:"skipped,omitempty"` -} - -// The states of a Composition (ADR 0484 clause 6): the composition check is the assigner's, so until it delivers a -// verdict says CompositionNotRun, and a line never says the composition passed on that. -// -// A composition not run may say why in its Words: AssignerNotRunning when no instance of the assigner is online, so a -// check never waits for a composition nobody is making (issue 1297). -const ( - CompositionNotRun = "not-run" - CompositionPass = "pass" - CompositionFail = "fail" - - // AssignerNotRunning is the Words of a composition not run because no instance of the assigner is online. - AssignerNotRunning = "the assigner is not running" -) - -// Composition is what the check of the machines' composition made of a verdict: its State (not-run, pass or fail), the -// Words of a refusal, the mesh-sdk commit it composed with and the machines composed (ADR 0482 clause 9, ADR 0484 clause 6). -type Composition struct { - State string `json:"state"` - Words string `json:"words,omitempty"` - SDK string `json:"sdk,omitempty"` - Machines []string `json:"machines,omitempty"` - // FPSet is the candidate's fingerprint set (Candidate.FPSet) the composition judged: a carry keeps a composition that - // passed while the candidate's set is the same, and composes again when a fingerprint changed (novox/hq ADR 0548 §5). - // Absent in a value written before it, which a carry composes again. - FPSet string `json:"fpset,omitempty"` -} - -// Took is the time of each part of a check in integer milliseconds (ADR 0447 clause 3): waiting in the queue, cloning, -// the unit level, the integration level's setup and its running, and the composition. -type Took struct { - Queue int64 `json:"queue"` - Clone int64 `json:"clone"` - Unit int64 `json:"unit"` - // Lint is the unit level's gofmt and go vet before its tests (ADR 0529 §1; issue 1887 D7-go); absent from a verdict - // of a build module that runs none. - Lint int64 `json:"lint,omitempty"` - IntegrationSetup int64 `json:"integration-setup"` - IntegrationRun int64 `json:"integration-run"` - Composition int64 `json:"composition"` -} - -// FailedFirst names what failed first: a test (its package and name) or a machine (and the words of the composition's -// refusal); one of them, with Words optional. -type FailedFirst struct { - Test string `json:"test,omitempty"` - Machine string `json:"machine,omitempty"` - Words string `json:"words,omitempty"` -} - -// MaxFailures is the most entries a verdict's Failures holds (issue 1921): fifty failures named one by one, and an entry -// after them saying how many more failed. -const MaxFailures = 50 - -// Verdict is the value of a verdict key: the group and its heads hash, the trunk head of each repository it was judged -// against (repository to full sha), the State (running, pass or fail), what failed first, the tests run and kept per -// level, each part's time, the machines composed and the mesh-sdk version the composition ran with (ADR 0446 clause 5). -type Verdict struct { - V int `json:"v"` - Group string `json:"group"` - Heads string `json:"heads"` - Trunk map[string]string `json:"trunk,omitempty"` - State string `json:"state"` - Failed FailedFirst `json:"failed,omitzero"` - // Failures names everything that failed, in the order judged, each with the first line of its words (issue 1921): every - // failed test, and every member's refused step, conflict, manifest refusal and the composition's failure, since a check - // runs every member, level and the composition to its end (the operator, 2026-11-02). Failed keeps the first one's - // words whole. At most MaxFailures entries, the last of a longer list saying how many more failed. Absent in a value - // written before it, and from a verdict with one failure that is no test. - Failures []FailedFirst `json:"failures,omitempty"` - Levels map[string]LevelResult `json:"levels,omitempty"` - Took Took `json:"took,omitzero"` - Machines []string `json:"machines,omitempty"` - SDK string `json:"sdk,omitempty"` - // Composition is the composition check's result; absent in a value written before it (ADR 0484 clause 6). - Composition *Composition `json:"composition,omitempty"` - // OverBound names the levels whose running went over their bound; it does not change the State (ADR 0453 clause 9). - OverBound []string `json:"over-bound,omitempty"` - // Beside is the commit of each sibling repository the check read beside the repositories it merged (ADR 0484 §4), by - // repository: a move of one puts the verdict out of date as a trunk move does, and it is carried (novox/hq ADR 0548 §6). - // A repository is in Trunk or here, never both. Absent in a value written before it. - Beside map[string]string `json:"beside,omitempty"` - // CarriedFrom names, of a carried verdict, the trunk and sibling commits it was judged on before the moves it was - // carried over, by repository (ADR 0548 §5): the verdict was rewritten for Trunk and Beside by running only the packages - // whose digest the moves changed. Absent from a verdict judged whole, and from one still running. - CarriedFrom map[string]string `json:"carried-from,omitempty"` - // Series names each change of an artifact's series the checked heads make against the trunk's manifests (design 32; - // the checker's DLV-C4), each once, in the order the check found them; the pull request's mesh/check line says the - // same, and a reader that cuts that line still has every one here. Absent when the heads change no series, and in a - // value written before it. - Series []SeriesChange `json:"series,omitempty"` -} - -// SeriesChange is one change of a series: the module, its artifact (or the package's ecosystem name, as the module's -// packages name it), the series on trunk and the series the heads give it. -type SeriesChange struct { - Module string `json:"module"` - Artifact string `json:"artifact"` - From string `json:"from"` - To string `json:"to"` -} - -func (c *Composition) check() error { - if c == nil { - return nil - } - switch c.State { - case CompositionNotRun: - if c.SDK != "" || len(c.Machines) > 0 { - return fmt.Errorf("delivery: the verdict's composition is not run and names no mesh-sdk commit or machines") - } - case CompositionPass: - if c.SDK == "" { - return fmt.Errorf("delivery: the verdict's composition passed and names no mesh-sdk commit") - } - case CompositionFail: - if c.Words == "" { - return fmt.Errorf("delivery: the verdict's composition failed and gives no words") - } - default: - return fmt.Errorf("delivery: the verdict's composition state %q is not %s, %s or %s", c.State, CompositionNotRun, CompositionPass, CompositionFail) - } - return nil -} - -func (v Verdict) check() error { - if v.Group == "" { - return fmt.Errorf("delivery: a verdict names no group") - } - if !isHex12(v.Heads) { - return fmt.Errorf("delivery: the verdict's heads hash %q is not 12 lowercase hex digits", v.Heads) - } - for repository, head := range v.Trunk { - if head == "" { - return fmt.Errorf("delivery: the verdict's trunk head of %s is empty", repository) - } - } - for repository, commit := range v.Beside { - if commit == "" { - return fmt.Errorf("delivery: the verdict's sibling commit of %s is empty", repository) - } - if _, ok := v.Trunk[repository]; ok { - return fmt.Errorf("delivery: the verdict names %s as a trunk it merged and as a sibling it read", repository) - } - } - if len(v.CarriedFrom) > 0 { - if v.State == VerdictRunning { - return fmt.Errorf("delivery: a running verdict is carried from nothing: carried-from is written with its end") - } - moved := false - for repository, commit := range v.CarriedFrom { - now, inTrunk := v.Trunk[repository] - if !inTrunk { - now = v.Beside[repository] - } - if commit == "" || now == "" { - return fmt.Errorf("delivery: the verdict's carried-from names %s, which it judged on no commit of", repository) - } - moved = moved || commit != now - } - if !moved { - return fmt.Errorf("delivery: the verdict's carried-from names the commits it is judged on: nothing moved, so nothing was carried") - } - } - changed := map[string]bool{} - for _, c := range v.Series { - if c.Module == "" || c.Artifact == "" || c.From == "" || c.To == "" { - return fmt.Errorf("delivery: the verdict's series change %+v names no module, artifact, from or to", c) - } - if c.From == c.To { - return fmt.Errorf("delivery: the verdict's series change of %s/%s is from %s to the same series", c.Module, c.Artifact, c.From) - } - if changed[c.Module+"/"+c.Artifact] { - return fmt.Errorf("delivery: the verdict names the series change of %s/%s twice", c.Module, c.Artifact) - } - changed[c.Module+"/"+c.Artifact] = true - } - for level, r := range v.Levels { - if err := checkLevel(level); err != nil { - return err - } - if r.Run < 0 || r.Kept < 0 || r.Skipped < 0 { - return fmt.Errorf("delivery: the verdict's %s level counts a negative number of tests", level) - } - } - if err := v.Composition.check(); err != nil { - return err - } - seen := map[string]bool{} - for _, level := range v.OverBound { - if err := checkLevel(level); err != nil { - return fmt.Errorf("delivery: the verdict's over-bound names: %w", err) - } - if seen[level] { - return fmt.Errorf("delivery: the verdict's over-bound names the level %s twice", level) - } - seen[level] = true - } - for part, ms := range map[string]int64{"queue": v.Took.Queue, "clone": v.Took.Clone, "unit": v.Took.Unit, - "integration-setup": v.Took.IntegrationSetup, "integration-run": v.Took.IntegrationRun, "composition": v.Took.Composition} { - if ms < 0 { - return fmt.Errorf("delivery: the verdict's time of the part %s is negative", part) - } - } - named := v.Failed.Test != "" || v.Failed.Machine != "" - if len(v.Failures) > 0 && v.State != VerdictFail { - return fmt.Errorf("delivery: a %s verdict names no failures", v.State) - } - if len(v.Failures) > MaxFailures+1 { - return fmt.Errorf("delivery: the verdict names %d failures, more than %d and the count of the rest", len(v.Failures), MaxFailures) - } - switch v.State { - case VerdictRunning: - if v.Failed != (FailedFirst{}) { - return fmt.Errorf("delivery: a running verdict names no failure") - } - case VerdictPass: - if v.Failed != (FailedFirst{}) { - return fmt.Errorf("delivery: a pass names no failure") - } - for _, level := range []string{LevelUnit, LevelIntegration} { - if _, ok := v.Levels[level]; !ok { - return fmt.Errorf("delivery: a pass says nothing of the %s level", level) - } - } - case VerdictFail: - if !named { - return fmt.Errorf("delivery: a fail names neither a test nor a machine") - } - default: - return fmt.Errorf("delivery: the verdict state %q is not running, pass or fail", v.State) - } - return nil -} - -// EncodeVerdict is v as JSON at Version; it refuses a fail that names neither a test nor a machine, a pass without both -// levels, an unknown state or level, and a negative count or time. -func EncodeVerdict(v Verdict) ([]byte, error) { - if err := v.check(); err != nil { - return nil, err - } - v.V = Version - return json.Marshal(v) -} - -// DecodeVerdict reads a verdict; it refuses an unknown version (ErrUnknownVersion) and what EncodeVerdict refuses. -func DecodeVerdict(data []byte) (Verdict, error) { - var v Verdict - if err := decode(data, &v); err != nil { - return Verdict{}, err - } - if err := v.check(); err != nil { - return Verdict{}, err - } - return v, nil -} - -// SameVerdict says whether two verdicts are the same value, so the checker writes only when they differ. -func SameVerdict(a, b Verdict) bool { return reflect.DeepEqual(a, b) } - -// KeptKey is the key, in CheckerKeptBucket, of a kept verdict: `.`, the level unit or integration (ADR -// 0445 clause 2) and the digest, lowercase hex, of everything that package's tests read at that level as the -// language's build module names it (ADR 0444 clause 1, ADR 0445 clause 5). -func KeptKey(level, digest string) (string, error) { - if err := checkLevel(level); err != nil { - return "", err - } - if digest == "" || strings.Trim(digest, "0123456789abcdef") != "" { - return "", fmt.Errorf("delivery: %q is not a digest, lowercase hex", digest) - } - return level + "." + digest, nil -} - -// ParseKeptKey is the inverse of KeptKey. -func ParseKeptKey(key string) (level, digest string, err error) { - level, digest, ok := strings.Cut(key, ".") - if !ok { - return "", "", fmt.Errorf("delivery: %q is not a kept key, .", key) - } - if _, err = KeptKey(level, digest); err != nil { - return "", "", fmt.Errorf("delivery: %q is not a kept key: %w", key, err) - } - return level, digest, nil -} - -// Kept is the value of a kept key: a passing verdict of one package at one level (State is pass and nothing else: a -// failure is never kept, ADR 0444 clause 1), with the repository, the package, the number of tests, the time they took -// in milliseconds, the toolchain's version and when it was kept. -type Kept struct { - V int `json:"v"` - State string `json:"state"` - Repository string `json:"repository"` - Package string `json:"package"` - Level string `json:"level"` - Tests int `json:"tests"` - Took int64 `json:"took"` - Toolchain string `json:"toolchain"` - At time.Time `json:"at"` -} - -func (k Kept) check() error { - if k.State != VerdictPass { - return fmt.Errorf("delivery: a kept verdict is a pass, never %q: a failure is never kept", k.State) - } - if err := checkRepository(k.Repository); err != nil { - return err - } - if k.Package == "" { - return fmt.Errorf("delivery: a kept verdict names no package") - } - if err := checkLevel(k.Level); err != nil { - return err - } - if k.Tests < 0 || k.Took < 0 { - return fmt.Errorf("delivery: a kept verdict counts a negative number of tests or a negative time") - } - if k.Toolchain == "" { - return fmt.Errorf("delivery: a kept verdict names no toolchain version") - } - if k.At.IsZero() { - return fmt.Errorf("delivery: a kept verdict says no time") - } - return nil -} - -// EncodeKept is k as JSON at Version; it refuses a kept value that is not a pass. -func EncodeKept(k Kept) ([]byte, error) { - if err := k.check(); err != nil { - return nil, err - } - k.V = Version - return json.Marshal(k) -} - -// DecodeKept reads a kept value; it refuses an unknown version (ErrUnknownVersion) and what EncodeKept refuses. -func DecodeKept(data []byte) (Kept, error) { - var k Kept - if err := decode(data, &k); err != nil { - return Kept{}, err - } - if err := k.check(); err != nil { - return Kept{}, err - } - return k, nil -} - -// SameKept says whether two kept values are the same value. -func SameKept(a, b Kept) bool { - at := a.At.Equal(b.At) - a.At, b.At = time.Time{}, time.Time{} - return at && a == b -} diff --git a/go/delivery/checker_test.go b/go/delivery/checker_test.go deleted file mode 100644 index e923ccd..0000000 --- a/go/delivery/checker_test.go +++ /dev/null @@ -1,674 +0,0 @@ -package delivery - -import ( - "encoding/json" - "errors" - "strconv" - "strings" - "testing" - "time" -) - -func passVerdict() Verdict { - return Verdict{ - Group: "feat/x", - Heads: "4b5491557853", - Trunk: map[string]string{"novox/mesh-sdk": "aaaa", "novox/mesh-control": "bbbb"}, - State: VerdictPass, - Levels: map[string]LevelResult{LevelUnit: {Run: 12, Kept: 301}, LevelIntegration: {Run: 3, Kept: 40}}, - Took: Took{Queue: 1200, Clone: 800, Unit: 950, IntegrationSetup: 2100, IntegrationRun: 7400, Composition: 3300}, - Machines: []string{"ace", "g14", "novox", "shanks"}, - SDK: "v0.1.14-0.20261019091011-aa68b67389c9", - } -} - -func verdictSamples() map[string]Verdict { - running := Verdict{Group: "feat/x", Heads: "4b5491557853", Trunk: map[string]string{"novox/mesh-sdk": "aaaa"}, State: VerdictRunning} - failTest := passVerdict() - failTest.State, failTest.Failed = VerdictFail, FailedFirst{Test: "novox/mesh-sdk/go/delivery TestHeadsHash"} - failTest.Levels = map[string]LevelResult{LevelUnit: {Run: 1}} - failMachine := passVerdict() - failMachine.State, failMachine.Failed = VerdictFail, FailedFirst{Machine: "ace", Words: "path /etc/x is owned by two modules"} - carried := passVerdict() - carried.Beside = map[string]string{"novox/mesh-catalog": "cccc"} - carried.CarriedFrom = map[string]string{"novox/mesh-sdk": "9999", "novox/mesh-control": "bbbb", "novox/mesh-catalog": "cccc"} - carried.Composition = &Composition{State: CompositionPass, SDK: carried.SDK, Machines: []string{"ace"}, FPSet: "fps1:00"} - series := passVerdict() - series.Series = []SeriesChange{{Module: "mesh-sdk", Artifact: "go", From: "0.1", To: "0.2"}, - {Module: "mesh-contracts", Artifact: "@novox/mesh-contracts", From: "0.1", To: "1.0"}} - return map[string]Verdict{"running": running, "pass": passVerdict(), "fail by a test": failTest, "fail by a machine": failMachine, - "carried": carried, "naming its series changes": series} -} - -func keptSample() Kept { - return Kept{State: VerdictPass, Repository: "novox/mesh-sdk", Package: "git.novox.be/novox/mesh-sdk/go/delivery", Level: LevelUnit, - Tests: 41, Took: 190, Toolchain: "1.26.0", At: time.Date(2026, 10, 20, 2, 0, 0, 0, time.UTC)} -} - -// 1359/AC1: the key is .; parsing gives back the group and the hash. -func TestCheckerVerdictKey(t *testing.T) { - heads := []Head{{Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"}} - hh, _ := HeadsHash(heads) - for _, group := range []string{"feat/x", "fix/1.2", "plain", "odd name=1"} { - key, err := CheckerVerdictKey(group, hh) - if err != nil { - t.Fatal(err) - } - if want := EscapeName(group) + "." + hh; key != want { - t.Errorf("key = %q, want %q", key, want) - } - g, h, err := ParseCheckerVerdictKey(key) - if err != nil || g != group || h != hh { - t.Errorf("parse(%q) = %q, %q, %v", key, g, h, err) - } - } - if k, _ := CheckerVerdictKey("feat/x", hh); !strings.HasPrefix(k, "feat/x.") { - t.Errorf("a group with a slash is not kept readable: %q", k) - } - if _, err := CheckerVerdictKey("", hh); err == nil { - t.Error("an empty group accepted") - } - for _, bad := range []string{"", "feat/x", "feat/x.", "feat/x.abc", "feat/x." + strings.ToUpper(hh), ".4b5491557853", "a.b.4b5491557853", "feat/x.4b5491557853a"} { - if _, _, err := ParseCheckerVerdictKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ParseCheckerVerdictKey(%q) = %v, want a refusal naming it", bad, err) - } - } - if _, err := CheckerVerdictKey("feat/x", "abc"); err == nil || !strings.Contains(err.Error(), "abc") { - t.Errorf("a short heads hash: %v", err) - } -} - -// 1359/AC2: a verdict in each state round-trips, with times per part in milliseconds, the tests run and kept per level, -// the machines composed and the mesh-sdk version composed with. -func TestVerdictRoundTrip(t *testing.T) { - for name, v := range verdictSamples() { - data, err := EncodeVerdict(v) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if !strings.HasPrefix(string(data), `{"v":1,`) { - t.Errorf("%s at %s", name, data) - } - got, err := DecodeVerdict(data) - v.V = Version - if err != nil || !SameVerdict(got, v) { - t.Errorf("%s: %+v %v", name, got, err) - } - } - data, _ := EncodeVerdict(passVerdict()) - data, _ = EncodeVerdict(verdictSamples()["naming its series changes"]) - if !strings.Contains(string(data), `"series":[{"module":"mesh-sdk","artifact":"go","from":"0.1","to":"0.2"},`) { - t.Errorf("the series changes are not each {module, artifact, from, to}: %s", data) - } - data, _ = EncodeVerdict(passVerdict()) - for _, field := range []string{`"queue":1200`, `"clone":800`, `"unit":950`, `"integration-setup":2100`, `"integration-run":7400`, `"composition":3300`, `"run":12`, `"kept":301`, `"machines":["ace","g14","novox","shanks"]`, `"sdk":"v0.1.14`} { - if !strings.Contains(string(data), field) { - t.Errorf("the value lacks %s: %s", field, data) - } - } -} - -// 1359/AC3: a fail that names neither a test nor a machine, and a pass whose levels are missing, are refused. -func TestVerdictChecked(t *testing.T) { - cases := map[string]struct { - edit func(*Verdict) - words []string - }{ - "fail naming nothing": {func(v *Verdict) { v.State, v.Failed = VerdictFail, FailedFirst{} }, []string{"test", "machine"}}, - "fail with words only": {func(v *Verdict) { v.State, v.Failed = VerdictFail, FailedFirst{Words: "x"} }, []string{"test", "machine"}}, - "pass without levels": {func(v *Verdict) { v.Levels = nil }, []string{"level"}}, - "pass without unit": {func(v *Verdict) { delete(v.Levels, LevelUnit) }, []string{"unit"}}, - "pass without integration": {func(v *Verdict) { delete(v.Levels, LevelIntegration) }, []string{"integration"}}, - "pass naming a failure": {func(v *Verdict) { v.Failed = FailedFirst{Test: "T"} }, []string{"pass"}}, - "an unknown state": {func(v *Verdict) { v.State = "maybe" }, []string{"maybe"}}, - "an unknown level": {func(v *Verdict) { v.Levels["e2e"] = LevelResult{} }, []string{"e2e"}}, - "a negative time": {func(v *Verdict) { v.Took.Clone = -1 }, []string{"clone"}}, - "a bad heads hash": {func(v *Verdict) { v.Heads = "xyz" }, []string{"xyz"}}, - "no group": {func(v *Verdict) { v.Group = "" }, []string{"group"}}, - "a trunk head left empty": {func(v *Verdict) { v.Trunk["novox/mesh-sdk"] = "" }, []string{"novox/mesh-sdk"}}, - "a negative count": {func(v *Verdict) { v.Levels[LevelUnit] = LevelResult{Run: -1} }, []string{"unit"}}, - } - for name, c := range cases { - v := passVerdict() - c.edit(&v) - _, err := EncodeVerdict(v) - if err == nil { - t.Errorf("%s accepted", name) - continue - } - for _, w := range c.words { - if !strings.Contains(err.Error(), w) { - t.Errorf("%s: error %q does not name %q", name, err, w) - } - } - } - if _, err := DecodeVerdict([]byte(`{"v":1,"group":"g","heads":"4b5491557853","state":"fail"}`)); err == nil { - t.Error("decoded a fail naming nothing") - } -} - -// 1359/AC4: the kept key is .; a level other than unit or integration, or a digest that is not hex, -// is refused, naming it. -func TestKeptKey(t *testing.T) { - digest := strings.Repeat("ab12", 16) - for _, level := range []string{LevelUnit, LevelIntegration} { - key, err := KeptKey(level, digest) - if err != nil || key != level+"."+digest { - t.Fatalf("key = %q, %v", key, err) - } - l, d, err := ParseKeptKey(key) - if err != nil || l != level || d != digest { - t.Errorf("parse = %q, %q, %v", l, d, err) - } - } - if _, err := KeptKey("e2e", digest); err == nil || !strings.Contains(err.Error(), "e2e") { - t.Errorf("a level e2e: %v", err) - } - if _, err := KeptKey("", digest); err == nil { - t.Error("an empty level accepted") - } - for _, d := range []string{"", "xyz", "ABCD", "ab 12", "sha256:abcd", "ab12.cd"} { - if _, err := KeptKey(LevelUnit, d); err == nil || !strings.Contains(err.Error(), "\""+d+"\"") { - t.Errorf("KeptKey(unit, %q) = %v, want a refusal naming it", d, err) - } - } - for _, bad := range []string{"", "unit", "unit.", "e2e." + digest, "unit.xyz", "unit." + digest + ".x", "." + digest} { - if _, _, err := ParseKeptKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ParseKeptKey(%q) = %v, want a refusal naming it", bad, err) - } - } - if CheckerVerdictsBucket != "mesh-delivery-checker_verdicts" || CheckerVerdictsHistory != 5 || - CheckerKeptBucket != "mesh-delivery-checker_kept" || CheckerKeptHistory != 1 || CheckerModule != "mesh-delivery-checker" { - t.Errorf("buckets %s %d %s %d", CheckerVerdictsBucket, CheckerVerdictsHistory, CheckerKeptBucket, CheckerKeptHistory) - } -} - -// 1359/AC5: a kept value that is not a pass cannot be encoded; a failure is never kept (ADR 0444 clause 1). -func TestAFailureIsNeverKept(t *testing.T) { - data, err := EncodeKept(keptSample()) - if err != nil { - t.Fatal(err) - } - got, err := DecodeKept(data) - want := keptSample() - want.V = Version - if err != nil || !SameKept(got, want) { - t.Errorf("round trip: %+v %v", got, err) - } - for _, state := range []string{VerdictFail, VerdictRunning, "", "skipped"} { - k := keptSample() - k.State = state - if _, err := EncodeKept(k); err == nil || !strings.Contains(err.Error(), "pass") { - t.Errorf("a kept value in state %q: %v", state, err) - } - } - if _, err := DecodeKept([]byte(`{"v":1,"state":"fail","repository":"novox/mesh-sdk","package":"p","level":"unit","tests":1,"took":1,"toolchain":"1.26.0","at":"2026-10-20T02:00:00Z"}`)); err == nil { - t.Error("decoded a kept failure") - } - for name, edit := range map[string]func(*Kept){ - "a level e2e": func(k *Kept) { k.Level = "e2e" }, - "no package": func(k *Kept) { k.Package = "" }, - "a bad repository": func(k *Kept) { k.Repository = "nope" }, - "no toolchain": func(k *Kept) { k.Toolchain = "" }, - "no time": func(k *Kept) { k.At = time.Time{} }, - "negative tests": func(k *Kept) { k.Tests = -1 }, - "a negative took": func(k *Kept) { k.Took = -1 }, - } { - k := keptSample() - edit(&k) - if _, err := EncodeKept(k); err == nil { - t.Errorf("%s accepted", name) - } - } -} - -// 1359/AC6: a value of another version, or none, is refused with ErrUnknownVersion. -func TestCheckerVersions(t *testing.T) { - for name, dec := range map[string]func([]byte) error{ - "verdict": func(b []byte) error { _, err := DecodeVerdict(b); return err }, - "kept": func(b []byte) error { _, err := DecodeKept(b); return err }, - } { - for _, in := range []string{`{"v":2,"state":"pass"}`, `{"state":"pass"}`} { - if err := dec([]byte(in)); !errors.Is(err, ErrUnknownVersion) { - t.Errorf("%s decode of %s = %v", name, in, err) - } - } - } -} - -// oldVerdictJSON is a verdict as the SDK before issue 1672 wrote it (copied from EncodeVerdict of passVerdict's shape -// at the commit before this change): no skipped count, no composition, no over-bound names. -const oldVerdictJSON = `{"v":1,"group":"feat/x","heads":"4b5491557853","trunk":{"novox/mesh-control":"bbbb","novox/mesh-sdk":"aaaa"},"state":"pass","levels":{"integration":{"run":3,"kept":40},"unit":{"run":12,"kept":301}},"took":{"queue":1200,"clone":800,"unit":950,"integration-setup":2100,"integration-run":7400,"composition":3300},"machines":["ace","g14","novox","shanks"],"sdk":"v0.1.14-0.20261019091011-aa68b67389c9"}` - -// oldVerdict is the reader of today's shape: the struct as it stood before the new fields. -type oldVerdict struct { - V int `json:"v"` - Group string `json:"group"` - Heads string `json:"heads"` - State string `json:"state"` - Levels map[string]struct{ Run, Kept int } - Machines []string - SDK string -} - -// 1672/AC1: the skipped count, the composition and the over-bound names round trip and appear in the bytes. -func TestVerdictCarriesSkippedCompositionAndOverBound(t *testing.T) { - v := passVerdict() - v.Levels[LevelUnit] = LevelResult{Run: 12, Kept: 301, Skipped: 4} - v.Composition = &Composition{State: CompositionPass, SDK: v.SDK, Machines: []string{"ace", "novox"}} - v.OverBound = []string{LevelIntegration} - data, err := EncodeVerdict(v) - if err != nil { - t.Fatal(err) - } - v.Beside, v.CarriedFrom = map[string]string{"novox/mesh-catalog": "cccc"}, map[string]string{"novox/mesh-catalog": "cbcb"} - v.Composition.FPSet = "fps1:ab" - data, err = EncodeVerdict(v) - if err != nil { - t.Fatal(err) - } - for _, field := range []string{`"skipped":4`, `"composition":{"state":"pass"`, `"over-bound":["integration"]`, `"beside":{"novox/mesh-catalog":"cccc"}`, - `"carried-from":{"novox/mesh-catalog":"cbcb"}`, `"fpset":"fps1:ab"`} { - if !strings.Contains(string(data), field) { - t.Errorf("the value lacks %s: %s", field, data) - } - } - got, err := DecodeVerdict(data) - v.V = Version - if err != nil || !SameVerdict(got, v) || got.Levels[LevelUnit].Skipped != 4 || got.Composition == nil || got.Composition.State != CompositionPass { - t.Errorf("round trip = %+v, %v", got, err) - } - notRun := passVerdict() - notRun.Composition = &Composition{State: CompositionNotRun} - if _, err := EncodeVerdict(notRun); err != nil { - t.Errorf("a composition not yet run refused: %v", err) - } - // 1297: a composition not run says why when the assigner is not running, and round-trips with its words. - notRunning := passVerdict() - notRunning.Composition = &Composition{State: CompositionNotRun, Words: AssignerNotRunning} - data, err = EncodeVerdict(notRunning) - if err != nil { - t.Fatalf("a composition not run because the assigner is not running refused: %v", err) - } - if back, err := DecodeVerdict(data); err != nil || back.Composition.Words != AssignerNotRunning { - t.Errorf("its words came back as %+v, %v", back.Composition, err) - } - failed := passVerdict() - failed.State, failed.Failed = VerdictFail, FailedFirst{Machine: "ace", Words: "x"} - failed.Composition = &Composition{State: CompositionFail, Words: "path /etc/x is owned by two modules", SDK: failed.SDK, Machines: []string{"ace"}} - if _, err := EncodeVerdict(failed); err != nil { - t.Errorf("a failed composition refused: %v", err) - } -} - -// 1672/AC1: an invalid new value is refused, naming the field. -func TestVerdictNewFieldsChecked(t *testing.T) { - cases := map[string]struct { - edit func(*Verdict) - words []string - }{ - "a negative skipped count": {func(v *Verdict) { v.Levels[LevelUnit] = LevelResult{Run: 1, Skipped: -1} }, []string{"unit", "negative"}}, - "an unknown composition": {func(v *Verdict) { v.Composition = &Composition{State: "maybe"} }, []string{"composition", "maybe"}}, - "a composition with no state": {func(v *Verdict) { v.Composition = &Composition{} }, []string{"composition"}}, - "a failed composition without words": {func(v *Verdict) { v.Composition = &Composition{State: CompositionFail} }, []string{"composition", "words"}}, - "a passed composition without the sdk": {func(v *Verdict) { v.Composition = &Composition{State: CompositionPass, Machines: []string{"ace"}} }, []string{"composition", "sdk"}}, - "a not-run composition naming machines": {func(v *Verdict) { v.Composition = &Composition{State: CompositionNotRun, Machines: []string{"ace"}} }, []string{"composition", "not run"}}, - "an unknown over-bound level": {func(v *Verdict) { v.OverBound = []string{"e2e"} }, []string{"e2e"}}, - "an over-bound level named twice": {func(v *Verdict) { v.OverBound = []string{LevelUnit, LevelUnit} }, []string{"over-bound", "unit"}}, - // ADR 0548 §5, §6: the siblings read and the commits a verdict was carried from. - "a sibling with no commit": {func(v *Verdict) { v.Beside = map[string]string{"novox/mesh-catalog": ""} }, []string{"sibling", "mesh-catalog"}}, - "a trunk named as a sibling": {func(v *Verdict) { v.Beside = map[string]string{"novox/mesh-sdk": "aaaa"} }, []string{"trunk", "sibling", "mesh-sdk"}}, - "carried from an unjudged repo": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/other": "1111"} }, []string{"carried-from", "novox/other"}}, - "carried from an empty commit": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": ""} }, []string{"carried-from", "mesh-sdk"}}, - "carried over no move": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": "aaaa"} }, []string{"nothing moved"}}, - "a running verdict carried": {func(v *Verdict) { v.State, v.CarriedFrom = VerdictRunning, map[string]string{"novox/mesh-sdk": "9999"} }, []string{"running", "carried"}}, - // Design 32: each series change whole, once, and a change. - "a series change with no from": {func(v *Verdict) { v.Series = []SeriesChange{{Module: "m", Artifact: "go", To: "0.2"}} }, []string{"series", "from"}}, - "a series change to itself": {func(v *Verdict) { v.Series = []SeriesChange{{Module: "m", Artifact: "go", From: "0.2", To: "0.2"}} }, []string{"m/go", "same series"}}, - "a series change named twice": {func(v *Verdict) { - v.Series = []SeriesChange{{Module: "m", Artifact: "go", From: "0.1", To: "0.2"}, {Module: "m", Artifact: "go", From: "0.2", To: "0.3"}} - }, []string{"m/go", "twice"}}, - } - for name, c := range cases { - v := passVerdict() - c.edit(&v) - _, err := EncodeVerdict(v) - if err == nil { - t.Errorf("%s accepted", name) - continue - } - for _, w := range c.words { - if !strings.Contains(err.Error(), w) { - t.Errorf("%s: error %q does not name %q", name, err, w) - } - } - } -} - -// 1672/AC2: a value written by the SDK before this change decodes with the new reader, with the new fields empty, and -// the new SDK writes the same bytes for it; a value with the new fields decodes under a reader of today's shape. -func TestVerdictOldAndNewReadersAgree(t *testing.T) { - v, err := DecodeVerdict([]byte(oldVerdictJSON)) - if err != nil { - t.Fatalf("the new reader of an old value: %v", err) - } - if v.Composition != nil || v.OverBound != nil || v.Beside != nil || v.CarriedFrom != nil || v.Levels[LevelUnit].Skipped != 0 || v.Levels[LevelUnit].Run != 12 { - t.Errorf("an old value grew fields: %+v", v) - } - again, err := EncodeVerdict(v) - if err != nil || string(again) != oldVerdictJSON { - t.Errorf("the new SDK writes %s, %v for an old value, want today's bytes", again, err) - } - n := passVerdict() - n.Levels[LevelUnit] = LevelResult{Run: 12, Kept: 301, Skipped: 4} - n.Composition = &Composition{State: CompositionNotRun} - n.OverBound = []string{LevelUnit} - n.Beside = map[string]string{"novox/mesh-catalog": "cccc"} - n.CarriedFrom = map[string]string{"novox/mesh-sdk": "9999"} - n.Composition.FPSet = "fps1:00" - data, err := EncodeVerdict(n) - if err != nil { - t.Fatal(err) - } - var ov oldVerdict - if err := json.Unmarshal(data, &ov); err != nil || ov.Group != "feat/x" || ov.State != VerdictPass || ov.Levels[LevelUnit].Run != 12 || ov.SDK == "" { - t.Errorf("the old reader of a new value: %+v, %v", ov, err) - } -} - -func sampleCheckerOwed() Owed { - return Owed{Act: OwedStatus, Context: StatusContextCheck, State: "success", Target: "https://mesh.example/delivery/check/36", - Text: "pass 4b5491557853 on aaaa: unit 12 run 301 kept, integration 3 run 40 kept; composition: not yet run"} -} - -// 1673/AC1: the checker's owed bucket is the checker module plus _owed, and its key is the gate's owed key with the act -// status=mesh/check. -func TestCheckerOwedKeyAndBucket(t *testing.T) { - if CheckerOwedBucket != "mesh-delivery-checker_owed" || CheckerOwedHistory != 5 || PreviewsBucket != "mesh-delivery-checker_previews" || PreviewsHistory != 5 { - t.Errorf("buckets %s %d %s %d", CheckerOwedBucket, CheckerOwedHistory, PreviewsBucket, PreviewsHistory) - } - key, err := CheckerOwedKey("novox/mesh-sdk", 36, "aaaa") - if err != nil || key != "novox/mesh-sdk.36.aaaa.status=mesh/check" { - t.Fatalf("key = %q, %v", key, err) - } - repo, number, head, err := ParseCheckerOwedKey(key) - if err != nil || repo != "novox/mesh-sdk" || number != 36 || head != "aaaa" { - t.Errorf("parse = %q %d %q %v", repo, number, head, err) - } - other, _ := OwedKey("novox/mesh-sdk", 36, "aaaa", "status=mesh/merge-gate") - for _, bad := range []string{other, "novox/mesh-sdk.36.aaaa.view", "novox/mesh-sdk.36.aaaa.note", "novox/mesh-sdk.36.aaaa.comment.1", ""} { - if _, _, _, err := ParseCheckerOwedKey(bad); err == nil { - t.Errorf("checker owed key %q accepted", bad) - } - } - if _, err := CheckerOwedKey("nope", 36, "aaaa"); err == nil { - t.Error("a bad repository accepted") - } -} - -// 1673/AC2: an owed value with the context mesh/check is accepted and an unknown context is refused, in the act and in the value. -func TestOwedAcceptsMeshCheckAndRefusesAnUnknownContext(t *testing.T) { - for _, ctx := range []string{StatusContextCheck, StatusContextMergeGate} { - if kind, got, err := ParseOwedAct("status=" + ctx); err != nil || kind != OwedStatus || got != ctx { - t.Errorf("ParseOwedAct(status=%s) = %q %q %v", ctx, kind, got, err) - } - } - for _, bad := range []string{"status=mesh/other", "status=check", "status=mesh/Check"} { - if _, err := OwedKey("novox/mesh-sdk", 36, "aaaa", bad); err == nil || !strings.Contains(err.Error(), bad[len("status="):]) { - t.Errorf("owed act %q: %v, want a refusal naming the context", bad, err) - } - } - data, err := EncodeCheckerOwed(sampleCheckerOwed()) - if err != nil { - t.Fatal(err) - } - if got, err := DecodeCheckerOwed(data); err != nil || !SameOwed(got, sampleCheckerOwed()) && got.Context != StatusContextCheck { - t.Errorf("round trip = %+v, %v", got, err) - } - unknown := sampleCheckerOwed() - unknown.Context = "mesh/other" - if _, err := EncodeOwed(unknown); err == nil || !strings.Contains(err.Error(), "mesh/other") { - t.Errorf("an owed status of the context mesh/other: %v", err) - } - // The checker's bucket holds only its own status. - gate := sampleCheckerOwed() - gate.Context = StatusContextMergeGate - for name, o := range map[string]Owed{"the gate's context": gate, "a view": {Act: OwedView, Text: "x"}, "a note": {Act: OwedNote, Text: "x", Ref: "mesh-plan"}} { - if _, err := EncodeCheckerOwed(o); err == nil { - t.Errorf("the checker's bucket takes %s", name) - } - } - for _, state := range []string{"pending", "success", "failure"} { - o := sampleCheckerOwed() - o.State = state - if _, err := EncodeCheckerOwed(o); err != nil { - t.Errorf("state %s: %v", state, err) - } - } -} - -func samplePreview() Preview { - return Preview{Repository: "novox/mesh-delivery", Head: "aaaa", Trunk: "bbbb", Merge: "cccc", - Modules: map[string]PreviewModule{ - "modules/mesh-delivery-checker": {Tree: "t1", Manifest: strings.Repeat("1", 64), Locks: map[string]string{"go.sum": strings.Repeat("2", 64)}}, - "modules/mesh-delivery-view": {Tree: "t2", Manifest: strings.Repeat("3", 64)}, - }} -} - -// 1673/AC3: the previews key is .. and parses back. -func TestPreviewKey(t *testing.T) { - key, err := PreviewKey("novox/mesh-delivery", "aaaa", "bbbb") - if err != nil || key != "novox/mesh-delivery.aaaa.bbbb" { - t.Fatalf("key = %q, %v", key, err) - } - repo, head, trunk, err := ParsePreviewKey(key) - if err != nil || repo != "novox/mesh-delivery" || head != "aaaa" || trunk != "bbbb" { - t.Errorf("parse = %q %q %q %v", repo, head, trunk, err) - } - for _, bad := range [][3]string{{"nope", "aaaa", "bbbb"}, {"novox/x", "", "bbbb"}, {"novox/x", "aaaa", ""}, {"novox/x", "a.b", "bbbb"}, {"novox/x", "aaaa", "b b"}} { - if _, err := PreviewKey(bad[0], bad[1], bad[2]); err == nil { - t.Errorf("PreviewKey%v accepted", bad) - } - } - for _, bad := range []string{"", "novox/x.aaaa", "novox/x.aaaa.bbbb.cccc", "nope.aaaa.bbbb", "novox/x..bbbb", "novox/x.aaaa."} { - if _, _, _, err := ParsePreviewKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ParsePreviewKey(%q) = %v, want a refusal naming it", bad, err) - } - } -} - -// 1673/AC4: a preview round trips, and an invalid one is refused naming the field. -func TestPreviewRoundTripAndRefusals(t *testing.T) { - data, err := EncodePreview(samplePreview()) - if err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(string(data), `{"v":1,`) { - t.Errorf("at %s", data) - } - got, err := DecodePreview(data) - want := samplePreview() - want.V = Version - if err != nil || !SamePreview(got, want) { - t.Errorf("round trip = %+v, %v", got, err) - } - if _, err := EncodePreview(Preview{Repository: "novox/x", Head: "aaaa", Trunk: "bbbb", Merge: "cccc"}); err != nil { - t.Errorf("a preview of a head that changes no module refused: %v", err) - } - cases := map[string]struct { - edit func(*Preview) - words []string - }{ - "no repository": {func(p *Preview) { p.Repository = "" }, []string{"repository"}}, - "a bad repository": {func(p *Preview) { p.Repository = "nope" }, []string{"nope"}}, - "no head": {func(p *Preview) { p.Head = "" }, []string{"head"}}, - "no trunk": {func(p *Preview) { p.Trunk = "" }, []string{"trunk"}}, - "no merge": {func(p *Preview) { p.Merge = "" }, []string{"merge"}}, - "a module with no tree": {func(p *Preview) { - p.Modules["modules/mesh-delivery-view"] = PreviewModule{Manifest: strings.Repeat("3", 64)} - }, []string{"modules/mesh-delivery-view", "tree"}}, - "a bad manifest digest": {func(p *Preview) { - p.Modules["modules/mesh-delivery-view"] = PreviewModule{Tree: "t2", Manifest: "xyz"} - }, []string{"modules/mesh-delivery-view", "xyz"}}, - "an empty directory": {func(p *Preview) { p.Modules[""] = PreviewModule{Tree: "t", Manifest: strings.Repeat("3", 64)} }, []string{"directory"}}, - "a lock with no hash": {func(p *Preview) { - p.Modules["modules/mesh-delivery-view"] = PreviewModule{Tree: "t2", Manifest: strings.Repeat("3", 64), Locks: map[string]string{"go.sum": ""}} - }, []string{"modules/mesh-delivery-view", "go.sum"}}, - } - for name, c := range cases { - p := samplePreview() - c.edit(&p) - _, err := EncodePreview(p) - if err == nil { - t.Errorf("%s accepted", name) - continue - } - for _, w := range c.words { - if !strings.Contains(err.Error(), w) { - t.Errorf("%s: error %q does not name %q", name, err, w) - } - } - } - if _, err := DecodePreview([]byte(`{"v":2,"repository":"novox/x"}`)); !errors.Is(err, ErrUnknownVersion) { - t.Errorf("a preview of version 2: %v", err) - } - if _, err := DecodePreview([]byte(`{"v":1,"repository":"novox/x","head":"a","trunk":"b"}`)); err == nil { - t.Error("decoded a preview with no merge") - } -} - -// 1673/AC5: a reader of today's checker shapes ignores a field it does not know, and the checker's owed value is the -// gate's shape, so the gate's reader reads it (as indexer2_test.go does for the indexer's values). -func TestCheckerOwedIsReadableByTheGatesReaderAndAnOldOwedReaderIgnoresNewFields(t *testing.T) { - data, err := EncodeCheckerOwed(sampleCheckerOwed()) - if err != nil { - t.Fatal(err) - } - var old struct { - V int `json:"v"` - Act string `json:"act"` - Context string `json:"context"` - State string `json:"state"` - } - if err := json.Unmarshal(data, &old); err != nil || old.Act != OwedStatus || old.Context != "mesh/check" { - t.Errorf("the old reader: %+v %v", old, err) - } - withNew := strings.Replace(string(data), `{"v":1,`, `{"v":1,"a-field-of-tomorrow":7,`, 1) - if _, err := DecodeOwed([]byte(withNew)); err != nil { - t.Errorf("an owed value with a field of tomorrow: %v", err) - } - p, _ := EncodePreview(samplePreview()) - withNew = strings.Replace(string(p), `{"v":1,`, `{"v":1,"a-field-of-tomorrow":7,`, 1) - if _, err := DecodePreview([]byte(withNew)); err != nil { - t.Errorf("a preview with a field of tomorrow: %v", err) - } -} - -// 1673/AC2 (review): the gate's recheck owes a pending status=mesh/repo-check (mesh-catalog mesh-delivery-gate owed.go, -// checks.go ctxRepo); its key and value round trip, and the checker's bucket still refuses it. -func TestTheGatesRecheckStatusRoundTripsAndTheCheckersBucketRefusesIt(t *testing.T) { - key, err := OwedKey("novox/mesh-sdk", 36, "aaaa", "status=mesh/repo-check") - if err != nil || key != "novox/mesh-sdk.36.aaaa.status=mesh/repo-check" { - t.Fatalf("key = %q, %v", key, err) - } - if _, _, _, act, err := ParseOwedKey(key); err != nil || act != "status=mesh/repo-check" { - t.Errorf("parse = %q, %v", act, err) - } - o := Owed{Act: OwedStatus, Context: StatusContextRepoCheck, State: "pending", Target: "https://mesh.example/delivery/36", Text: "checking again"} - data, err := EncodeOwed(o) - if err != nil { - t.Fatal(err) - } - if got, err := DecodeOwed(data); err != nil || !SameOwed(got, o) && got.Context != o.Context { - t.Errorf("round trip = %+v, %v", got, err) - } - if _, err := EncodeCheckerOwed(o); err == nil { - t.Error("the checker's bucket takes the gate's recheck status") - } - if _, _, _, err := ParseCheckerOwedKey(key); err == nil { - t.Error("a checker owed key of the recheck status accepted") - } -} - -// 1445/AC5 (layout): a head that does not merge is a preview naming its conflicting files, with no merge commit and no -// modules; a preview with conflicts and a merge or modules is refused; a preview of today's shape (no conflicts) -// decodes with none, and a reader of today's shape reads one with conflicts. -func TestPreviewOfAHeadInConflict(t *testing.T) { - c := Preview{Repository: "novox/mesh-delivery", Head: "aaaa", Trunk: "bbbb", Conflicts: []string{"a.txt", "modules/m/module.json"}} - data, err := EncodePreview(c) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), `"conflicts":["a.txt","modules/m/module.json"]`) || strings.Contains(string(data), `"merge"`) { - t.Errorf("at %s", data) - } - got, err := DecodePreview(data) - c.V = Version - if err != nil || !SamePreview(got, c) { - t.Errorf("round trip = %+v, %v", got, err) - } - withMerge := c - withMerge.Merge = "cccc" - withModules := c - withModules.Modules = samplePreview().Modules - emptyPath := c - emptyPath.Conflicts = []string{""} - for name, p := range map[string]Preview{"a merge commit": withMerge, "modules": withModules, "an empty conflicting path": emptyPath} { - if _, err := EncodePreview(p); err == nil || !strings.Contains(err.Error(), "onflict") { - t.Errorf("a preview in conflict with %s: %v", name, err) - } - } - // today's bytes: a clean preview has no conflicts and decodes with none. - clean, _ := EncodePreview(samplePreview()) - if strings.Contains(string(clean), "conflicts") { - t.Errorf("a clean preview carries conflicts: %s", clean) - } - if p, err := DecodePreview(clean); err != nil || p.Conflicts != nil { - t.Errorf("a clean preview decodes with conflicts %v, %v", p.Conflicts, err) - } - var old struct { - V int `json:"v"` - Repository string `json:"repository"` - Head string `json:"head"` - Trunk string `json:"trunk"` - Merge string `json:"merge"` - } - if err := json.Unmarshal(data, &old); err != nil || old.Head != "aaaa" || old.Merge != "" { - t.Errorf("the old reader: %+v %v", old, err) - } -} - -// Issue 1921: a failed verdict names every failed test in Failures and reads back the same; an older reader's value -// without it reads too; Failures on a verdict that is no fail, and more than MaxFailures and the count of the rest, are -// refused. -func TestVerdictNamesEveryFailure(t *testing.T) { - v := passVerdict() - v.State = VerdictFail - v.Failed = FailedFirst{Test: "integration: x/a.TestA", Words: "a_test.go:9: want 1, got 2\nmore"} - v.Failures = []FailedFirst{{Test: "integration: x/a.TestA", Words: "a_test.go:9: want 1, got 2"}, {Test: "integration: x/b.TestB", Words: "b_test.go:3: boom"}} - data, err := EncodeVerdict(v) - if err != nil { - t.Fatal(err) - } - got, err := DecodeVerdict(data) - v.V = Version - if err != nil || !SameVerdict(got, v) || !strings.Contains(string(data), `"failures":[{"test":"integration: x/a.TestA"`) { - t.Fatalf("%s: %+v %v", data, got, err) - } - old := strings.Replace(string(data), string(data[strings.Index(string(data), `,"failures":`):strings.Index(string(data), `,"levels":`)]), "", 1) - if got, err := DecodeVerdict([]byte(old)); err != nil || len(got.Failures) != 0 || got.Failed != v.Failed { - t.Fatalf("a value without failures: %+v %v (%s)", got, err, old) - } - p := passVerdict() - p.Failures = v.Failures - if _, err := EncodeVerdict(p); err == nil { - t.Error("a pass with failures was written") - } - v.Failures = make([]FailedFirst, MaxFailures+2) - for i := range v.Failures { - v.Failures[i] = FailedFirst{Test: "unit: x.T" + strconv.Itoa(i)} - } - if _, err := EncodeVerdict(v); err == nil { - t.Error("a verdict naming more than MaxFailures and the rest was written") - } -} diff --git a/go/delivery/composed.go b/go/delivery/composed.go deleted file mode 100644 index 20c04ac..0000000 --- a/go/delivery/composed.go +++ /dev/null @@ -1,245 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "regexp" - "strings" -) - -// The compositions (novox/hq ADR 0539 clauses 1 to 4). A **composition** is one module's resources composed for one node -// at one fingerprint: a node composes its own (ADR 0547 §2) and writes each it makes into its node's bucket, -// `composed-` (ADR 0593 §6), under `..`, and -// the node's own resources (adoption, firewall openings, accounts, the resolver, identities and groups) under -// `.node`. A composition's digest and its judgement of the move from the composition the node runs now is -// ComposedDigest. -const ( - // ComposedBucketPrefix starts the name of a node's bucket of compositions, ComposedBucketFor(node). - ComposedBucketPrefix = "composed-" - // ComposedHistory is the revisions a node's composition bucket keeps per key (ADR 0539 clause 4). - ComposedHistory = 5 - // ComposedKeep is how many of a module's newest fingerprints keep their compositions though no want names them - // (ADR 0539 clause 4). - ComposedKeep = 5 -) - -// Who writes and who reads a node's compositions (novox/hq ADR 0593 §6): the node's engine alone writes its node's -// bucket, unsigned, a record for readers that no engine applies; the grants part creates each bucket, create-only, and -// grants the readers read-only on every node's bucket. A reader declares ComposedRead in its manifest's `reads`, and the -// engine's manifest announces the state ComposedState so that name resolves. -const ( - // ComposedState is the local name the node-engine's manifest announces for the compositions of every node. - ComposedState = "composed" - // ComposedRead is the `reads` entry naming it: the node-engine's module and ComposedState. - ComposedRead = "mesh-host." + ComposedState -) - -// ComposedReaders are the modules that may read every node's compositions (ADR 0593 §6, §8): the provisioner makes its -// plans from them, the assigner compares them for mesh-wants.upgrade, and the view shows them. -var ComposedReaders = []string{"mesh-delivery-assigner", "mesh-delivery-view", "mesh-provisioner"} - -// nodeWord is the last part of a node's own key, in the have bucket and in a node's composition bucket alike. -const nodeWord = "node" - -var bucketNode = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) - -// ComposedBucketFor is the name of a node's bucket of compositions, "composed-", as declaration.FileBucketFor -// names a node's object bucket (ADR 0488 §4). The node name must be one a bucket and a subject token accept: letters, -// digits, '_' and '-'. -func ComposedBucketFor(node string) (string, error) { - if node == "" { - return "", fmt.Errorf("delivery: the node name is empty; a node's composition bucket is named for its node") - } - if !bucketNode.MatchString(node) { - return "", fmt.Errorf("delivery: the node name %q is not one a bucket accepts: letters, digits, '_' and '-' only", node) - } - return ComposedBucketPrefix + node, nil -} - -// ComposedKey is the key, in ComposedBucketFor(node) and in ControllerComposedBucket, of a module's composition for a -// node at a fingerprint: `..`, the hex being the fingerprint's key part (FingerprintKeyPart). A -// module named `node` is refused, since `.node` is the node's own key. -func ComposedKey(node, module, fingerprint string) (string, error) { - have, err := HaveKey(node, module) - if err != nil { - return "", err - } - part, err := FingerprintKeyPart(fingerprint) - if err != nil { - return "", err - } - return have + "." + part, nil -} - -// ComposedNodeKey is the key of a node's own composition: `.node`. -func ComposedNodeKey(node string) (string, error) { - return HaveNodeKey(node) -} - -// ParseComposedKey is the inverse of ComposedKey and ComposedNodeKey: isNode is true for `.node`, with module -// and fingerprint empty. It refuses any other shape, naming the key. -func ParseComposedKey(key string) (node, module, fingerprint string, isNode bool, err error) { - refused := func(why error) (string, string, string, bool, error) { - return "", "", "", false, fmt.Errorf("delivery: %q is not a composition key, .. or .node: %w", key, why) - } - parts := strings.Split(key, ".") - switch len(parts) { - case 2: - if parts[1] != nodeWord { - return refused(fmt.Errorf("a module's composition names its fingerprint")) - } - if err := checkName("node", parts[0]); err != nil { - return refused(err) - } - return parts[0], "", "", true, nil - case 3: - node, module, isNode, err := ParseHaveKey(parts[0] + "." + parts[1]) - if err != nil { - return refused(err) - } - if isNode { - return refused(fmt.Errorf("the node's own key names no fingerprint")) - } - fp, err := ParseFingerprintKeyPart(parts[2]) - if err != nil { - return refused(err) - } - return node, module, fp, false, nil - } - return refused(fmt.Errorf("it has %d parts", len(parts))) -} - -// Judgement is the controller's judgement of a move from the composition a node runs, as its have names it, to a new -// one (ADR 0539 clauses 2 and 14, by ADR 0377 §3's tests). -type Judgement string - -// The judgements of a move. -const ( - // JudgedRecreates is a move that recreates a container. - JudgedRecreates Judgement = "recreates" - // JudgedMovesData is a move that gives the module's data another place. - JudgedMovesData Judgement = "moves-data" - // JudgedNeither is a move that does neither, and the judgement of a composition the node never ran. - JudgedNeither Judgement = "neither" - // JudgedCannot is a move that cannot be judged, because the composition the node runs is gone or cannot be read. - JudgedCannot Judgement = "cannot-judge" - // JudgedUnchanged is a composition equal to the module's slice of the declaration the node last applied, for a node - // whose have names no composition yet (the first want at the switch, novox/hq ADR 0540 clause 9): applying it - // changes nothing on the node, so it needs no person's yes. - JudgedUnchanged Judgement = "unchanged" -) - -var digestForm = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) - -func checkDigest(digest, of string) error { - if !digestForm.MatchString(digest) { - return fmt.Errorf("delivery: %s %q is not sha256:<64 lowercase hex digits>", of, digest) - } - return nil -} - -// ComposedDigest is the value, in ControllerComposedBucket, beside a composition: its Digest (ComposedDigestOf), and -// the Judgement of the move to it from the composition the node runs now, whose digest is Against (empty when the node -// runs no composition of the module). Words say why, and are required for a move that cannot be judged. Rewritten when -// the node's have changes. -// -// Policy is the module's upgrade policy as the controller holds it (ADR 0236, ADR 0539 clause 14), with PolicyFrom -// where it comes from and PolicyWhy why: the assigner holds a move of a module of policy record from it, the operator's -// own choice (`upgrade` with policy) included. Rewritten when the policy changes. Empty on a composition made by hand, -// whose want the operator wrote. -type ComposedDigest struct { - V int `json:"v"` - Digest string `json:"digest"` - Judgement Judgement `json:"judgement"` - Against string `json:"against,omitempty"` - Words string `json:"words,omitempty"` - // UserListOnly says the composition differs from the one the node's have names only in the bus's user list (the - // bus module's file `.bus-users`), whatever the judgement says of its mount: the assigner writes such a - // revision into the bus node's want at once, outside the record hold, at the fingerprint the node runs (ADR 0539 - // clause 7). - UserListOnly bool `json:"user-list-only,omitempty"` - Policy string `json:"policy,omitempty"` - PolicyFrom string `json:"policy-from,omitempty"` - PolicyWhy string `json:"policy-why,omitempty"` -} - -// The upgrade policies and their sources a composition's value names: catalogue.PolicyRoll, PolicyTogether and -// PolicyRecord, and catalogue.FromPerson, FromModule, FromData and FromDefault, the same words (the compose package's -// test holds them equal; this package does not import the catalogue). "the bus" is the word an older reader derived -// for the bus module, still read from a composition it wrote; nothing derives it now. -var ( - composedPolicies = []string{"roll", "together", "record"} - composedPolicyFroms = []string{"person", "module", "the bus", "irreplaceable data", "default"} -) - -func oneOf(v string, of []string) bool { - for _, o := range of { - if v == o { - return true - } - } - return false -} - -func (c ComposedDigest) check() error { - if err := checkDigest(c.Digest, "the composition's digest"); err != nil { - return err - } - if c.Against != "" { - if err := checkDigest(c.Against, "the digest a move is judged against"); err != nil { - return err - } - } - switch c.Judgement { - case JudgedRecreates, JudgedMovesData: - if c.Against == "" { - return fmt.Errorf("delivery: a move judged %s names the composition it is judged against", c.Judgement) - } - case JudgedNeither, JudgedUnchanged: - case JudgedCannot: - if c.Words == "" { - return fmt.Errorf("delivery: a move that cannot be judged says why") - } - default: - return fmt.Errorf("delivery: the judgement %q is not %s, %s, %s, %s or %s", c.Judgement, JudgedRecreates, - JudgedMovesData, JudgedNeither, JudgedCannot, JudgedUnchanged) - } - if c.Policy != "" || c.PolicyFrom != "" { - if !oneOf(c.Policy, composedPolicies) { - return fmt.Errorf("delivery: the upgrade policy %q is not one of %v", c.Policy, composedPolicies) - } - if !oneOf(c.PolicyFrom, composedPolicyFroms) { - return fmt.Errorf("delivery: the upgrade policy's source %q is not one of %v", c.PolicyFrom, composedPolicyFroms) - } - } - return nil -} - -// PolicyRecord is the policy whose moves the assigner holds for a person's yes (ADR 0539 clause 14). -const PolicyRecord = "record" - -// EncodeComposedDigest is c as JSON at Version; it refuses an unknown judgement, a digest not of the sha256 form, a -// move judged to recreate or move data against nothing, and a move that cannot be judged without its reason. -func EncodeComposedDigest(c ComposedDigest) ([]byte, error) { - if err := c.check(); err != nil { - return nil, err - } - c.V = Version - return json.Marshal(c) -} - -// DecodeComposedDigest reads the value beside a composition; it refuses an unknown version (ErrUnknownVersion) and -// what EncodeComposedDigest refuses. -func DecodeComposedDigest(data []byte) (ComposedDigest, error) { - var c ComposedDigest - if err := decode(data, &c); err != nil { - return ComposedDigest{}, err - } - if err := c.check(); err != nil { - return ComposedDigest{}, err - } - return c, nil -} - -// SameComposedDigest says whether two values are the same, so the controller writes only when they differ. -func SameComposedDigest(a, b ComposedDigest) bool { return a == b } diff --git a/go/delivery/composed_compare.go b/go/delivery/composed_compare.go deleted file mode 100644 index 3ce208d..0000000 --- a/go/delivery/composed_compare.go +++ /dev/null @@ -1,387 +0,0 @@ -package delivery - -import ( - "bytes" - "encoding/json" - "strconv" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// Comparison is what CompareComposed finds, module by module: the same, differing, added by what was composed (not run -// now) and removed by it (run now, not composed). Each list is sorted by module. -type Comparison struct { - Same []string - Differs []ModuleDiffers - Added []ModuleAdded - Removed []string -} - -// CompareComposed compares the compositions a node composed (by key, as compose.Compositions answers them) with the ones -// it runs, as its agent would (ADR 0559 §4: a node's answer to a placement candidate). A module is the same when its -// composition's digest is the one it runs (ComposedDigestOf, the signature left out), so the agent would apply nothing -// of it; it differs when the digests differ (both named); it is added when composed and not run, removed when run and -// not composed. The node's own composition is named HaveNodeWord. The bus's user list (catalogue.BusUsersID) is left out -// on both sides: the bus node follows it alone and never applies it from a composition it made (ADR 0547 §2, §8). A -// difference names the first resource, by its id, whose content differs or that one side alone holds; when every -// resource is equal it says what else differs (their order, the files filled, the data, the node's own parts, a file -// carried by digest). -func CompareComposed(composed map[string]Composed, running []Composed) Comparison { - return CompareComposedWith(composed, running, nil) -} - -// CompareComposedWith is CompareComposed showing, for each module that differs, every differing resource with both -// bodies masked (MaskedPair); sameValue, when given, says whether two sealed values open to the same value (the node -// opens them with its own key), so a value sealed again reads as such. -func CompareComposedWith(composed map[string]Composed, running []Composed, sameValue func(a, b string) (same, known bool)) Comparison { - name := func(c Composed) string { - if c.Module == "" { - return HaveNodeWord - } - return c.Module - } - ours := map[string]Composed{} - for _, c := range composed { - ours[name(c)] = c - } - theirs := map[string]Composed{} - for _, c := range running { - theirs[name(c)] = c - } - modules := map[string]bool{} - for m := range ours { - modules[m] = true - } - for m := range theirs { - modules[m] = true - } - var out Comparison - for _, m := range sortedKeys(modules) { - a, composedHere := ours[m] - b, runs := theirs[m] - switch { - case !runs: - dig, _ := ComposedDigestOf(withoutBusUsers(a)) - out.Added = append(out.Added, ModuleAdded{Module: m, Composed: dig}) - case !composedHere: - out.Removed = append(out.Removed, m) - default: - if d, ok := compareOne(m, a, b, sameValue); ok { - out.Same = append(out.Same, m) - } else { - out.Differs = append(out.Differs, d) - } - } - } - return out -} - -// compareOne is one module's comparison: true when the digests agree, else what differs first. -func compareOne(module string, composed, running Composed, sameValue func(a, b string) (bool, bool)) (ModuleDiffers, bool) { - composed, running = withoutBusUsers(composed), withoutBusUsers(running) - // A sealed value is judged by the value it opens to, slot by slot (issue 1126, C1): a value sealed again at the same - // field of the same resource is the box that runs, never a difference; and a box is never matched across resources. - composed = KeepSeals(composed, SealsIn(running), sameValue) - dc, errC := ComposedDigestOf(composed) - dr, errR := ComposedDigestOf(running) - if errC != nil { - return ModuleDiffers{Module: module, Running: dr, Why: "the composition has no digest: " + errC.Error()}, false - } - if errR == nil && dc == dr { - return ModuleDiffers{}, true - } - differs := func(resource, why string) (ModuleDiffers, bool) { - return ModuleDiffers{Module: module, Composed: dc, Running: dr, Resource: resource, Why: why}, false - } - // Words for the difference: resource by resource, each side with its large files by digest, so a file carried whole - // and the same file carried by digest read as one. - a, _, errA := composed.ByDigest() - b, _, errB := running.ByDigest() - if errA != nil || errB != nil { - return differs("", "the compositions differ and cannot be read resource by resource") - } - ra, rb := resourcesByID(a.Resources), resourcesByID(b.Resources) - ids := map[string]bool{} - for id := range ra { - ids[id] = true - } - for id := range rb { - ids[id] = true - } - var first ModuleDiffers - found := false - var bodies []ResourceBodies - for _, id := range sortedKeys(ids) { - x, inA := ra[id] - y, inB := rb[id] - why := "" - switch { - case !inB: - why = "composed, and not in what runs" - case !inA: - why = "in what runs, and not composed" - case !bytes.Equal(x, y): - why = "the resource differs" - default: - continue - } - if !found { - first, _ = differs(id, why) - found = true - } - if len(bodies) < MaxBodies { - // The paths are found on the whole bodies, before either is cut (issue 1126, T1: three resources over - // MaxBody could not be told apart). - c, r := MaskedPair(x, y, sameValue) - bodies = append(bodies, ResourceBodies{ID: id, Why: why, Paths: DifferingPaths(x, y), - Composed: cut(c), Running: cut(r)}) - } - } - if found { - first.Bodies = bodies - return first, false - } - switch { - case !jsonEqual(a.Filled, b.Filled): - return differs("", "the files the provisioner fills differ") - case a.Data != b.Data: - return differs("", "whether it declares data differs") - case !jsonEqual(a.Own, b.Own): - return differs("", "the node's own parts differ") - case a.FP != b.FP: - return differs("", "the fingerprint differs") - } - if dA, err := ComposedDigestOf(a); err == nil { - if dB, err := ComposedDigestOf(b); err == nil && dA == dB { - return differs("", "the same content, a large file carried whole on one side and by digest on the other") - } - } - return differs("", "the same resources in another order") -} - -// withoutBusUsers is c without the bus's user list resource (its id `bus-users`, or `.bus-users`). -func withoutBusUsers(c Composed) Composed { - out := c - out.Resources = nil - for _, raw := range c.Resources { - var head struct { - ID string `json:"id"` - } - if json.Unmarshal(raw, &head) == nil && (head.ID == catalogue.BusUsersID() || strings.HasSuffix(head.ID, "."+catalogue.BusUsersID())) { - continue - } - out.Resources = append(out.Resources, raw) - } - return out -} - -// resourcesByID is each resource canonical, by its id (a resource without one by its place). -func resourcesByID(resources []json.RawMessage) map[string][]byte { - out := make(map[string][]byte, len(resources)) - for i, raw := range resources { - var head struct { - ID string `json:"id"` - } - _ = json.Unmarshal(raw, &head) - id := head.ID - if id == "" { - id = "#" + strconv.Itoa(i) - } - canon, err := canonicalJSON(raw) - if err != nil { - canon = raw - } - out[id] = canon - } - return out -} - -// cut is s cut at MaxBody bytes, saying so. -func cut(s string) string { - if len(s) <= MaxBody { - return s - } - return s[:MaxBody] + "… (cut at " + strconv.Itoa(MaxBody) + " bytes)" -} - -// secretWords are the words of a key whose value may hold a secret or a sealed one: masked in a shown body. -var secretWords = []string{"sealed", "secret", "password", "passwd", "token", "private", "key", "credential", "jwt", "salt", "hash"} - -func secretKey(k string) bool { - k = strings.ToLower(k) - for _, w := range secretWords { - if strings.Contains(k, w) { - return true - } - } - return false -} - -// MaskedPair is two bodies of one resource (either may be nil) as JSON text with every value under a key a secret could -// be in (secretWords, at any depth) masked: "", "" or "" when sameValue says so, "" otherwise, "" on one side -// alone. A body that is not JSON is shown as its length only. -func MaskedPair(composed, running json.RawMessage, sameValue func(a, b string) (same, known bool)) (string, string) { - var a, b any - okA := composed != nil && json.Unmarshal(composed, &a) == nil - okB := running != nil && json.Unmarshal(running, &b) == nil - if okA && okB { - a, b = maskBoth(a, b, sameValue) - } else if okA { - a, _ = maskBoth(a, nil, nil) - } else if okB { - _, b = maskBoth(nil, b, nil) - } - show := func(v any, raw json.RawMessage, ok bool) string { - if raw == nil { - return "" - } - if !ok { - return "" - } - var out bytes.Buffer - enc := json.NewEncoder(&out) - enc.SetEscapeHTML(false) - _ = enc.Encode(v) - return strings.TrimSuffix(out.String(), "\n") - } - return show(a, composed, okA), show(b, running, okB) -} - -func maskBoth(a, b any, sameValue func(a, b string) (bool, bool)) (any, any) { - ma, isA := a.(map[string]any) - mb, isB := b.(map[string]any) - if isA || isB { - outA, outB := map[string]any{}, map[string]any{} - keys := map[string]bool{} - for k := range ma { - keys[k] = true - } - for k := range mb { - keys[k] = true - } - for k := range keys { - va, inA := ma[k] - vb, inB := mb[k] - if secretKey(k) { - x, y := maskAll(k, va, vb, inA && inB, sameValue) - if inA { - outA[k] = x - } - if inB { - outB[k] = y - } - continue - } - x, y := maskBoth(va, vb, sameValue) - if inA { - outA[k] = x - } - if inB { - outB[k] = y - } - } - var ra, rb any - if isA { - ra = outA - } else { - ra = a - } - if isB { - rb = outB - } else { - rb = b - } - return ra, rb - } - la, isLA := a.([]any) - lb, isLB := b.([]any) - if isLA || isLB { - outA := make([]any, len(la)) - outB := make([]any, len(lb)) - for i := range max(len(la), len(lb)) { - var x, y any - if i < len(la) { - x = la[i] - } - if i < len(lb) { - y = lb[i] - } - x, y = maskBoth(x, y, sameValue) - if i < len(la) { - outA[i] = x - } - if i < len(lb) { - outB[i] = y - } - } - var ra, rb any = a, b - if isLA { - ra = outA - } - if isLB { - rb = outB - } - return ra, rb - } - return a, b -} - -// maskAll is a value under a key a secret could be under, masked as one word (maskWord); a file's secrets -// (SecretsField) alone entry by entry, each its own sealed value: "another seal of the same value" or "another value" -// per secret, its name shown (issue 1126, C1). Any other object, a vault box included, is one word, judged whole. -func maskAll(key string, a, b any, both bool, sameValue func(a, b string) (bool, bool)) (any, any) { - ma, isA := a.(map[string]any) - mb, isB := b.(map[string]any) - if key != SecretsField || (!isA && !isB) { - w := maskWord(a, b, both, sameValue) - return w, w - } - outA, outB := map[string]any{}, map[string]any{} - for k, va := range ma { - vb, inB := mb[k] - outA[k] = maskWord(va, vb, isB && inB, sameValue) - } - for k, vb := range mb { - va, inA := ma[k] - outB[k] = maskWord(va, vb, isA && inA, sameValue) - } - // A map on one side and another value on the other: the other is one word. - var ra, rb any = outA, outB - if !isA { - ra = maskWord(a, b, both, sameValue) - } - if !isB { - rb = maskWord(a, b, both, sameValue) - } - return ra, rb -} - -func maskWord(a, b any, both bool, sameValue func(a, b string) (bool, bool)) string { - if !both { - return "" - } - x, _ := json.Marshal(a) - y, _ := json.Marshal(b) - if bytes.Equal(x, y) { - return "" - } - if sameValue != nil { - sa, okA := a.(string) - sb, okB := b.(string) - if !okA { - sa = string(x) - } - if !okB { - sb = string(y) - } - if same, known := sameValue(sa, sb); known { - if same { - return "" - } - return "" - } - } - return "" -} diff --git a/go/delivery/composed_keys_test.go b/go/delivery/composed_keys_test.go deleted file mode 100644 index 5882208..0000000 --- a/go/delivery/composed_keys_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package delivery - -import ( - "errors" - "strings" - "testing" -) - -// 1943/AC1: a node's composition bucket is named for the node, and a node with no name, or one a bucket refuses, -// is refused, naming why. -func TestComposedBucketFor(t *testing.T) { - b, err := ComposedBucketFor("ace") - if err != nil || b != "composed-ace" { - t.Fatalf("ComposedBucketFor(ace) = %q, %v", b, err) - } - for _, bad := range []string{"", "a.b", "a*", "a b", "a>"} { - if _, err := ComposedBucketFor(bad); err == nil || !strings.Contains(err.Error(), "node") { - t.Errorf("ComposedBucketFor(%q) = %v, want a refusal naming the node", bad, err) - } - } -} - -// 1943/AC1: a module's composition key and the node's own key round-trip through one parser; an empty node, a -// module named node, a bad fingerprint and a key of another shape are refused, naming why. -func TestComposedKeys(t *testing.T) { - fp := fp2of('c') - key, err := ComposedKey("ace", "mesh-mail", fp) - if err != nil || key != "ace.mesh-mail."+strings.Repeat("c", 64) { - t.Fatalf("ComposedKey = %q, %v", key, err) - } - node, module, back, isNode, err := ParseComposedKey(key) - if err != nil || node != "ace" || module != "mesh-mail" || back != fp || isNode { - t.Errorf("ParseComposedKey(%q) = %q, %q, %q, %v, %v", key, node, module, back, isNode, err) - } - own, err := ComposedNodeKey("ace") - if err != nil || own != "ace.node" { - t.Fatalf("ComposedNodeKey = %q, %v", own, err) - } - node, module, back, isNode, err = ParseComposedKey(own) - if err != nil || node != "ace" || module != "" || back != "" || !isNode { - t.Errorf("ParseComposedKey(%q) = %q, %q, %q, %v, %v", own, node, module, back, isNode, err) - } - - refusals := []struct { - node, module, fp, why string - }{ - {"", "mesh-mail", fp, "node name is empty"}, - {"ace", "node", fp, `"node" is refused`}, - {"ace", "", fp, "module name is empty"}, - {"ace", "mesh-mail", "src2:abc", "not a fingerprint of the new form"}, - {"ace", "mesh-mail", "src1:" + strings.Repeat("c", 64), "not a fingerprint of the new form"}, - {"a.b", "mesh-mail", fp, "node name"}, - } - for _, r := range refusals { - if _, err := ComposedKey(r.node, r.module, r.fp); err == nil || !strings.Contains(err.Error(), r.why) { - t.Errorf("ComposedKey(%q, %q, %q) = %v, want a refusal saying %q", r.node, r.module, r.fp, err, r.why) - } - } - if _, err := ComposedNodeKey(""); err == nil { - t.Errorf("ComposedNodeKey(\"\") accepted") - } - for _, bad := range []string{"", "ace", "ace.mesh-mail", "ace.node.x", "ace.mesh-mail.abc", ".mesh-mail." + strings.Repeat("c", 64), - "ace.node." + strings.Repeat("c", 64), "ace.mesh-mail." + strings.Repeat("c", 64) + ".x", "want.ace.mesh-mail"} { - if _, _, _, _, err := ParseComposedKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ParseComposedKey(%q) = %v, want a refusal naming it", bad, err) - } - } -} - -// 1943/AC1: the controller's value beside a composition, its digest and the judgement of the move, round-trips; an -// unknown judgement, a digest not of the sha256 form, a move judged against nothing, a cannot-judge without its -// reason, and an upgrade policy or source outside the catalogue's words are refused, naming why; an unknown version is -// refused as one. The module's upgrade policy and its source ride beside the judgement, for the assigner's hold. -func TestComposedDigestValue(t *testing.T) { - digest := "sha256:" + strings.Repeat("d", 64) - against := "sha256:" + strings.Repeat("e", 64) - for _, v := range []ComposedDigest{ - {Digest: digest, Judgement: JudgedNeither}, - {Digest: digest, Judgement: JudgedNeither, Against: against}, - {Digest: digest, Judgement: JudgedRecreates, Against: against, Words: "the container's image changes"}, - {Digest: digest, Judgement: JudgedMovesData, Against: against}, - {Digest: digest, Judgement: JudgedCannot, Words: "the composition the node runs is gone"}, - {Digest: digest, Judgement: JudgedUnchanged, Words: "equal to the declaration the node last applied"}, - {Digest: digest, Judgement: JudgedNeither, Policy: "record", PolicyFrom: "person", PolicyWhy: "the operator: its data"}, - {Digest: digest, Judgement: JudgedNeither, Policy: "roll", PolicyFrom: "default"}, - {Digest: digest, Judgement: JudgedNeither, Against: against, UserListOnly: true, Words: "only the bus's user list changes"}, - } { - raw, err := EncodeComposedDigest(v) - if err != nil { - t.Fatalf("EncodeComposedDigest(%+v): %v", v, err) - } - back, err := DecodeComposedDigest(raw) - v.V = Version - if err != nil || !SameComposedDigest(back, v) { - t.Errorf("round trip of %+v = %+v, %v", v, back, err) - } - } - refusals := []struct { - v ComposedDigest - why string - }{ - {ComposedDigest{Digest: digest, Judgement: "maybe"}, `judgement "maybe"`}, - {ComposedDigest{Digest: digest}, `judgement ""`}, - {ComposedDigest{Digest: "sha256:abc", Judgement: JudgedNeither}, "not sha256"}, - {ComposedDigest{Judgement: JudgedNeither}, "not sha256"}, - {ComposedDigest{Digest: digest, Judgement: JudgedRecreates}, "against"}, - {ComposedDigest{Digest: digest, Judgement: JudgedMovesData}, "against"}, - {ComposedDigest{Digest: digest, Judgement: JudgedCannot}, "why"}, - {ComposedDigest{Digest: digest, Judgement: JudgedNeither, Against: "sha256:zz"}, "not sha256"}, - {ComposedDigest{Digest: digest, Judgement: JudgedNeither, Policy: "sometimes", PolicyFrom: "person"}, `policy "sometimes"`}, - {ComposedDigest{Digest: digest, Judgement: JudgedNeither, Policy: "record"}, `source ""`}, - {ComposedDigest{Digest: digest, Judgement: JudgedNeither, PolicyFrom: "person"}, `policy ""`}, - } - for _, r := range refusals { - if _, err := EncodeComposedDigest(r.v); err == nil || !strings.Contains(err.Error(), r.why) { - t.Errorf("EncodeComposedDigest(%+v) = %v, want a refusal saying %q", r.v, err, r.why) - } - } - if _, err := DecodeComposedDigest([]byte(`{"v":2,"digest":"` + digest + `","judgement":"neither"}`)); !errors.Is(err, ErrUnknownVersion) { - t.Errorf("version 2 = %v, want ErrUnknownVersion", err) - } - if _, err := DecodeComposedDigest([]byte(`{"v":1,"digest":"` + digest + `","judgement":"perhaps"}`)); err == nil { - t.Errorf("an unknown judgement was read") - } -} diff --git a/go/delivery/composed_plan_test.go b/go/delivery/composed_plan_test.go deleted file mode 100644 index f6993c4..0000000 --- a/go/delivery/composed_plan_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package delivery - -import ( - "encoding/json" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// A composition that carries its plan or its refusal (ADR 0593 §6): each round-trips, and a reader refuses what it could -// not take, naming it. (compose-candidate's ask and answer are node-engine's client's, mesh-contracts -// go/clients/nodeengineclient, ADR 0627 §1.) -func TestComposeCandidateAndComposedPlanRoundTripAndRefuse(t *testing.T) { - fp := "src2:" + strings.Repeat("a", 64) - planned := Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}, - Plan: &catalogue.Plan{Files: []catalogue.PlanFile{}}} - raw, err := EncodeComposed(planned) - if err != nil { - t.Fatal(err) - } - c, err := DecodeComposed(raw) - if err != nil || c.Plan == nil { - t.Fatalf("a composition's plan did not round-trip: %v, %+v", err, c) - } - d1, _ := ComposedDigestOf(planned) - d0, _ := ComposedDigestOf(Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}}) - if d1 == "" || d1 != d0 { - t.Error("the digest moves with the plan: it is the resources' alone") - } - if SameComposed(planned, Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}}) { - t.Error("a plan change is not a new composition value") - } - signed := strings.Replace(string(raw), `"resources"`, `"signature":{"signer":"x","sig":"y"},"resources"`, 1) - if c, err := DecodeComposed([]byte(signed)); err != nil || c.Node != "ace" { - t.Fatalf("a composition the ended controller signed does not decode: %v", err) - } - if strings.Contains(string(raw), "signature") { - t.Error("a composition is written with a signature") - } - refused := Composed{Node: "ace", Module: "netcheck", FP: fp, Refused: "the setting domain has no value"} - if raw, err = EncodeComposed(refused); err != nil { - t.Fatal(err) - } - if c, err := DecodeComposed(raw); err != nil || c.Refused != refused.Refused { - t.Fatalf("a refused composition did not round-trip: %v", err) - } - if _, err := ComposedDigestOf(refused); err == nil { - t.Error("a refused composition has a digest") - } - refused.Data = true - if _, err := EncodeComposed(refused); err == nil { - t.Error("a refused composition holding data was encoded") - } - if _, err := EncodeComposed(Composed{Node: "ace", Resources: []json.RawMessage{}, Refused: "x"}); err == nil { - t.Error("the node's own composition carried a refusal") - } -} - -// A kept import form is set only with imports, and in the fingerprint's form (ADR 0593 §4). -func TestAKeptImportFormNeedsImports(t *testing.T) { - form := SourcePrefix2 + strings.Repeat("b", 64) - in := KeptInputs2{Tree: "t", Imports: []string{"git.novox.be/novox/mesh-sdk/go"}, ImportForm: form} - m := Module{FP2: form, Manifest: strings.Repeat("c", 64), Inputs2: &in, Repository: "novox/x", Commit: "c"} - if err := m.check(); err != nil && strings.Contains(err.Error(), "import form") { - t.Fatalf("a good import form refused: %v", err) - } - for _, bad := range []KeptInputs2{{Tree: "t", ImportForm: form}, {Tree: "t", Imports: in.Imports, ImportForm: "src2:short"}} { - m.Inputs2 = &bad - if err := m.check(); err == nil || !strings.Contains(err.Error(), "import form") { - t.Errorf("%+v: %v", bad, err) - } - } -} diff --git a/go/delivery/composed_seals.go b/go/delivery/composed_seals.go deleted file mode 100644 index aa43da9..0000000 --- a/go/delivery/composed_seals.go +++ /dev/null @@ -1,194 +0,0 @@ -package delivery - -import ( - "bytes" - "encoding/json" - "sort" - "strconv" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// Seals is, by slot, each value one composition carries sealed to its node's key (issue 1126, C1): a file's `sealed` -// a vault box (catalogue.VaultSealedField) and each entry of a file's secrets (SecretsField), as their JSON text (a box is carried as its JSON, a sealed value as -// the string it is). A slot is one field of one resource of the composition's module (SealSlot): a box is reused only -// at the slot that holds it, never for another resource whose value opens to the same (a provider's grant and its -// consumer's secret on one node hold one value). -type Seals map[string]string - -// SealedFields are the fields of a resource that carry a value sealed to the node's key. -var SealedFields = []string{"sealed", catalogue.VaultSealedField} - -// SealSlot is the slot of one sealed field of one resource, by the resource's id (its place, `#`, when it has none). -// The module is the composition's: a slot is never compared across compositions. -func SealSlot(resource, field string) string { return resource + "#" + field } - -// SecretsField is the field of a file that carries, by name, each sealed value its content asks for -// (${secret:}, catalogue's intoFile): each entry is a slot of its own (SecretSlotField), so a secret sealed again -// is judged by the value it opens to, entry by entry, like a file's `sealed` (issue 1126, C1; the seed-7 probe: about 45 -// files on ace and novox differed by their secrets' bytes alone). -const SecretsField = "secrets" - -// SecretSlotField is the field part of the slot of one entry of a file's secrets (SealSlot(id, SecretSlotField(name))). -func SecretSlotField(name string) string { return SecretsField + "." + name } - -// sealAt is one sealed value of a resource: its slot's field, its place (a top-level field, or an entry of the -// secrets), and its raw JSON. -type sealAt struct { - field, entry string - raw json.RawMessage -} - -// sealsAt is every sealed value of one resource's fields, in a fixed order: SealedFields, then the secrets by name. -func sealsAt(fields map[string]json.RawMessage) []sealAt { - var out []sealAt - for _, field := range SealedFields { - if f, has := fields[field]; has { - out = append(out, sealAt{field: field, raw: f}) - } - } - var secrets map[string]json.RawMessage - if f, has := fields[SecretsField]; has && json.Unmarshal(f, &secrets) == nil { - names := make([]string, 0, len(secrets)) - for name := range secrets { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - out = append(out, sealAt{field: SecretSlotField(name), entry: name, raw: secrets[name]}) - } - } - return out -} - -// SealsIn is every sealed value c carries, by slot; nil when it carries none. -func SealsIn(c Composed) Seals { - var out Seals - for i, raw := range c.Resources { - id, fields, ok := sealedFieldsOf(raw, i) - if !ok { - continue - } - for _, s := range sealsAt(fields) { - if text := sealedText(s.raw); text != "" { - if out == nil { - out = Seals{} - } - out[SealSlot(id, s.field)] = text - } - } - } - return out -} - -// KeepSeals is c with each sealed value replaced by the one kept at the same slot, where same says the two open to the -// same value (known and same): a value the vault sealed again is then the box that runs, so the composition is the -// one that runs (ADR 0547 §3, issue 1126). A slot kept holds nothing for, or whose value differs or cannot be opened, -// keeps c's. Every other byte of a resource is kept as it is; c itself is not changed. -func KeepSeals(c Composed, kept Seals, same func(a, b string) (same, known bool)) Composed { - if len(kept) == 0 || same == nil { - return c - } - out := c - out.Resources = nil - for i, raw := range c.Resources { - id, fields, ok := sealedFieldsOf(raw, i) - if ok { - for _, s := range sealsAt(fields) { - ours := sealedText(s.raw) - theirs, held := kept[SealSlot(id, s.field)] - if !held || ours == "" || theirs == ours { - continue - } - if opens, known := same(ours, theirs); !known || !opens { - continue - } - value := json.RawMessage(theirs) - if s.raw[0] == '"' { - value, _ = json.Marshal(theirs) - } - if s.entry == "" { - if swapped, ok := withField(raw, s.field, value); ok { - raw = swapped - } - continue - } - // An entry of the secrets: swapped inside the secrets as they stand in raw, then the secrets in raw. - var now map[string]json.RawMessage - if json.Unmarshal(raw, &now) != nil { - continue - } - if secrets, ok := withField(now[SecretsField], s.entry, value); ok { - if swapped, ok := withField(raw, SecretsField, secrets); ok { - raw = swapped - } - } - } - } - out.Resources = append(out.Resources, raw) - } - return out -} - -// sealedFieldsOf is a resource's id (`#` when it has none) and its top-level fields, false when it is no object. -func sealedFieldsOf(raw json.RawMessage, i int) (string, map[string]json.RawMessage, bool) { - var fields map[string]json.RawMessage - if json.Unmarshal(raw, &fields) != nil { - return "", nil, false - } - var id string - if f, ok := fields["id"]; !ok || json.Unmarshal(f, &id) != nil || id == "" { - id = "#" + strconv.Itoa(i) - } - return id, fields, true -} - -// sealedText is a sealed field as its text: a string as it is, a box as its JSON. -func sealedText(f json.RawMessage) string { - var text string - if json.Unmarshal(f, &text) == nil { - return text - } - var compact bytes.Buffer - if json.Compact(&compact, f) != nil { - return string(f) - } - return compact.String() -} - -// withField is the object raw with its top-level field replaced by value, every other byte as it was (so a composition -// whose sealed values are those that run is the one that runs, byte for byte); false when raw is no object holding it. -func withField(raw json.RawMessage, field string, value json.RawMessage) (json.RawMessage, bool) { - d := json.NewDecoder(bytes.NewReader(raw)) - if t, err := d.Token(); err != nil || t != json.Delim('{') { - return nil, false - } - for d.More() { - t, err := d.Token() - if err != nil { - return nil, false - } - key, _ := t.(string) - before := d.InputOffset() - var skip json.RawMessage - if err := d.Decode(&skip); err != nil { - return nil, false - } - if key != field { - continue - } - after := d.InputOffset() - // The span holds the colon, any space and the value: kept up to the colon, the value replaced. - span := raw[before:after] - colon := bytes.IndexByte(span, ':') - if colon < 0 { - return nil, false - } - out := make([]byte, 0, len(raw)-len(span)+colon+1+len(value)) - out = append(out, raw[:int(before)+colon+1]...) - out = append(out, value...) - out = append(out, raw[after:]...) - return out, true - } - return nil, false -} diff --git a/go/delivery/composed_value.go b/go/delivery/composed_value.go deleted file mode 100644 index e3535f0..0000000 --- a/go/delivery/composed_value.go +++ /dev/null @@ -1,348 +0,0 @@ -package delivery - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/declaration" -) - -// NodeOwn is what a node's own composition (`.node`) carries beside its resources: what the declaration says of -// the node as a whole (novox/hq ADR 0539 clause 2: adoption, accounts, identities and groups). A module's composition -// carries none of it. -type NodeOwn struct { - // Adoption says the node is adopted and which of its modules are taken (ADR 0100, ADR 0103); nil for a converged node. - Adoption *declaration.Adoption `json:"adoption,omitempty"` - // Identities is the identity of every module assigned to the node (ADR 0328 §4), Groups and Libraries every library - // it is reached by (ADR 0328 §3). - Identities []declaration.Identity `json:"identities,omitempty"` - Groups []declaration.Group `json:"groups,omitempty"` - Libraries []declaration.Library `json:"libraries,omitempty"` - // Provisioner is the account the provisioner's client runs as on the node (ADR 0349 §4). - Provisioner string `json:"provisioner_account,omitempty"` -} - -func (o NodeOwn) empty() bool { - return o.Adoption == nil && len(o.Identities) == 0 && len(o.Groups) == 0 && len(o.Libraries) == 0 && o.Provisioner == "" -} - -// Composed is a composition (ADR 0539 clause 1): one module's resources composed for one node at one fingerprint, or, -// with Module and FP empty, the node's own resources and parts (Own). Each resource is a declaration resource as the -// node-engine reads it, saying its module; it is kept as JSON and written canonically (its keys sorted, its spacing -// dropped), so its digest (ComposedDigestOf) is a function of its content alone. Data says the module declares data, -// so the node agent backs it up before it moves (ADR 0429 §14). Filled names the files of the module the provisioner -// fills (ADR 0349 §5). A file over 64 KiB travels by digest and size (ByDigest), its content in the node's object -// bucket (ADR 0539 clause 4). -type Composed struct { - V int - Node string - Module string - FP string - Data bool - Resources []json.RawMessage - Filled []string - Own NodeOwn - // Plan is the module's whole provisioning plan as the node-engine hands it to the provisioner (templates, files, - // own secrets by name, items, collections, provisions; never a value), composed node-wide (novox/hq ADR 0593 §6, - // §8): the provisioner makes every plan from it. Nil when the module hands the provisioner nothing; only on a - // module's key. It is part of the encoding (SameComposed sees a plan change) but not of the digest - // (ComposedDigestOf): a have, a want and the apply name a composition by its resources. - Plan *catalogue.Plan - // Refused is why the module cannot be composed at this fingerprint on this node, the rest of the node composed - // (ADR 0593 §6): a refused composition holds no resource, filled file, data or plan, and has no digest - // (ComposedDigestOf refuses it); only on a module's key. The node keeps running what it ran of the module. - Refused string -} - -// composedWire is a composition as it is written. -type composedWire struct { - V int `json:"v"` - Node string `json:"node"` - Module string `json:"module,omitempty"` - FP string `json:"fp,omitempty"` - Data bool `json:"data,omitempty"` - Resources []json.RawMessage `json:"resources"` - Filled []string `json:"filled_files,omitempty"` - Own *NodeOwn `json:"own,omitempty"` - Plan *catalogue.Plan `json:"plan,omitempty"` - Refused string `json:"refused,omitempty"` - // Signature is read and dropped: a composition is unsigned (ADR 0593 §6), and a value the ended controller signed - // still decodes. Nothing writes it. - Signature json.RawMessage `json:"signature,omitempty"` -} - -// canonicalJSON is raw with its object keys sorted and its spacing dropped, its numbers kept as written. -func canonicalJSON(raw json.RawMessage) (json.RawMessage, error) { - var v any - d := json.NewDecoder(bytes.NewReader(raw)) - d.UseNumber() - if err := d.Decode(&v); err != nil { - return nil, err - } - if _, err := d.Token(); err != io.EOF { - return nil, fmt.Errorf("more after the value") - } - return json.Marshal(v) -} - -func (c Composed) check() error { - if _, err := HaveNodeKey(c.Node); err != nil { - return err - } - if c.Module == "" { - if c.FP != "" { - return fmt.Errorf("delivery: the node's own composition names no fingerprint, and this one names %s", c.FP) - } - // It carries the filled paths of its own files, those of a module with no fingerprint of the new form moved - // into it (issue 1945), and never a module's data. - if c.Data { - return fmt.Errorf("delivery: the node's own composition holds no module's data") - } - } else { - if _, err := ComposedKey(c.Node, c.Module, c.FP); err != nil { - return err - } - if !c.Own.empty() { - return fmt.Errorf("delivery: the composition of %s carries the node's own parts, which only .node carries", c.Module) - } - } - if c.Module == "" && (c.Plan != nil || c.Refused != "") { - return fmt.Errorf("delivery: the node's own composition carries no plan and no refusal: only a module's does") - } - if c.Refused != "" && (len(c.Resources) > 0 || len(c.Filled) > 0 || c.Data || c.Plan != nil) { - return fmt.Errorf("delivery: the composition of %s on %s is refused and holds resources, filled files, data or a plan", c.Module, c.Node) - } - seen := map[string]bool{} - files := map[string][]*declaration.File{} - for _, raw := range c.Resources { - r, err := declaration.ParseResource(raw) - if err != nil { - return fmt.Errorf("delivery: the composition of %q on %s: %w", c.Module, c.Node, err) - } - if seen[r.Identity()] { - return fmt.Errorf("delivery: the composition of %q on %s holds the resource %q twice", c.Module, c.Node, r.Identity()) - } - seen[r.Identity()] = true - if r.DeclaredModule() != c.Module { - return fmt.Errorf("delivery: the resource %q is of %q, and this is the composition of %q on %s", - r.Identity(), r.DeclaredModule(), c.Module, c.Node) - } - if f, ok := r.(*declaration.File); ok { - if f.TravelsByDigest() { - return fmt.Errorf("delivery: the file %q is over %d bytes and travels by digest (ByDigest), not whole", - f.ID, declaration.FileReferenceThreshold) - } - files[f.Path] = append(files[f.Path], f) - } - } - // A file the provisioner fills says no content, so a composition that does not say it is filled would have the - // node-engine write it empty (issue 1945, live on 2026-10-25): refused, naming it. - filledHere := make(map[string]bool, len(c.Filled)) - for _, path := range c.Filled { - filledHere[path] = true - } - for path, fs := range files { - for _, f := range fs { - if f.OwnerAtRuntime == declaration.RuntimeOwned && !filledHere[path] && !saysContent(f) { - return fmt.Errorf("delivery: the file %q says no content and the composition of %q on %s does not say "+ - "the provisioner fills %s: the node-engine would write it empty", f.ID, c.Module, c.Node, path) - } - } - } - for _, path := range c.Filled { - if len(files[path]) == 0 { - return fmt.Errorf("delivery: the composition of %q fills %q, and declares no file there", c.Module, path) - } - for _, f := range files[path] { - if f.OwnerAtRuntime == "" { - return fmt.Errorf("delivery: the file %q is filled by the provisioner and says no owner-at-runtime", f.ID) - } - } - } - return nil -} - -// saysContent says a file names what to write: content, bytes, a digest or a sealed value. A file the provisioner fills -// names none of them. -func saysContent(f *declaration.File) bool { - return f.Content != "" || f.Bytes != "" || f.Digest != "" || f.Sealed != "" || f.VaultSealed != nil -} - -// wire is c checked and made canonical, ready to marshal. -func (c Composed) wire() (composedWire, error) { - if err := c.check(); err != nil { - return composedWire{}, err - } - w := composedWire{V: Version, Node: c.Node, Module: c.Module, FP: c.FP, Data: c.Data, Filled: c.Filled, - Plan: c.Plan, Refused: c.Refused, Resources: make([]json.RawMessage, len(c.Resources))} - for i, r := range c.Resources { - canon, err := canonicalJSON(r) - if err != nil { - return composedWire{}, fmt.Errorf("delivery: a resource of %q is not JSON: %w", c.Module, err) - } - w.Resources[i] = canon - } - if !c.Own.empty() { - own := c.Own - w.Own = &own - } - return w, nil -} - -// EncodeComposed is c as JSON at Version, each resource canonical. It refuses a composition whose node or fingerprint -// a key refuses, a resource its kind refuses (an action among them), a resource of another module, a module's -// composition carrying the node's own parts, a file over 64 KiB carried whole, and a filled path no file declares. -func EncodeComposed(c Composed) ([]byte, error) { - w, err := c.wire() - if err != nil { - return nil, err - } - return json.Marshal(w) -} - -// DecodeComposed reads a composition; it refuses an unknown version (ErrUnknownVersion), a field it does not know, and -// what EncodeComposed refuses. -func DecodeComposed(data []byte) (Composed, error) { - if err := checkVersion(data); err != nil { - return Composed{}, err - } - var w composedWire - d := json.NewDecoder(bytes.NewReader(data)) - d.DisallowUnknownFields() - if err := d.Decode(&w); err != nil { - return Composed{}, fmt.Errorf("delivery: a composition does not fit its shape: %w", err) - } - c := Composed{V: w.V, Node: w.Node, Module: w.Module, FP: w.FP, Data: w.Data, Resources: w.Resources, - Filled: w.Filled, Plan: w.Plan, Refused: w.Refused} - if w.Own != nil { - c.Own = *w.Own - } - if err := c.check(); err != nil { - return Composed{}, err - } - return c, nil -} - -// ComposedDigestOf is a composition's digest, "sha256:", of its encoding without its plan. A want names it (ADR 0539 -// clause 1), and the node's engine writes it beside the composition (ADR 0593 §6). -// -// A refused composition has no digest: it is refused, naming the module (ADR 0593 §6). -func ComposedDigestOf(c Composed) (string, error) { - if c.Refused != "" { - return "", fmt.Errorf("delivery: the composition of %s on %s is refused (%s) and has no digest", c.Module, c.Node, c.Refused) - } - c.Plan = nil - raw, err := EncodeComposed(c) - if err != nil { - return "", err - } - sum := sha256.Sum256(raw) - return "sha256:" + hex.EncodeToString(sum[:]), nil -} - -// SameComposed says whether two compositions are the same value, their plans included, so the engine writes only when -// they differ. -func SameComposed(a, b Composed) bool { - ra, errA := EncodeComposed(a) - rb, errB := EncodeComposed(b) - return errA == nil && errB == nil && bytes.Equal(ra, rb) -} - -// ByDigest is c with every file over 64 KiB given by its digest and size, and the content of each by digest, for the -// writer to put in the node's object bucket (declaration.FileBucketFor) under declaration.ObjectName before it writes -// the composition (ADR 0539 clause 4, ADR 0488 §3). A file that is sealed, filled with secrets or written into another -// stays as it is (declaration.File.TravelsByDigest). -func (c Composed) ByDigest() (Composed, map[string][]byte, error) { - out := c - out.Resources = make([]json.RawMessage, len(c.Resources)) - objects := map[string][]byte{} - for i, raw := range c.Resources { - out.Resources[i] = raw - r, err := declaration.ParseResource(raw) - if err != nil { - return Composed{}, nil, err - } - f, ok := r.(*declaration.File) - if !ok || !f.TravelsByDigest() { - continue - } - ref, body, err := f.AsReference() - if err != nil { - return Composed{}, nil, err - } - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err != nil { - return Composed{}, nil, err - } - delete(fields, "content") - delete(fields, "bytes") - fields["digest"], _ = json.Marshal(ref.Digest) - fields["size"], _ = json.Marshal(ref.Size) - rewritten, err := json.Marshal(fields) - if err != nil { - return Composed{}, nil, err - } - out.Resources[i] = rewritten - objects[ref.Digest] = body - } - return out, objects, nil -} - -// SplitComposed splits a node's composed resources into its compositions, by key (ComposedKey, ComposedNodeKey): each -// resource into the composition of the module it says, under that module's fingerprint in fps, and a resource that -// says no module into the node's own, with own. Each filled path goes with the module whose file declares it. The order -// of the resources is kept within each composition. A resource of a module fps names no fingerprint for is refused, -// naming it. Data is the caller's to set, from the module's manifest. -func SplitComposed(node string, resources []json.RawMessage, fps map[string]string, filled []string, own NodeOwn) (map[string]Composed, error) { - nodeKey, err := ComposedNodeKey(node) - if err != nil { - return nil, err - } - out := map[string]Composed{nodeKey: {Node: node, Own: own, Resources: []json.RawMessage{}}} - pathOf := map[string]string{} - for _, raw := range resources { - var head struct { - ID string `json:"id"` - Type string `json:"type"` - Module string `json:"module"` - Path string `json:"path"` - } - if err := json.Unmarshal(raw, &head); err != nil { - return nil, fmt.Errorf("delivery: a resource of %s is not JSON: %w", node, err) - } - key := nodeKey - if head.Module != "" { - fp, ok := fps[head.Module] - if !ok { - return nil, fmt.Errorf("delivery: the resource %q is of %q, whose fingerprint on %s is not given", head.ID, head.Module, node) - } - if key, err = ComposedKey(node, head.Module, fp); err != nil { - return nil, err - } - if _, ok := out[key]; !ok { - out[key] = Composed{Node: node, Module: head.Module, FP: fp} - } - } - if head.Type == string(declaration.TypeFile) && head.Module != "" { - pathOf[head.Path] = key - } - c := out[key] - c.Resources = append(c.Resources, raw) - out[key] = c - } - for _, path := range filled { - key, ok := pathOf[path] - if !ok { - return nil, fmt.Errorf("delivery: the provisioner fills %q on %s, and no module's file declares it", path, node) - } - c := out[key] - c.Filled = append(c.Filled, path) - out[key] = c - } - return out, nil -} diff --git a/go/delivery/composed_value_test.go b/go/delivery/composed_value_test.go deleted file mode 100644 index f7085cd..0000000 --- a/go/delivery/composed_value_test.go +++ /dev/null @@ -1,364 +0,0 @@ -package delivery - -import ( - "bytes" - "compress/gzip" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "sort" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/declaration" -) - -// capturedDeclaration is a node's declaration as the controller's `plan` verb answered it on 2026-10-24 at 16:19 -// (testdata/declaration-.json.gz; the resources carrying a sealed value or a secret taken out, and the filled -// files that named them), never typed by hand. -type capturedDeclaration struct { - Builds map[string]string `json:"builds"` - Resources []json.RawMessage `json:"resources"` - Filled []string `json:"filled_files"` - Provisioner string `json:"provisioner_account"` -} - -func readCaptured(t *testing.T, node string) capturedDeclaration { - t.Helper() - f, err := os.Open("testdata/declaration-" + node + ".json.gz") - if err != nil { - t.Fatal(err) - } - defer f.Close() - z, err := gzip.NewReader(f) - if err != nil { - t.Fatal(err) - } - raw, err := io.ReadAll(z) - if err != nil { - t.Fatal(err) - } - var c capturedDeclaration - if err := json.Unmarshal(raw, &c); err != nil { - t.Fatal(err) - } - return c -} - -// fpOfBuild stands for each module's fingerprint in a captured declaration, which names builds, not fingerprints. -func fpOfBuild(build string) string { - var b strings.Builder - for b.Len() < 64 { - b.WriteString(fmt.Sprintf("%x", []byte(build))) - } - return SourcePrefix2 + b.String()[:64] -} - -func splitCaptured(t *testing.T, node string, c capturedDeclaration) map[string]Composed { - t.Helper() - fps := map[string]string{} - for m, b := range c.Builds { - fps[m] = fpOfBuild(b) - } - // mesh-wireguard's resources are composed with no build of it named; its fingerprint is the indexer's, as every - // module's is, and stood in for here by its name. - for _, r := range c.Resources { - var head struct { - Module string `json:"module"` - } - _ = json.Unmarshal(r, &head) - if _, ok := fps[head.Module]; head.Module != "" && !ok { - fps[head.Module] = fpOfBuild(head.Module) - } - } - split, err := SplitComposed(node, c.Resources, fps, c.Filled, NodeOwn{Provisioner: c.Provisioner}) - if err != nil { - t.Fatalf("SplitComposed(%s): %v", node, err) - } - return split -} - -func canonical(t *testing.T, raw json.RawMessage) string { - t.Helper() - var v any - d := json.NewDecoder(bytes.NewReader(raw)) - d.UseNumber() - if err := d.Decode(&v); err != nil { - t.Fatal(err) - } - out, _ := json.Marshal(v) - return string(out) -} - -// 1943/AC2: each module's slice of a captured declaration of each node, and the node's own resources, round-trip -// through the composition's encoding with the same digest; together they are the declaration's resources, each once, -// each in the composition of the module it says. -func TestCompositionsOfACapturedDeclaration(t *testing.T) { - t.Parallel() - for _, node := range []string{"ace", "g14"} { - t.Run(node, func(t *testing.T) { - t.Parallel() - oneCapturedDeclaration(t, node) - }) - } -} - -func oneCapturedDeclaration(t *testing.T, node string) { - { - c := readCaptured(t, node) - split := splitCaptured(t, node, c) - modules := map[string]bool{} - for m := range c.Builds { - modules[m] = true - } - var union []string - for key, comp := range split { - n, module, fp, isNode, err := ParseComposedKey(key) - if err != nil || n != node || comp.Node != node || comp.Module != module || comp.FP != fp { - t.Fatalf("%s: key %q holds %s/%s/%s (%v)", node, key, comp.Node, comp.Module, comp.FP, err) - } - if !isNode && !modules[module] && module != "mesh-wireguard" { - t.Errorf("%s: a composition of %s, which the declaration names no build of", node, module) - } - raw, err := EncodeComposed(comp) - if err != nil { - t.Fatalf("%s: EncodeComposed(%s): %v", node, key, err) - } - back, err := DecodeComposed(raw) - if err != nil { - t.Fatalf("%s: DecodeComposed(%s): %v", node, key, err) - } - if !SameComposed(back, comp) { - t.Errorf("%s: %s did not round-trip", node, key) - } - // SameComposed encodes both, so the digest of what was read back, a hash of its encoding, is the one written. - if d, err := ComposedDigestOf(back); err != nil || !digestForm.MatchString(d) { - t.Errorf("%s: %s digests %q (%v)", node, key, d, err) - } - for _, r := range back.Resources { - var head struct { - Module string `json:"module"` - } - _ = json.Unmarshal(r, &head) - if head.Module != module { - t.Errorf("%s: %s holds a resource of %q", node, key, head.Module) - } - union = append(union, canonical(t, r)) - } - } - var want []string - for _, r := range c.Resources { - want = append(want, canonical(t, r)) - } - sort.Strings(union) - sort.Strings(want) - if strings.Join(union, "\n") != strings.Join(want, "\n") { - t.Errorf("%s: the compositions hold %d resources, the declaration %d, or they differ", node, len(union), len(want)) - } - } -} - -// 1943/AC2: one changed byte of a resource is another digest; the same composition written with its keys in another -// order is the same digest. -func TestComposedDigestFollowsTheContent(t *testing.T) { - c := readCaptured(t, "g14") - split := splitCaptured(t, "g14", c) - var comp Composed - for _, v := range split { - if v.Module != "" && len(v.Resources) > 1 { - comp = v - break - } - } - d1, _ := ComposedDigestOf(comp) - var m map[string]json.RawMessage - _ = json.Unmarshal(comp.Resources[0], &m) - var keys []string - for k := range m { - keys = append(keys, k) - } - sort.Sort(sort.Reverse(sort.StringSlice(keys))) - var parts []string - for _, k := range keys { - parts = append(parts, fmt.Sprintf("%q : %s", k, m[k])) - } - reordered := json.RawMessage("{ " + strings.Join(parts, ",\n ") + " }") - shuffled := comp - shuffled.Resources = append([]json.RawMessage{reordered}, comp.Resources[1:]...) - if d2, _ := ComposedDigestOf(shuffled); d2 != d1 { - t.Errorf("the same resources in another order and spacing digest %s and %s", d1, d2) - } - changed := comp - changed.Resources = append([]json.RawMessage{json.RawMessage(strings.Replace(canonical(t, comp.Resources[0]), `"id":"`, `"id":"x`, 1))}, comp.Resources[1:]...) - if d3, _ := ComposedDigestOf(changed); d3 == d1 { - t.Errorf("a changed resource kept its digest") - } - revised := comp - revised.Data = !comp.Data - if d4, _ := ComposedDigestOf(revised); d4 == d1 { - t.Errorf("a changed data flag kept its digest") - } -} - -// 1943/AC2: a file over 64 KiB is carried by digest and size, its content handed back to be put in the node's object -// bucket; a composition that carries such a file whole is refused. -func TestAComposedFileOver64KiBIsCarriedByDigest(t *testing.T) { - c := readCaptured(t, "g14") - split := splitCaptured(t, "g14", c) - var comp Composed - var at int - for _, v := range split { - for i, r := range v.Resources { - var f map[string]any - _ = json.Unmarshal(r, &f) - if f["type"] == "file" && v.Module != "" && f["bytes"] == nil && f["into"] == nil { - comp, at = v, i - break - } - } - if comp.Module != "" { - break - } - } - var f map[string]any - _ = json.Unmarshal(comp.Resources[at], &f) - // Grown past the threshold from a captured file: no captured file of this node is over 64 KiB. - body := strings.Repeat("x", declaration.FileReferenceThreshold+1) - f["content"] = body - big, _ := json.Marshal(f) - comp.Resources = append([]json.RawMessage{}, comp.Resources...) - comp.Resources[at] = big - if _, err := EncodeComposed(comp); err == nil || !strings.Contains(err.Error(), "by digest") { - t.Fatalf("a file over 64 KiB carried whole = %v, want refused", err) - } - byDigest, objects, err := comp.ByDigest() - if err != nil { - t.Fatal(err) - } - var ref map[string]any - _ = json.Unmarshal(byDigest.Resources[at], &ref) - digest := declaration.DigestOf([]byte(body)) - if ref["digest"] != digest || ref["size"] != float64(len(body)) || ref["content"] != nil { - t.Errorf("the file became %v, want digest %s and size %d", ref, digest, len(body)) - } - if string(objects[digest]) != body || len(objects) != 1 { - t.Errorf("the objects handed back are %d, want the file's content under its digest", len(objects)) - } - if _, err := EncodeComposed(byDigest); err != nil { - t.Errorf("the composition by digest is refused: %v", err) - } -} - -// 1943/AC2: an unknown version is refused as one; a module's composition carrying the node's own parts, a resource of -// another module, a resource its kind refuses, or no fingerprint, is refused, naming why. -func TestComposedRefusals(t *testing.T) { - fp := fp2of('a') - file := json.RawMessage(`{"id":"m.f","type":"file","module":"m","path":"/etc/m","content":"x"}`) - ok := Composed{Node: "ace", Module: "m", FP: fp, Resources: []json.RawMessage{file}} - raw, err := EncodeComposed(ok) - if err != nil { - t.Fatal(err) - } - if _, err := DecodeComposed(bytes.Replace(raw, []byte(`"v":1`), []byte(`"v":2`), 1)); !errors.Is(err, ErrUnknownVersion) { - t.Errorf("version 2 = %v, want ErrUnknownVersion", err) - } - refusals := []struct { - c Composed - why string - }{ - {Composed{Node: "ace", Module: "m", Resources: []json.RawMessage{file}}, "fingerprint"}, - {Composed{Node: "", Module: "m", FP: fp, Resources: []json.RawMessage{file}}, "node name is empty"}, - {Composed{Node: "ace", Module: "other", FP: fp, Resources: []json.RawMessage{file}}, `of "m"`}, - {Composed{Node: "ace", Module: "m", FP: fp, Resources: []json.RawMessage{file}, Own: NodeOwn{Provisioner: "p"}}, "node's own"}, - {Composed{Node: "ace", FP: fp, Resources: []json.RawMessage{}}, "names no fingerprint"}, - {Composed{Node: "ace", Resources: []json.RawMessage{file}}, `of "m"`}, - {Composed{Node: "ace", Module: "m", FP: fp, Resources: []json.RawMessage{json.RawMessage(`{"id":"m.f","type":"file","module":"m","path":"/etc/m","content":"x","image":"y"}`)}}, `"image"`}, - {Composed{Node: "ace", Module: "m", FP: fp, Resources: []json.RawMessage{json.RawMessage(`{"id":"m.a","type":"action","module":"m"}`)}}, "action"}, - } - for _, r := range refusals { - if _, err := EncodeComposed(r.c); err == nil || !strings.Contains(err.Error(), r.why) { - t.Errorf("EncodeComposed(%s/%s) = %v, want a refusal saying %q", r.c.Node, r.c.Module, err, r.why) - } - } -} - -// 1943/AC2 (issue 1150): a module whose name holds a dot keys its have, its want and its composition escaped, and the -// parsers give its name back; a name with a wildcard, whitespace or an escape of its own is still refused. -func TestADottedModuleKeysEscaped(t *testing.T) { - fp := fp2of('f') - have, err := HaveKey("ace", "novox.be") - if err != nil || have != "ace.novox=2ebe" { - t.Fatalf("HaveKey = %q, %v", have, err) - } - if n, m, isNode, err := ParseHaveKey(have); err != nil || n != "ace" || m != "novox.be" || isNode { - t.Errorf("ParseHaveKey(%q) = %q, %q, %v, %v", have, n, m, isNode, err) - } - want, err := WantKey("ace", "novox.be") - if err != nil || want != "want.ace.novox=2ebe" { - t.Fatalf("WantKey = %q, %v", want, err) - } - if n, m, err := ParseWantKey(want); err != nil || n != "ace" || m != "novox.be" { - t.Errorf("ParseWantKey(%q) = %q, %q, %v", want, n, m, err) - } - comp, err := ComposedKey("ace", "novox.be", fp) - if err != nil || comp != "ace.novox=2ebe."+strings.Repeat("f", 64) { - t.Fatalf("ComposedKey = %q, %v", comp, err) - } - if n, m, back, _, err := ParseComposedKey(comp); err != nil || n != "ace" || m != "novox.be" || back != fp { - t.Errorf("ParseComposedKey(%q) = %q, %q, %q, %v", comp, n, m, back, err) - } - for _, bad := range []string{"m*", "m x", "m=2e", "m>", ".m", "m.", "a..b"} { - if _, err := HaveKey("ace", bad); err == nil { - t.Errorf("HaveKey(ace, %q) accepted", bad) - } - } - for _, bad := range []string{"ace.m=2", "ace.m=2E", "ace.m=41", "ace.=6eode"} { - if _, _, _, err := ParseHaveKey(bad); err == nil { - t.Errorf("ParseHaveKey(%q) accepted", bad) - } - } -} - -// The node's own composition carries the filled paths of the files moved into it, the files of a module with no -// fingerprint of the new form: the provisioner fills them all the same (issue 1945, live on 2026-10-25: the paths were -// dropped and the node-engine wrote those files empty). It still holds no module's data. -func TestTheNodesOwnCompositionCarriesTheFilledPathsOfItsFiles(t *testing.T) { - filled := json.RawMessage(`{"id":"power.unit","type":"file","path":"/etc/systemd/system/p.service","mode":"0644","owner-at-runtime":"owned"}`) - own := Composed{Node: "ace", Resources: []json.RawMessage{filled}, Filled: []string{"/etc/systemd/system/p.service"}, - Own: NodeOwn{Provisioner: "mesh-provisioner-client"}} - raw, err := EncodeComposed(own) - if err != nil { - t.Fatalf("the node's own composition with a filled file: %v", err) - } - back, err := DecodeComposed(raw) - if err != nil || len(back.Filled) != 1 { - t.Fatalf("read back: %v %v", back.Filled, err) - } - if _, err := EncodeComposed(Composed{Node: "ace", Data: true, Resources: []json.RawMessage{filled}, - Filled: own.Filled}); err == nil { - t.Error("the node's own composition was let hold a module's data") - } -} - -// A file the provisioner fills says no content: in a composition that does not say it is filled, the node-engine would -// write it empty. Such a composition is refused, in the node's own as in a module's (issue 1945). -func TestAFileWithNoContentThatIsNotFilledIsRefused(t *testing.T) { - fp := fp2of('a') - for _, c := range []Composed{ - {Node: "ace", Resources: []json.RawMessage{json.RawMessage( - `{"id":"power.unit","type":"file","path":"/etc/p","mode":"0644","owner-at-runtime":"owned"}`)}}, - {Node: "ace", Module: "m", FP: fp, Resources: []json.RawMessage{json.RawMessage( - `{"id":"m.f","type":"file","module":"m","path":"/etc/m","mode":"0644","owner-at-runtime":"owned"}`)}}, - } { - if _, err := EncodeComposed(c); err == nil || !strings.Contains(err.Error(), "empty") { - t.Errorf("%s/%s: %v, want a refusal saying it would be written empty", c.Node, c.Module, err) - } - } - // A file that says its content, even an empty one, is written as it says. - if _, err := EncodeComposed(Composed{Node: "ace", Resources: []json.RawMessage{json.RawMessage( - `{"id":"x.f","type":"file","path":"/etc/x","mode":"0644","content":""}`)}}); err != nil { - t.Errorf("a file that says it is empty: %v", err) - } -} diff --git a/go/delivery/control_records.go b/go/delivery/control_records.go deleted file mode 100644 index a0112d8..0000000 --- a/go/delivery/control_records.go +++ /dev/null @@ -1,132 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "strings" - "time" -) - -// **The control records' layouts, out of the controller** (novox/hq ADR 0565 clauses 2, 3 and 6; issue 1953 L5-S2): what -// the indexer keeps of the catalogue's predefined list and of each open pull request's config declarations, which -// mesh-config reads in place of the controller's mirrors and `pull-declares`; and each node's declared packages, which -// its engine writes in place of the controller's mirror. - -// The indexer's two states (clause 2). -const ( - // IndexerPredefinedState holds one key, PredefinedListKey: the catalogue's predefined.json at the newest merge taken. - IndexerPredefinedState = "predefined" - PredefinedListKey = "list" - // IndexerConfigCandidatesState holds one key per open pull request (ConfigCandidateKey). - IndexerConfigCandidatesState = "config-candidates" -) - -// PredefinedKept is the indexer's key `predefined.list`: the list whole, as predefined.json holds it, with the merge it -// was read at. A merge older than the one the key names never replaces it (ADR 0348 §7). -type PredefinedKept struct { - V int `json:"v"` - Commit string `json:"commit"` - At time.Time `json:"at"` - List json.RawMessage `json:"list"` -} - -// ConfigDeclaration is one config key a module's manifest declares, in the layout of the controller's declared-config -// mirror (mesh-controller internal/inventory DeclaredMirrored) unchanged, so mesh-config reads the indexer's as it read -// the mirror's. -type ConfigDeclaration struct { - Kind string `json:"kind"` - Default json.RawMessage `json:"default"` - Why string `json:"why"` - Type string `json:"type,omitempty"` - Description string `json:"description,omitempty"` - Enum json.RawMessage `json:"enum,omitempty"` - Minimum *float64 `json:"minimum,omitempty"` - Maximum *float64 `json:"maximum,omitempty"` - Pattern string `json:"pattern,omitempty"` - Trusted bool `json:"trusted,omitempty"` - Predefined string `json:"predefined,omitempty"` - Required bool `json:"required,omitempty"` - Runtime bool `json:"runtime,omitempty"` -} - -// ConfigCandidate is one open pull request's key of `config-candidates`: its head, and per module the config -// declarations the head's manifests add or change against the trunk's, an empty set when none. Written at each new -// head and each trunk merge that changes a module it names; deleted when the pull request merges or closes. -type ConfigCandidate struct { - V int `json:"v"` - Pull string `json:"pull"` - Head string `json:"head"` - Declares map[string]map[string]ConfigDeclaration `json:"declares"` - At time.Time `json:"at"` -} - -// ConfigCandidateKey is a pull request's key, `owner/repository#number` with `/` and `#` as dots, the form mesh-config -// keeps a pull request's values under. -func ConfigCandidateKey(pull string) (string, error) { - owner, rest, ok := strings.Cut(strings.TrimSpace(pull), "/") - repo, number, ok2 := strings.Cut(rest, "#") - if !ok || !ok2 || owner == "" || repo == "" || number == "" || strings.ContainsAny(owner+repo+number, ".*> ") { - return "", fmt.Errorf("delivery: %q is not a pull request, owner/repository#number", pull) - } - return owner + "." + repo + "." + number, nil -} - -// The node-engine's packages (clause 6): its state `packages`, one key, the node's name, written by that node's engine -// alone. -const EnginePackagesState = "packages" - -// PackageDeclared is one package a node's modules declare, the controller's declared-packages mirror's entry unchanged -// (mesh-controller internal/inventory PackageDeclared): its name, the modules that declare it that way, sorted, and -// whether they declare it absent. -type PackageDeclared struct { - Package string `json:"package"` - Modules []string `json:"modules"` - Absent bool `json:"absent"` -} - -// DeclaredPackages is one node's key of `packages`, the mirror's value unchanged: its packages sorted by name, absent -// after present. Fixed fields and no time, so the same declaration is the same bytes (ADR 0289 §1). -type DeclaredPackages struct { - Node string `json:"node"` - Packages []PackageDeclared `json:"packages"` -} - -// mesh-config's state of the approved values held for a pull request (novox/hq ADR 0565 clause 5): read by the checker -// and the assigner's composition check, which lay a pull request's own held values over its modules' values. -const ( - MeshConfigKeptState = "kept" - MeshConfigKeptRead = "mesh-config." + MeshConfigKeptState -) - -// KeptValue is one approved held value, mesh-config's layout unchanged (modules/mesh-config pulls.go): kept apart from -// the values mesh-config resolves, laid only over the composition of its own pull request at its head. -type KeptValue struct { - Pull string `json:"pull"` - Head string `json:"head"` - Digest string `json:"declaration"` - Node string `json:"node"` - Module string `json:"module"` - Key string `json:"key"` - // Value is the value held, as the operator approved it. - Value json.RawMessage `json:"value"` - By string `json:"by"` - Via string `json:"via"` - Why string `json:"why"` - // Approved is who approved it, as the proposal kept it. - Approved string `json:"approved"` - Proposal string `json:"proposal"` - At time.Time `json:"at"` -} - -// HeldValueKey is a held value's key in `kept`: the pull request's key (ConfigCandidateKey's form), the node, the module and -// the config key, joined by dots, as mesh-config writes it. -func HeldValueKey(pull, node, module, key string) (string, error) { - p, err := ConfigCandidateKey(pull) - if err != nil { - return "", err - } - return p + "." + node + "." + module + "." + key, nil -} - -// The assigner's state of a person's upgrade policy, one key per module (clause 7; catalogue.PersonPolicy). -const AssignerPoliciesState = "policies" diff --git a/go/delivery/control_records_test.go b/go/delivery/control_records_test.go deleted file mode 100644 index c98f15f..0000000 --- a/go/delivery/control_records_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package delivery - -import ( - "encoding/json" - "strings" - "testing" - "time" -) - -// ADR 0565 clauses 2 and 6 (issue 1953 L5-S2): the control records' layouts read what their writers write. The -// packages value is the controller's declared-packages mirror's, as its writer's own test pins it (mesh-controller -// internal/inventory/declared_packages_test.go at 0fbb5ef3); the config declaration is its declared-config mirror's -// (DeclaredMirrored); a pull request's key is mesh-config's form. -func TestTheControlRecordsReadTheirWritersValues(t *testing.T) { - for _, c := range []struct { - name string - raw string - into any - }{ - {"declared packages", `{"node":"one","packages":[{"package":"curl","modules":["backup","web"],"absent":false},` + - `{"package":"nano","modules":["web"],"absent":true}]}`, &DeclaredPackages{}}, - {"a config candidate", `{"v":1,"pull":"novox/mesh-catalog#42","head":"abc","declares":{"mail":{"quota":{"kind":"config",` + - `"default":10,"why":"a quota","type":"integer","minimum":1}}},"at":"2026-10-29T12:00:00Z"}`, &ConfigCandidate{}}, - {"a held value", `{"pull":"novox/mesh-catalog#42","head":"abc","declaration":"sha256:d","node":"novox","module":"mail",` + - `"key":"quota","value":20,"by":"person.jochen","via":"telegram","why":"more room","approved":"the operator",` + - `"proposal":"p-1","at":"2026-10-29T12:00:00Z"}`, &KeptValue{}}, - // A node's health report as mesh-host 65fbc1e6 encodes it (its test's look, struct order kept). - {"a node's health report", `{"v":1,"node":"anchor","network":{"state":"unhealthy","since":"2026-10-29T20:00:00Z",` + - `"parts":[{"part":"resolv-conf","state":"unhealthy","writer":"NetworkManager","owner":"dnsmasq","since":"2026-10-29T20:00:00Z"}]},` + - `"peers":[{"key":"A=","addresses":["10.10.0.2/32"],"seen":false},{"key":"B=","seen":true}],` + - `"units":{"state":"degraded","failed":[{"unit":"cups.service","scope":"system","since":"2026-10-29T20:00:00Z"}]},` + - `"relogin":[{"account":"jochen","module":"brother","resource":"brother.scanner","reason":"relogin needed: the session began before scanner","since":"2026-10-29T20:00:00Z"}],` + - `"found":[{"module":"photos","resource":"photos.library","path":"/srv/photos","reason":"used as found: owned by root","since":"2026-10-29T20:00:00Z"}],` + - `"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}}, - // The engine's verdict on an account declared never to become root (ADR 0266), and its contract. - {"a node's health report, the agent account judged", `{"v":1,"node":"anchor","root":[{"account":"agent",` + - `"module":"claude-code","resource":"claude-code.agent","state":"healthy","since":"2026-10-29T20:00:00Z"}],` + - `"contract":13,"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}}, - // A part pointing at another machine names it (issue 774): the hub's tunnel, held at the hub's machine. - {"a node's health report, a part pointing at the hub", `{"v":1,"node":"anchor","network":{"state":"unhealthy",` + - `"since":"2026-10-29T20:00:00Z","parts":[{"part":"tunnel","state":"unhealthy","reason":"the tunnel to the hub has not handshaken for over five minutes",` + - `"toward":["hub"],"targets":["novox"],"since":"2026-10-29T20:00:00Z"}]},"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}}, - // A node fallen back to its old login (ADR 0583 §6), which the watchman raises as node-account-fallback. - {"a node's health report, fallen back to its old login", `{"v":1,"node":"anchor","fallback":{"node":"anchor",` + - `"from":"node.anchor--b","why":"the new login's test-dial was refused","at":"2026-11-02T01:00:00Z"},` + - `"at":"2026-11-02T01:00:00Z"}`, &NodeHealth{}}, - {"the predefined list", `{"v":1,"commit":"abc","at":"2026-10-29T12:00:00Z","list":{"domain":{"type":"string"}}}`, &PredefinedKept{}}, - } { - if err := json.Unmarshal([]byte(c.raw), c.into); err != nil { - t.Fatalf("%s: %v", c.name, err) - } - again, _ := json.Marshal(c.into) - var want, got any - _ = json.Unmarshal([]byte(c.raw), &want) - _ = json.Unmarshal(again, &got) - if string(jsonOf(want)) != string(jsonOf(got)) || ((c.name == "declared packages" || strings.HasPrefix(c.name, "a node's health report")) && string(again) != c.raw) { - t.Errorf("%s reads back as %s", c.name, again) - } - } - if a, b := (NodeHealth{Node: "anchor", At: time.Now()}), (NodeHealth{Node: "anchor"}); !SameNodeHealth(a, b) || - SameNodeHealth(a, NodeHealth{Node: "anchor", Peers: []PeerSeen{{Key: "A="}}}) { - t.Error("two reports differing only in when they were written are not the same, or differing ones are") - } - if _, err := EncodeNodeHealth(NodeHealth{}); err == nil { - t.Error("a report naming no node was encoded") - } - if _, err := EncodeNodeHealth(NodeHealth{Node: "anchor", Fallback: &LoginFallback{Node: "ace", From: "node.anchor--b", - Why: "refused", At: time.Now()}}); err == nil { - t.Error("a fall back naming another node was encoded") - } - // ADR 0571 clause 4: who holds which seat, from placement; a node seat by its node. - held := SeatHoldersOf(map[string]Placement{ - "ace": {Node: "ace", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "node-files", Scope: "node", Module: "mesh-files"}}}, - "novox": {Node: "novox", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "mesh-broker", Scope: "mesh", Module: "nats"}, - {Claim: "photo-gallery", Scope: "mesh", Module: "photos"}}}, - }) - if o := held["photo-gallery"]; !o.Outside || len(o.Holders) != 1 || held["mesh-broker"].Outside { - t.Errorf("a claim outside the set is %+v", o) - } - keys := map[string][]byte{} - for k, v := range held { - keys[k], _ = json.Marshal(v) - } - if h, err := HoldersOf(keys, "mesh-dns-resolver", ""); err != nil || len(h) != 2 || h[0].Node != "ace" { - t.Errorf("a replicated seat's holders are %v, %v", h, err) - } - if h, _ := HoldersOf(keys, "node-files", "ace"); len(h) != 1 || h[0].Module != "mesh-files" || len(held) != 4 { - t.Errorf("a node seat's holder is %v, keys %v", h, held) - } - // ADR 0571 clauses 4, 5: the modules and the machines, from the indexer's and the placement seat's keys. - if m, err := ModuleNames(map[string][]byte{"letta": nil, "gitea": nil}); err != nil || strings.Join(m, ",") != "gitea,letta" { - t.Errorf("the modules are %v, %v", m, err) - } - if _, err := ModuleNames(map[string][]byte{"a.b": nil}); err == nil { - t.Error("a key that is no module's was read as one") - } - if n := MachineNames(map[string][]byte{"placement.novox": nil, "placement.ace": nil, "ace.node": nil, "ace.app": nil, "g14.app": nil, "proposal.p1": nil}); strings.Join(n, ",") != "ace,novox" { - t.Errorf("the machines are %v", n) - } - if h, _ := HoldersOf(keys, "node-files", "novox"); h != nil { - t.Errorf("a node seat nobody holds there is held by %v", h) - } - // ADR 0571 clause 1: the builder's log, a start and an end, and the duration between them. - at := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC) - start := BuildLogEntry{Event: BuildStartedEvent, Module: "letta", FP: "src2:a", Source: "abc", Repository: "novox/mesh-catalog", - Directory: "modules/letta", Commit: "abc123", Node: "ace", At: at} - end := start - end.Event, end.At, end.Outcome, end.Manifest, end.Against = BuildEndedEvent, at.Add(3*time.Minute), "done", "sha256:m", []string{"artifact-store://base/image@sha256:b"} - end.Made = []BuiltArtifact{{Name: "image", Kind: "image", Digest: "sha256:1", Reference: "artifact-store://letta/image@sha256:1"}} - for _, e := range []BuildLogEntry{start, end} { - raw, err := EncodeBuildLogEntry(e) - back, derr := DecodeBuildLogEntry(raw) - if err != nil || derr != nil || back.Event != e.Event || back.Outcome != e.Outcome || back.Directory != "modules/letta" || - back.Repository != "novox/mesh-catalog" || back.Commit != "abc123" { - t.Errorf("%s read back as %+v, %v %v", e.Event, back, err, derr) - } - } - if d, ok := BuildDuration(start, end); !ok || d != 3*time.Minute { - t.Errorf("the build took %s, %v", d, ok) - } - older := end - older.At, older.FP = at.Add(-time.Hour), "src2:b" - other := end - other.Module = "mesh-catalog" - if n := NewestPerModule([]BuildLogEntry{older, end, other}); n["letta"].FP != "src2:a" || len(n) != 2 { - t.Errorf("each module's newest is %v", n) - } - if e := EndedSince([]BuildLogEntry{end, start, older}, at.Add(-2*time.Hour)); len(e) != 2 || e[0].FP != "src2:b" { - t.Errorf("the ended builds since are %v", e) - } - if k, err := BuildLogKey("letta"); k != "letta" || err != nil || BuildLogRead != "mesh-delivery-builder.build-log" { - t.Errorf("the key is %q, %v", k, err) - } - if _, err := BuildLogKey("a.b"); err == nil { - t.Error("a module with a dot was taken as a key") - } - for _, edit := range []func(*BuildLogEntry){func(e *BuildLogEntry) { e.Outcome = "" }, func(e *BuildLogEntry) { e.Commit = "" }, - func(e *BuildLogEntry) { e.Event, e.Outcome, e.Against = BuildStartedEvent, "", nil }} { - bad := end - edit(&bad) - if _, err := EncodeBuildLogEntry(bad); err == nil { - t.Errorf("%+v was encoded", bad) - } - } - if k, err := HeldValueKey("novox/mesh-catalog#42", "novox", "mail", "quota"); k != "novox.mesh-catalog.42.novox.mail.quota" || err != nil { - t.Errorf("a held value's key is %q, %v", k, err) - } - for pull, want := range map[string]string{"novox/mesh-catalog#42": "novox.mesh-catalog.42", "x#1": "", "a/b": "", "a/b.c#1": ""} { - if got, err := ConfigCandidateKey(pull); got != want || (want == "") != (err != nil) { - t.Errorf("%s keyed %q, %v", pull, got, err) - } - } - // ADR 0565 §6: the engine works out its node's key of `packages` from the compositions it composed, the mirror's bytes - // for the same resources (mesh-controller internal/inventory/declared_packages_test.go aNodesPackages at 0fbb5ef3): - // a module's own composition, the node's own with a folded module's resource named by its id alone, and a file. - res := func(s ...string) []json.RawMessage { - var out []json.RawMessage - for _, r := range s { - out = append(out, json.RawMessage(r)) - } - return out - } - comps := []Composed{ - {Node: "one", Module: "web", Resources: res(`{"id":"pkg-curl","type":"package","package":"curl"}`, - `{"id":"pkg-nano","type":"package","package":"nano","absent":true}`, `{"id":"conf","type":"file","path":"/etc/web.conf"}`)}, - {Node: "one", Resources: res(`{"id":"backup.pkg-curl","type":"package","package":"curl"}`)}, - } - want := `{"node":"one","packages":[{"package":"curl","modules":["backup","web"],"absent":false},` + - `{"package":"nano","modules":["web"],"absent":true}]}` - got, err := DeclaredPackagesOf("one", comps) - if raw, _ := json.Marshal(got); err != nil || string(raw) != want { - t.Errorf("the node's packages are %s (%v)", raw, err) - } - if none, _ := DeclaredPackagesOf("one", nil); string(jsonOf(none)) != `{"node":"one","packages":[]}` || EnginePackagesRead != "mesh-host.packages" { - t.Errorf("a node declaring no package is %s", jsonOf(none)) - } - // Their difference with the mirror: a package only one side holds, and one whose modules differ; none when they agree. - mirror := DeclaredPackages{Node: "one", Packages: []PackageDeclared{{Package: "curl", Modules: []string{"web"}}, - {Package: "nano", Modules: []string{"web"}, Absent: true}, {Package: "vim", Modules: []string{"edit"}}}} - if d := DiffDeclaredPackages(got, mirror); len(d) != 2 || d[0].Package != "curl" || strings.Join(d[0].Engine, ",") != "backup,web" || - d[1].Package != "vim" || len(d[1].Engine) != 0 || d[1].Mirror[0] != "edit" { - t.Errorf("the difference is %+v", d) - } - if d := DiffDeclaredPackages(got, got); d == nil || len(d) != 0 { - t.Errorf("one declaration differs from itself: %+v", d) - } -} - -func jsonOf(v any) []byte { - b, _ := json.Marshal(v) - return b -} diff --git a/go/delivery/delivery.go b/go/delivery/delivery.go deleted file mode 100644 index 62af7fc..0000000 --- a/go/delivery/delivery.go +++ /dev/null @@ -1,677 +0,0 @@ -// Package delivery is the one home of the keys and values the parts of delivery read and write on the bus -// (novox/hq ADR 0429 §2: the key layouts another repository reads live in mesh-sdk; research 063 section 01). The -// node-engine writes the have keys, the watchman of the repository mesh-delivery writes presence and pulse, the -// view reads all of them, and the controller grants them; every one imports this package, and nothing else defines -// these names. -// -// **Buckets** are named `_` as the broker names a module's state (provider.BucketOf): the -// node-engine's `have`, and the watchman's `presence` and `pulse`. -// -// **Keys.** A have key is `.`, and a node's own facts are `.node`, so a module may not be named -// `node`. A presence key is `` for a node and `part..` for a part's instance, so a node may -// not be named `part`. A pulse key is `.`: the full name of the bucket watched, then that bucket's key, -// which may hold dots, so it splits at the first dot. A name is refused when it is empty or holds a dot, a wildcard, -// whitespace or a character a bus key refuses, and the error names it; but a module's name may hold dots between its -// parts (`novox.be`), and keys with each dot escaped as EscapeName writes it (`novox=2ebe`, issue 1150). -// -// **Values** are JSON of under about 500 bytes, each carrying its version in `v`. A reader refuses a value whose -// version is missing or one it does not know, with an error that is ErrUnknownVersion (research 063 section 05 -// question 9 item 7): a writer writes a new version only once every reader's have shows a version that reads it. A -// writer writes only when the new value differs from the one before (the Same functions). -// -// **Source, candidates, modules and the forge** (issue 1200; ADR 0437 §5, release 3): the gate's bucket `source` with -// its one key `main` (every repository's merged head and the group that moved it), the indexer's `candidates.` -// and `modules.` (a module's source fingerprint, or none and why), and the forge bridge's messages named -// `forge...` (source.go). -// -// **Merges, asks, owed and answers** (issue 1201; ADR 0437 §6, §8): the gate's `merges.`, its asks -// `merge..` (the router's ask whole), its owed keys `...` that the -// forge bridge performs, and the teller's answer under the ask's own key (gate.go). -// -// **The source fingerprint** (issue 1203; ADR 0437 §5): one function of named inputs that the controller's builder and -// the indexer both call, so they agree byte for byte (fingerprint.go). -// -// **The release 4 layouts** (issues 1356 to 1359; ADR 0453 clauses 4, 6 and 16): the new fingerprint form (fingerprint2.go), -// the indexer's manifests by digest and the new fields of its modules and candidates (manifests.go, source.go), the -// builder's build keys (builder.go) and the checker's verdicts and kept verdicts (checker.go). A manifests value is the -// one exception to the size above: a manifest is up to about 21 KB, whole, byte for byte. The bus module's have on the -// node holding mesh-broker is the other: its user list's handing carries a sealed box of every loaded user's hash, some -// kilobytes (handed.go). -// -// Standard library only, and this module's own asks, declaration and seal packages (a composition holds declaration -// resources; the adoption handing is a sealed box, handed.go). -package delivery - -import ( - "bytes" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "maps" - "reflect" - "regexp" - "slices" - "strings" - "time" -) - -// The buckets of delivery's parts. Each is written by one owner: every node's node-engine writes the have keys of -// its own node only, and the watchman writes presence and pulse. -const ( - HaveBucket = "node-engine_have" - PresenceBucket = WatchmanModule + "_presence" - PulseBucket = WatchmanModule + "_pulse" -) - -// HaveHistory and PresenceHistory are the revisions the have and presence buckets keep per key (research 063 -// section 01). -const ( - HaveHistory = 20 - PresenceHistory = 20 -) - -// PulseMoveBound is how long a move may take: a pulse key lives for this time minus the move's age, and the bucket -// expires it on its own (the server writes a delete marker), so a move that overruns it is seen to stall. -const PulseMoveBound = 120 * time.Second - -// Version is the version of every value this package writes and the only one it reads. -const Version = 1 - -// ErrUnknownVersion is what a decode returns for a value whose version is missing or not Version. Test it with -// errors.Is; the message names the version seen and the one read. -var ErrUnknownVersion = errors.New("delivery: unknown value version") - -// Phase is how far a module's change has come on a node. -type Phase string - -// The phases of a module's change on a node. -const ( - PhaseBackingUp Phase = "backing-up" - PhaseApplying Phase = "applying" - PhaseChecking Phase = "checking" - PhaseRunning Phase = "running" - PhaseFailed Phase = "failed" - // PhaseRemoved is written by the node-engine, once, for a module its applied declaration no longer holds and - // for which it had written a have key; the key is never deleted (issue 1369, ADR 0408 §4), so a reader can tell - // that the module stopped. - PhaseRemoved Phase = "removed" - // PhaseHeld is a module whose backup before an apply failed: it keeps running what it has, nothing of the new - // composition is applied, and the node waits for the answer in its want (novox/hq ADR 0539 clause 16). Detail - // carries the backup's own words. - PhaseHeld Phase = "held" - // PhaseWaitingYes is a module of policy record whose composed move the node agent judged to recreate a container or - // move its data (novox/hq ADR 0547 §3): nothing of it is applied, Judgement says the judgement and Waits the digest - // of the composition waiting, and the assigner asks a person; the node applies it once its want's Yes names Waits. - PhaseWaitingYes Phase = "waiting-yes" -) - -// Health is what the module's own check says. -type Health string - -// The healths a have carries. -const ( - HealthOK Health = "ok" - HealthFailed Health = "failed" - HealthWaits Health = "waits" - HealthUnknown Health = "unknown" -) - -var nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`) - -// checkName refuses a name that is empty or holds a dot, a wildcard, whitespace or a character a bus key refuses. -func checkName(kind, name string) error { - if name == "" { - return fmt.Errorf("delivery: the %s name is empty", kind) - } - if !nameOK.MatchString(name) { - return fmt.Errorf("delivery: the %s name %q holds a dot, a wildcard, whitespace or a character a key refuses", kind, name) - } - return nil -} - -// EscapeName is name as a key part: a character outside letters, digits, `-`, `/` and `_` is written `=` and its -// two lowercase hex digits per byte, and `=` itself is escaped, so any non-empty name keys and a name of allowed -// characters only keys as it is. UnescapeName is its inverse. -func EscapeName(name string) string { - var b strings.Builder - for i := 0; i < len(name); i++ { - c := name[i] - switch { - case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '/', c == '_': - b.WriteByte(c) - default: - fmt.Fprintf(&b, "=%02x", c) - } - } - return b.String() -} - -// UnescapeName is the inverse of EscapeName; it refuses a part with a bare `=` or any character EscapeName leaves -// escaped. -func UnescapeName(part string) (string, error) { - var out []byte - for i := 0; i < len(part); i++ { - c := part[i] - if c != '=' { - if !nameOK.MatchString(string(c)) { - return "", fmt.Errorf("delivery: %q holds %q, which a key part escapes", part, c) - } - out = append(out, c) - continue - } - if i+3 > len(part) { - return "", fmt.Errorf("delivery: %q ends in an incomplete escape", part) - } - v, err := hex.DecodeString(part[i+1 : i+3]) - if err != nil || part[i+1:i+3] != strings.ToLower(part[i+1:i+3]) { - return "", fmt.Errorf("delivery: %q holds a malformed escape %q", part, part[i:i+3]) - } - out = append(out, v[0]) - i += 2 - } - return string(out), nil -} - -// HaveKey is the key, in HaveBucket, of a module's have on a node: `.`. A module named `node` is -// refused, since `.node` is the node's own facts. -func HaveKey(node, module string) (string, error) { - if err := checkName("node", node); err != nil { - return "", err - } - part, err := moduleKeyPart(module) - if err != nil { - return "", err - } - if module == "node" { - return "", fmt.Errorf(`delivery: the module name "node" is refused: %s.node is the node's own key`, node) - } - return node + "." + part, nil -} - -var moduleNameOK = regexp.MustCompile(`^[-/_a-zA-Z0-9]+(\.[-/_a-zA-Z0-9]+)*$`) - -// moduleKeyPart is a module's name as a key part (issue 1150): a name may hold a dot (`novox.be`), which a key part -// may not, so a dot is escaped as EscapeName writes it (`novox=2ebe`); a name of letters, digits, `-`, `/` and `_` keys -// as it is. A name that is empty, or holds a wildcard, whitespace, `=`, or an empty part between dots, is refused. -func moduleKeyPart(module string) (string, error) { - if module == "" { - return "", fmt.Errorf("delivery: the module name is empty") - } - if !moduleNameOK.MatchString(module) { - return "", fmt.Errorf("delivery: the module name %q holds a wildcard, whitespace, '=', an empty part between dots or a character a key refuses", module) - } - return EscapeName(module), nil -} - -// parseModuleKeyPart is the inverse of moduleKeyPart; it refuses a part that is not the one moduleKeyPart writes for -// its name (an escape of a character that keys as it is, an upper-case escape, an escape of a refused character). -func parseModuleKeyPart(part string) (string, error) { - if err := checkName("module", part); err != nil { - return "", err - } - module, err := UnescapeName(part) - if err != nil { - return "", err - } - if back, err := moduleKeyPart(module); err != nil || back != part { - return "", fmt.Errorf("delivery: the module part %q is not a module name as a key writes it", part) - } - return module, nil -} - -// HaveNodeWord is the last token of a node's own facts' key in HaveBucket. -const HaveNodeWord = nodeWord - -// HaveNodeKey is the key, in HaveBucket, of a node's own facts: `.node`. -func HaveNodeKey(node string) (string, error) { - if err := checkName("node", node); err != nil { - return "", err - } - return node + "." + HaveNodeWord, nil -} - -// ParseHaveKey is the inverse of HaveKey and HaveNodeKey: isNode is true for `.node`, with module empty. -func ParseHaveKey(key string) (node, module string, isNode bool, err error) { - node, module, ok := strings.Cut(key, ".") - if !ok { - return "", "", false, fmt.Errorf("delivery: %q is not a have key, .", key) - } - if err := checkName("node", node); err != nil { - return "", "", false, fmt.Errorf("delivery: %q is not a have key: %w", key, err) - } - if module == "node" { - return node, "", true, nil - } - module, err = parseModuleKeyPart(module) - if err != nil { - return "", "", false, fmt.Errorf("delivery: %q is not a have key: %w", key, err) - } - return node, module, false, nil -} - -// PresenceNodeKey is the key, in PresenceBucket, of a node: ``. A node named `part` is refused. -func PresenceNodeKey(node string) (string, error) { - if err := checkName("node", node); err != nil { - return "", err - } - if node == "part" { - return "", errors.New(`delivery: the node name "part" is refused: part.. is a part's key`) - } - return node, nil -} - -// PresencePartKey is the key, in PresenceBucket, of a part's instance: `part..`. -func PresencePartKey(part, instance string) (string, error) { - if err := checkName("part", part); err != nil { - return "", err - } - if err := checkName("instance", instance); err != nil { - return "", err - } - return "part." + part + "." + instance, nil -} - -// ParsePresenceKey is the inverse of PresenceNodeKey and PresencePartKey: for a node's key part and instance are -// empty, for a part's key node is. -func ParsePresenceKey(key string) (node, part, instance string, err error) { - if !strings.Contains(key, ".") { - node, err = PresenceNodeKey(key) - if err != nil { - return "", "", "", fmt.Errorf("delivery: %q is not a presence key: %w", key, err) - } - return node, "", "", nil - } - fields := strings.Split(key, ".") - if len(fields) != 3 || fields[0] != "part" { - return "", "", "", fmt.Errorf("delivery: %q is not a presence key, or part..", key) - } - if _, err := PresencePartKey(fields[1], fields[2]); err != nil { - return "", "", "", fmt.Errorf("delivery: %q is not a presence key: %w", key, err) - } - return "", fields[1], fields[2], nil -} - -// PulseKey is the key, in PulseBucket, of the move watched at key of bucket: `.`. The bucket is the -// full bucket name and holds no dot; the key may. -func PulseKey(bucket, key string) (string, error) { - if err := checkName("bucket", bucket); err != nil { - return "", err - } - if key == "" || strings.HasPrefix(key, ".") || strings.HasSuffix(key, ".") || strings.Contains(key, "..") { - return "", fmt.Errorf("delivery: the pulse key %q is empty or has an empty part", key) - } - for _, part := range strings.Split(key, ".") { - if err := checkName("key", part); err != nil { - return "", err - } - } - return bucket + "." + key, nil -} - -// ParsePulseKey is the inverse of PulseKey, splitting at the first dot. -func ParsePulseKey(pulseKey string) (bucket, key string, err error) { - bucket, key, ok := strings.Cut(pulseKey, ".") - if !ok { - return "", "", fmt.Errorf("delivery: %q is not a pulse key, .", pulseKey) - } - if _, err := PulseKey(bucket, key); err != nil { - return "", "", fmt.Errorf("delivery: %q is not a pulse key: %w", pulseKey, err) - } - return bucket, key, nil -} - -// PulseTTL is the time to live of a pulse key for a move of the given age: PulseMoveBound minus the age. At or past -// the bound the move is stalled, with no time left and nothing to write: the watchman raises it at once. -func PulseTTL(moveAge time.Duration) (ttl time.Duration, stalled bool) { - if moveAge >= PulseMoveBound { - return 0, true - } - return PulseMoveBound - moveAge, false -} - -// Have is a module's state on a node, written by that node's node-engine under HaveKey. FP is the module's -// fingerprint once the indexer exists (design 47 release 3); until then Build is the build the node was sent, and -// the view compares by build. -// -// Composed is the digest of the composition the node runs for the module (novox/hq ADR 0539 clause 9): the -// controller judges a move against it (clause 14), and the assigner reads it to know a want was applied. -type Have struct { - V int `json:"v"` - FP string `json:"fp,omitempty"` - Composed string `json:"composed,omitempty"` - Build string `json:"build,omitempty"` - Phase Phase `json:"phase"` - Health Health `json:"health"` - Detail string `json:"detail,omitempty"` - // UserList is what the node-engine of the node holding mesh-broker wrote of the bus's user list, in the have of the - // bus module (novox/hq ADR 0547 §7, §8). Nil on every other have. - UserList *UserListWritten `json:"user-list,omitempty"` - // Judgement and Waits are, in phase waiting-yes, the node agent's judgement of the move and the digest of the - // composition that waits for a person's yes (ADR 0547 §3); empty otherwise. - Judgement Judgement `json:"judgement,omitempty"` - Waits string `json:"waits,omitempty"` - // BuildAlone says, in phase waiting-yes, that the move changes the module's build alone (novox/hq ADR 0601 §1): the - // node composed the build it runs again with today's settings and secrets and got the composition it runs, so the - // release that carries the new build answers the yes. False when a setting or a secret changed too, or the node could - // not tell: the move is then asked of a person as before. - BuildAlone bool `json:"build-alone,omitempty"` - // Kept says, in words, what the node keeps of the module as it runs because its manifest does not yet say what shared - // code no longer infers (that it is the tool runner, that its own secrets are the core's credentials; novox/hq ADR 0601 - // §4): the watchman raises a condition from it, cleared once the have says none. Empty in every other have. - Kept []string `json:"kept,omitempty"` - // Sealed is, for a module the node composes itself, each value its running composition carries sealed to the node's - // key (a secret, a credential, a vault box), as applied (ADR 0547 §3, issue 1126): what the node's composer reuses - // after a restart while it opens to the vault's value, so a value sealed again moves no composition. Openable by the - // node alone, as the vault hands them over the bus. Empty on every other have. - Sealed []string `json:"sealed,omitempty"` - // SealedAt is Sealed by slot (Seals: a field of a resource, issue 1126, C1): the composer reuses a box only at the - // slot that holds it, so a provider and a consumer of one value on one node each keep their own. Written in Sealed's - // place by a node-engine that knows it; Sealed is read only from a have written before. - SealedAt Seals `json:"sealed-at,omitempty"` - // PullWait is, on a failed have, the pull from a store its apply failed on and waits on that store's holder to settle - // for (design 51 *What a pull meets in the swap*, issue 1966 ENG-9): nil on every other have, and on a failure that - // waits on no holder. - PullWait *PullWait `json:"pull-wait,omitempty"` -} - -// PullWait is the pull a failed apply met from a store: the image, the seat it came through and that seat's holder, whose -// settled the apply is tried again on, when the pull began, and when the apply was last tried again with what triggered -// it, in words (empty while it has not been). -type PullWait struct { - Image string `json:"image"` - Seat string `json:"seat,omitempty"` - Holder string `json:"holder"` - Began time.Time `json:"began"` - Retried string `json:"retried,omitempty"` - RetriedAt time.Time `json:"retried-at,omitzero"` -} - -// samePullWait says whether two pull waits are the same, their times compared as instants. -func samePullWait(a, b *PullWait) bool { - if a == nil || b == nil { - return a == b - } - return a.Image == b.Image && a.Seat == b.Seat && a.Holder == b.Holder && a.Began.Equal(b.Began) && - a.Retried == b.Retried && a.RetriedAt.Equal(b.RetriedAt) -} - -// UserListWritten is the bus's user list as the node-engine of the bus node wrote it to the bus module's file, applied -// alone and with no want (novox/hq ADR 0547 §8): where it was taken from, what that source holds, and what the file -// holds now. The bus reloads the file itself when its digest changes (the module's entrypoint), so Digest is the list -// the bus loads. Wanted differs from Digest only while a write failed, and Error says why. -type UserListWritten struct { - // Source is the key the list was taken from: the grants part's `user-list` (GrantsUserListSource). - Source string `json:"source"` - Revision uint64 `json:"revision"` - // Wanted is the digest of the list the source holds, `sha256:`. - Wanted string `json:"wanted"` - // Digest is the digest of the file on the machine after the write, `sha256:`; empty when there is none. - Digest string `json:"digest,omitempty"` - Users int `json:"users"` - At time.Time `json:"at"` - Error string `json:"error,omitempty"` - // Follows is which list the node follows now (FollowGrants, novox/hq ADR 0547 clause 8), empty from a node-engine - // before the switch. - Follows string `json:"follows,omitempty"` - // Key is the bus node's sealing key, its public half as SPKI PEM: the key the grants part seals its list to - // (clause 7). Public, as the vault holds it. - Key string `json:"key,omitempty"` - // Unwatched says why the bus node does not watch the grants part's key now (its watch refused or ended, and tried - // again), empty while it does or where it need not (ADR 0547 clause 8): it then keeps the last list it followed. - Unwatched string `json:"unwatched,omitempty"` - // Handed is the hashes of the users of the list on the machine, sealed to the vault, for its adoption of the - // accounts it holds no value for (ADR 0547 clause 9, handed.go); nil while the vault's key is not known. - Handed *AccountsHanded `json:"handed,omitempty"` -} - -// HaveNode is a node's own facts, written under HaveNodeKey. Each field is the node-engine's own JSON, carried whole: -// this package names the facts and does not type them. -// -// Composed is the digest of the node's own composition it runs (ADR 0539 clause 9). -type HaveNode struct { - V int `json:"v"` - Composed string `json:"composed,omitempty"` - Windows json.RawMessage `json:"windows,omitempty"` - Busy json.RawMessage `json:"busy,omitempty"` - Strays json.RawMessage `json:"strays,omitempty"` - Filters json.RawMessage `json:"filters,omitempty"` - Profile json.RawMessage `json:"profile,omitempty"` - // Record is what the machine observes of itself, typed, for every node and composing part to read (issue 1126, - // ADR 0555 §3): the node's record lives in its own have key, so it needs no bucket, writer or grant of its own. - Record *NodeRecord `json:"record,omitempty"` - // Sealed is, on a node composing its own, each value its own composition carries sealed to the node's key, as - // applied (Have.Sealed for the node's own, issue 1126): a value the vault seals again after a restart moves no - // composition, so a restart applies nothing again. - Sealed []string `json:"sealed,omitempty"` - // SealedAt is Sealed by slot (Have.SealedAt). - SealedAt Seals `json:"sealed-at,omitempty"` - // Candidates is the node's answer to each placement candidate naming it, by proposal id (PlacementCandidate): written by - // its shadow composer whenever an answer changes, removed when the candidate key goes. The node applies nothing of a - // candidate. - Candidates map[string]CandidateAnswer `json:"candidates,omitempty"` - // Composes is whose compositions the node applies: its own (ComposesOwn, placement places it), or none, empty, with - // ComposesWhy saying why in words. Written by the node agent at each start (issue 1126). An engine from before the - // controller ended may still say `controller` (composedByTheEndedController), which is read and never written. - Composes string `json:"composes,omitempty"` - ComposesWhy string `json:"composes-why,omitempty"` -} - -// ComposesOwn is a node applying its own compositions (HaveNode.Composes). -const ComposesOwn = "own" - -// composedByTheEndedController is what an engine from before the controller ended (ADR 0590) wrote in Composes while -// it applied the controller's compositions: still read, so its have is not refused, and written by no engine now. -const composedByTheEndedController = "controller" - -// Presence is whether a node or a part's instance is online; the entry's own time says since when. The watchman -// writes it. -type Presence struct { - V int `json:"v"` - Online bool `json:"online"` -} - -// Pulse is the value of a pulse key: the watched key mirrored, its revision, and who owes the next move (a node or -// a part instance). The watchman writes it with the time to live PulseTTL gives. -type Pulse struct { - V int `json:"v"` - Bucket string `json:"bucket"` - Key string `json:"key"` - Revision uint64 `json:"revision"` - Owes string `json:"owes,omitempty"` -} - -func checkVersion(data []byte) error { - var probe struct { - V *int `json:"v"` - } - if err := json.Unmarshal(data, &probe); err != nil { - return fmt.Errorf("delivery: value is not JSON: %w", err) - } - if probe.V == nil { - return fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownVersion, Version) - } - if *probe.V != Version { - return fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownVersion, *probe.V, Version) - } - return nil -} - -func decode(data []byte, into any) error { - if err := checkVersion(data); err != nil { - return err - } - if err := json.Unmarshal(data, into); err != nil { - return fmt.Errorf("delivery: value does not fit its shape: %w", err) - } - return nil -} - -func (h Have) check() error { - switch h.Phase { - case PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed, PhaseRemoved, PhaseHeld: - if h.Judgement != "" || h.Waits != "" || h.BuildAlone { - return fmt.Errorf("delivery: only a have waiting for a yes names a judgement, the composition waiting and whether it changes the build alone") - } - if h.PullWait != nil && h.Phase != PhaseFailed { - return fmt.Errorf("delivery: only a failed have names the pull it waits on, not one %s", h.Phase) - } - case PhaseWaitingYes: - if h.Judgement != JudgedRecreates && h.Judgement != JudgedMovesData && h.Judgement != JudgedCannot { - return fmt.Errorf("delivery: a have waiting for a yes names the judgement %q, not recreates, moves-data or cannot-judge", h.Judgement) - } - if err := checkDigest(h.Waits, "the composition a have waits a yes for"); err != nil { - return err - } - default: - return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running, failed, removed, held or waiting-yes", h.Phase) - } - if w := h.PullWait; w != nil && (w.Image == "" || w.Holder == "") { - return fmt.Errorf("delivery: a have's pull wait names no image or no holder") - } - if h.Composed != "" { - if err := checkDigest(h.Composed, "the have's composition digest"); err != nil { - return err - } - } - switch h.Health { - case HealthOK, HealthFailed, HealthWaits, HealthUnknown: - default: - return fmt.Errorf("delivery: health %q is not ok, failed, waits or unknown", h.Health) - } - return nil -} - -// EncodeHave is h as JSON at Version; it refuses a phase or health outside the lists. -func EncodeHave(h Have) ([]byte, error) { - if err := h.check(); err != nil { - return nil, err - } - h.V = Version - return json.Marshal(h) -} - -// DecodeHave reads a have; it refuses an unknown version (ErrUnknownVersion) and a phase or health outside the lists. -func DecodeHave(data []byte) (Have, error) { - var h Have - if err := decode(data, &h); err != nil { - return Have{}, err - } - if err := h.check(); err != nil { - return Have{}, err - } - return h, nil -} - -func (n HaveNode) check() error { - switch n.Composes { - case "", ComposesOwn, composedByTheEndedController: - default: - return fmt.Errorf("delivery: a node composes %q, not %s", n.Composes, ComposesOwn) - } - for group, a := range n.Candidates { - if group == "" { - return fmt.Errorf("delivery: a node's candidate answer names no group") - } - if err := a.check(); err != nil { - return fmt.Errorf("delivery: the answer to %s's candidate: %w", group, err) - } - } - return nil -} - -// EncodeHaveNode is n as JSON at Version; it refuses a candidate answer CandidateAnswer's rules refuse. -func EncodeHaveNode(n HaveNode) ([]byte, error) { - if err := n.check(); err != nil { - return nil, err - } - n.V = Version - return json.Marshal(n) -} - -// DecodeHaveNode reads a node's facts; it refuses an unknown version (ErrUnknownVersion) and a candidate answer that -// is not one. -func DecodeHaveNode(data []byte) (HaveNode, error) { - var n HaveNode - if err := decode(data, &n); err != nil { - return n, err - } - if err := n.check(); err != nil { - return HaveNode{}, err - } - return n, nil -} - -// EncodePresence is p as JSON at Version. -func EncodePresence(p Presence) ([]byte, error) { - p.V = Version - return json.Marshal(p) -} - -// DecodePresence reads a presence; it refuses an unknown version (ErrUnknownVersion). -func DecodePresence(data []byte) (Presence, error) { - var p Presence - err := decode(data, &p) - return p, err -} - -// EncodePulse is p as JSON at Version. -func EncodePulse(p Pulse) ([]byte, error) { - p.V = Version - return json.Marshal(p) -} - -// DecodePulse reads a pulse; it refuses an unknown version (ErrUnknownVersion). -func DecodePulse(data []byte) (Pulse, error) { - var p Pulse - err := decode(data, &p) - return p, err -} - -// SameHave says whether two haves are the same value, so a writer writes only when they differ. -func SameHave(a, b Have) bool { - ua, ub := a.UserList, b.UserList - a.UserList, b.UserList = nil, nil - if !samePullWait(a.PullWait, b.PullWait) { - return false - } - a.PullWait, b.PullWait = nil, nil - if !slices.Equal(a.Sealed, b.Sealed) || !maps.Equal(a.SealedAt, b.SealedAt) { - return false - } - a.Sealed, b.Sealed = nil, nil - a.SealedAt, b.SealedAt = nil, nil - if !reflect.DeepEqual(a, b) || (ua == nil) != (ub == nil) { - return false - } - if ua == nil { - return true - } - x, y := *ua, *ub - x.At, y.At = time.Time{}, time.Time{} - x.Handed, y.Handed = nil, nil - return x == y && ua.At.Equal(ub.At) && sameHanded(ua.Handed, ub.Handed) -} - -// SameHaveNode says whether two node facts are the same value, comparing each carried JSON without its spacing. -func SameHaveNode(a, b HaveNode) bool { - return a.V == b.V && a.Composed == b.Composed && sameJSON(a.Windows, b.Windows) && sameJSON(a.Busy, b.Busy) && - sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile) && - (a.Record == nil) == (b.Record == nil) && (a.Record == nil || SameNodeRecord(*a.Record, *b.Record)) && - jsonEqual(a.Sealed, b.Sealed) && maps.Equal(a.SealedAt, b.SealedAt) && jsonEqual(a.Candidates, b.Candidates) && a.Composes == b.Composes && - a.ComposesWhy == b.ComposesWhy -} - -// SamePresence says whether two presences are the same value. -func SamePresence(a, b Presence) bool { return a == b } - -// SamePulse says whether two pulses are the same value. -func SamePulse(a, b Pulse) bool { return a == b } - -func sameJSON(a, b json.RawMessage) bool { - var ca, cb bytes.Buffer - if json.Compact(&ca, a) != nil || json.Compact(&cb, b) != nil { - return bytes.Equal(a, b) - } - return bytes.Equal(ca.Bytes(), cb.Bytes()) -} diff --git a/go/delivery/delivery_test.go b/go/delivery/delivery_test.go deleted file mode 100644 index f292a01..0000000 --- a/go/delivery/delivery_test.go +++ /dev/null @@ -1,542 +0,0 @@ -package delivery - -import ( - "bytes" - "encoding/json" - "errors" - "reflect" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/seal" -) - -// 1131/AC1: the have key forms and parses back; bad names are refused, naming them. -func TestHaveKey(t *testing.T) { - t.Run("presence", testPresenceKey) - t.Run("pulse", testPulseKey) - t.Run("pulse ttl", testPulseTTL) - if HaveBucket != "node-engine_have" { - t.Fatalf("HaveBucket = %q", HaveBucket) - } - key, err := HaveKey("ace", "plex") - if err != nil || key != "ace.plex" { - t.Fatalf("HaveKey = %q, %v", key, err) - } - node, module, isNode, err := ParseHaveKey(key) - if err != nil || node != "ace" || module != "plex" || isNode { - t.Fatalf("ParseHaveKey = %q %q %v %v", node, module, isNode, err) - } - if got, err := HaveNodeKey("ace"); err != nil || got != "ace.node" { - t.Fatalf("HaveNodeKey = %q, %v", got, err) - } - if _, err := HaveNodeKey("a b"); err == nil { - t.Error("HaveNodeKey with a space accepted") - } - node, module, isNode, err = ParseHaveKey("ace.node") - if err != nil || node != "ace" || module != "" || !isNode { - t.Fatalf("ParseHaveKey node = %q %q %v %v", node, module, isNode, err) - } - for _, c := range []struct{ node, module, named string }{ - {"ace", "node", "node"}, - {"ace", "", "module"}, - {"", "plex", "node"}, - {"a.b", "plex", "a.b"}, - {"ace", "p*", "p*"}, - {"ace", "p>", "p>"}, - {"ace", "p x", "p x"}, - {"ace", "p\tx", "module"}, - } { - _, err := HaveKey(c.node, c.module) - if err == nil || !strings.Contains(err.Error(), c.named) { - t.Errorf("HaveKey(%q, %q) = %v, want an error naming %q", c.node, c.module, err, c.named) - } - } - for _, bad := range []string{"", "ace", "ace.", ".plex", "ace.plex.x", "ace.p x"} { - if _, _, _, err := ParseHaveKey(bad); err == nil { - t.Errorf("ParseHaveKey(%q) accepted", bad) - } - } -} - -// 1131/AC2: the presence keys form and parse back; a node named part is refused. -func testPresenceKey(t *testing.T) { - if PresenceBucket != "mesh-delivery-watchman_presence" { - t.Fatalf("PresenceBucket = %q", PresenceBucket) - } - key, err := PresenceNodeKey("ace") - if err != nil || key != "ace" { - t.Fatalf("PresenceNodeKey = %q, %v", key, err) - } - node, part, instance, err := ParsePresenceKey(key) - if err != nil || node != "ace" || part != "" || instance != "" { - t.Fatalf("ParsePresenceKey node = %q %q %q %v", node, part, instance, err) - } - key, err = PresencePartKey("view", "novox") - if err != nil || key != "part.view.novox" { - t.Fatalf("PresencePartKey = %q, %v", key, err) - } - node, part, instance, err = ParsePresenceKey(key) - if err != nil || node != "" || part != "view" || instance != "novox" { - t.Fatalf("ParsePresenceKey part = %q %q %q %v", node, part, instance, err) - } - if _, err := PresenceNodeKey("part"); err == nil || !strings.Contains(err.Error(), "part") { - t.Errorf("PresenceNodeKey(part) = %v", err) - } - if _, err := PresenceNodeKey("a.b"); err == nil { - t.Error("PresenceNodeKey(a.b) accepted") - } - if _, err := PresencePartKey("", "x"); err == nil { - t.Error("PresencePartKey with empty part accepted") - } - if _, err := PresencePartKey("view", "x y"); err == nil { - t.Error("PresencePartKey with a space accepted") - } - for _, bad := range []string{"", "part", "part.view", "part.view.a.b", "a.b", "part..x"} { - if _, _, _, err := ParsePresenceKey(bad); err == nil { - t.Errorf("ParsePresenceKey(%q) accepted", bad) - } - } -} - -// 1131/AC3: the pulse key splits at the first dot, the key holding dots. -func testPulseKey(t *testing.T) { - if PulseBucket != "mesh-delivery-watchman_pulse" { - t.Fatalf("PulseBucket = %q", PulseBucket) - } - key, err := PulseKey("node-engine_have", "ace.plex") - if err != nil || key != "node-engine_have.ace.plex" { - t.Fatalf("PulseKey = %q, %v", key, err) - } - bucket, rest, err := ParsePulseKey(key) - if err != nil || bucket != "node-engine_have" || rest != "ace.plex" { - t.Fatalf("ParsePulseKey = %q %q %v", bucket, rest, err) - } - for _, c := range [][2]string{{"", "k"}, {"a.b", "k"}, {"b", ""}, {"b", "k x"}, {"b", ".k"}, {"b", "k."}, {"b", "k*"}} { - if _, err := PulseKey(c[0], c[1]); err == nil { - t.Errorf("PulseKey(%q, %q) accepted", c[0], c[1]) - } - } - for _, bad := range []string{"", "nodot", ".k", "b."} { - if _, _, err := ParsePulseKey(bad); err == nil { - t.Errorf("ParsePulseKey(%q) accepted", bad) - } - } -} - -// 1131/AC4: each value encodes, decodes equal, and carries "v":1. -func TestRoundTrip(t *testing.T) { - t.Run("phase and health refused", testPhaseHealthRefused) - hasV1 := func(t *testing.T, b []byte) { - t.Helper() - var m map[string]any - if err := json.Unmarshal(b, &m); err != nil { - t.Fatal(err) - } - if m["v"] != float64(1) { - t.Fatalf("v = %v in %s", m["v"], b) - } - if len(b) > 500 { - t.Fatalf("value is %d bytes", len(b)) - } - } - t.Run("have", func(t *testing.T) { - in := Have{Build: "b1", FP: "f1", Phase: PhaseRunning, Health: HealthOK, Detail: "up"} - b, err := EncodeHave(in) - if err != nil { - t.Fatal(err) - } - hasV1(t, b) - out, err := DecodeHave(b) - if err != nil { - t.Fatal(err) - } - in.V = Version - if !SameHave(out, in) { - t.Fatalf("got %+v want %+v", out, in) - } - }) - t.Run("have with the user list", func(t *testing.T) { - at := time.Date(2026, 10, 25, 21, 40, 0, 0, time.UTC) - in := Have{FP: "f1", Phase: PhaseRunning, Health: HealthOK, UserList: &UserListWritten{Source: "novox.nats.f1", - Revision: 7, Wanted: "sha256:ab", Digest: "sha256:ab", Users: 93, At: at}} - b, err := EncodeHave(in) - if err != nil { - t.Fatal(err) - } - hasV1(t, b) - out, err := DecodeHave(b) - if err != nil { - t.Fatal(err) - } - in.V = Version - if !reflect.DeepEqual(out, in) || !out.UserList.At.Equal(at) { - t.Fatalf("got %+v want %+v", out, in) - } - // And with the hashes handed to the vault (ADR 0547 clause 9): sealed to its key, opened by it alone, every - // listed user with a bcrypt hash once, and refused to another key. - hash := "$2a$11$" + strings.Repeat("a", 53) - list := []byte("{ user: \"controller\", password: \"" + hash + "\", permissions: {\n" + - "{ user: \"node.ace\", password: \"" + hash + "\", permissions: {\n" + - "{ user: \"controller\", password: \"" + hash + "\" }\n{ user: \"nkey\", password: \"plain\" }\n") - accounts := HandedAccountsOf(list) - if len(accounts) != 2 || accounts[0].User != "controller" || accounts[1].User != "node.ace" || accounts[1].Hash != hash { - t.Fatalf("handed accounts: %+v", accounts) - } - vault, err := seal.GenerateKeyPair() - if err != nil { - t.Fatal(err) - } - other, _ := seal.GenerateKeyPair() - handed, err := HandAccounts(accounts, vault.PublicKey, "sha256:ab", at) - if err != nil { - t.Fatal(err) - } - in.UserList.Handed = &handed - b, err = EncodeHave(in) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(b), "node.ace") || strings.Contains(string(b), hash) { - t.Fatalf("a user or a hash is in the clear: %s", b) - } - out, err = DecodeHave(b) - if err != nil || !SameHave(out, in) { - t.Fatalf("handed round trip: %v %+v", err, out.UserList) - } - opened, err := OpenHanded(*out.UserList.Handed, vault.PublicKey, vault.PrivateKey) - if err != nil || !reflect.DeepEqual(opened, accounts) { - t.Fatalf("opened %+v, %v", opened, err) - } - if _, err := OpenHanded(*out.UserList.Handed, other.PublicKey, other.PrivateKey); err == nil { - t.Fatal("a handing opened for another key") - } - if _, err := HandAccounts(accounts, "", "sha256:ab", at); err == nil { - t.Fatal("handed with no vault key") - } - }) - // The grants part's list sealed to the bus node (novox/hq ADR 0547 clause 7): the key's value round-trips and refuses - // what the bus node could not follow, and the object opens with the node's key alone, to the digest the key names. - t.Run("the grants part's sealed user list", func(t *testing.T) { - in := Have{FP: "f1", Phase: PhaseRunning, Health: HealthOK, UserList: &UserListWritten{Source: GrantsUserListSource, - Revision: 3, Wanted: "sha256:ab", Digest: "sha256:ab", Users: 2, Follows: FollowGrants, Key: "PEM"}} - b, err := EncodeHave(in) - if err != nil { - t.Fatal(err) - } - if out, err := DecodeHave(b); err != nil || out.UserList.Follows != FollowGrants || out.UserList.Key != "PEM" { - t.Fatalf("got %+v, %v", out.UserList, err) - } - list := []byte("accounts { MESH { users = [ { user: \"controller\" } ] } }\n") - digest := UserListDigest(list) - node, err := seal.GenerateKeyPair() - if err != nil { - t.Fatal(err) - } - other, _ := seal.GenerateKeyPair() - g := GrantsUserList{Follow: FollowGrants, Digest: digest, Object: UserListObjectName(digest), - SealedTo: SealingKeyID(node.PublicKey), Node: "novox", Revision: 1, Users: 1} - raw, err := EncodeGrantsUserList(g) - if err != nil { - t.Fatal(err) - } - back, err := DecodeGrantsUserList(raw) - if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants { - t.Fatalf("decoded %+v, %v", back, err) - } - // Issue 1126 B4a (ADR 0590): a key written with the lease's flag still reads; the controller's list is no source. - if old, err := DecodeGrantsUserList([]byte(`{"v":1,"follow":"grants","digest":"","object":"","sealed_to":"",` + - `"node":"novox","revision":3,"users":40,"left_out":[],"at":"2026-10-30T10:00:00Z","lease-handed":true}`)); err != nil || old.Revision != 3 { - t.Errorf("a key with the lease's flag decodes %+v, %v", old, err) - } - for _, bad := range []GrantsUserList{{Follow: "both", Digest: digest, Object: g.Object}, {Follow: "controller"}, - {Follow: FollowGrants, Digest: digest, Object: "other"}, {Follow: FollowGrants, Digest: "md5:x", Object: "md5:x"}} { - if _, err := EncodeGrantsUserList(bad); err == nil { - t.Errorf("%+v was written", bad) - } - } - if _, err := EncodeGrantsUserList(GrantsUserList{Follow: FollowGrants}); err != nil { - t.Errorf("a switch with no list yet refused: %v", err) - } - object, err := SealUserList(list, node.PublicKey) - if err != nil { - t.Fatal(err) - } - if bytes.Contains(object, []byte("controller")) { - t.Fatal("the object carries the list in the clear") - } - opener := func(priv string) func(seal.Box) ([]byte, error) { - return func(b seal.Box) ([]byte, error) { s, err := seal.Open(b, priv); return []byte(s), err } - } - if got, err := OpenUserList(object, digest, opener(node.PrivateKey)); err != nil || !bytes.Equal(got, list) { - t.Fatalf("opened %q, %v", got, err) - } - if _, err := OpenUserList(object, digest, opener(other.PrivateKey)); err == nil { - t.Error("another key opened the list") - } - if _, err := OpenUserList(object, "sha256:00", opener(node.PrivateKey)); err == nil { - t.Error("a list of another digest was taken") - } - if _, err := SealUserList(list, "not a key"); err == nil { - t.Error("a list was sealed to no key") - } - }) - t.Run("have node", func(t *testing.T) { - in := HaveNode{Windows: json.RawMessage(`{"a":1}`), Busy: json.RawMessage(`true`)} - b, err := EncodeHaveNode(in) - if err != nil { - t.Fatal(err) - } - hasV1(t, b) - out, err := DecodeHaveNode(b) - if err != nil { - t.Fatal(err) - } - if !SameHaveNode(out, HaveNode{V: Version, Windows: in.Windows, Busy: in.Busy}) { - t.Fatalf("got %+v", out) - } - }) - t.Run("presence", func(t *testing.T) { - b, err := EncodePresence(Presence{Online: true}) - if err != nil { - t.Fatal(err) - } - hasV1(t, b) - out, err := DecodePresence(b) - if err != nil || out != (Presence{V: Version, Online: true}) { - t.Fatalf("got %+v %v", out, err) - } - }) - t.Run("pulse", func(t *testing.T) { - in := Pulse{Bucket: "node-engine_have", Key: "ace.plex", Revision: 7, Owes: "ace"} - b, err := EncodePulse(in) - if err != nil { - t.Fatal(err) - } - hasV1(t, b) - out, err := DecodePulse(b) - in.V = Version - if err != nil || out != in { - t.Fatalf("got %+v %v", out, err) - } - }) -} - -// 1131/AC5: a missing or unknown version is refused as ErrUnknownVersion, naming both versions. -func TestUnknownVersion(t *testing.T) { - decoders := map[string]func([]byte) error{ - "have": func(b []byte) error { _, err := DecodeHave(b); return err }, - "have node": func(b []byte) error { _, err := DecodeHaveNode(b); return err }, - "presence": func(b []byte) error { _, err := DecodePresence(b); return err }, - "pulse": func(b []byte) error { _, err := DecodePulse(b); return err }, - } - for name, dec := range decoders { - t.Run(name, func(t *testing.T) { - err := dec([]byte(`{"v":2,"phase":"running","health":"ok"}`)) - if !errors.Is(err, ErrUnknownVersion) || !strings.Contains(err.Error(), "2") || !strings.Contains(err.Error(), "1") { - t.Errorf("v 2: %v", err) - } - err = dec([]byte(`{"phase":"running","health":"ok"}`)) - if !errors.Is(err, ErrUnknownVersion) { - t.Errorf("missing v: %v", err) - } - if err := dec([]byte(`not json`)); err == nil || errors.Is(err, ErrUnknownVersion) { - t.Errorf("garbage: %v", err) - } - }) - } -} - -// 1131/AC6: a phase or health outside the lists is refused on encode and decode. -func testPhaseHealthRefused(t *testing.T) { - for _, p := range []Phase{PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed} { - for _, h := range []Health{HealthOK, HealthFailed, HealthWaits, HealthUnknown} { - if _, err := EncodeHave(Have{Phase: p, Health: h}); err != nil { - t.Errorf("%s/%s refused: %v", p, h, err) - } - } - } - if _, err := EncodeHave(Have{Phase: "sleeping", Health: HealthOK}); err == nil || !strings.Contains(err.Error(), "sleeping") { - t.Errorf("bad phase encoded: %v", err) - } - if _, err := EncodeHave(Have{Phase: PhaseRunning, Health: "fine"}); err == nil || !strings.Contains(err.Error(), "fine") { - t.Errorf("bad health encoded: %v", err) - } - if _, err := EncodeHave(Have{Phase: PhaseRunning}); err == nil { - t.Error("empty health encoded") - } - if _, err := DecodeHave([]byte(`{"v":1,"phase":"sleeping","health":"ok"}`)); err == nil { - t.Error("bad phase decoded") - } - if _, err := DecodeHave([]byte(`{"v":1,"phase":"running","health":"fine"}`)); err == nil { - t.Error("bad health decoded") - } - // ADR 0547 §3 (issue 1126): a have waits for a yes with its judgement and the composition waiting, and no other have - // names either. - waits := Have{Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedRecreates, Waits: "sha256:" + strings.Repeat("a", 64)} - if raw, err := EncodeHave(waits); err != nil { - t.Errorf("a have waiting for a yes: %v", err) - } else if back, err := DecodeHave(raw); err != nil || back.Judgement != JudgedRecreates || back.Waits != waits.Waits { - t.Errorf("a have waiting for a yes came back %+v, %v", back, err) - } - // ADR 0601 §1: and whether the move changes the build alone, read back as said. - alone := waits - alone.BuildAlone = true - if raw, err := EncodeHave(alone); err != nil { - t.Errorf("a have waiting for a yes to its build alone: %v", err) - } else if back, err := DecodeHave(raw); err != nil || !back.BuildAlone { - t.Errorf("a have waiting for a yes to its build alone came back %+v, %v", back, err) - } - // ADR 0601 §4: what the node keeps as it runs, read back as said. - if raw, err := EncodeHave(Have{Phase: PhaseRunning, Health: HealthOK, Kept: []string{"runs the tool runner"}}); err != nil { - t.Errorf("a have keeping what runs: %v", err) - } else if back, err := DecodeHave(raw); err != nil || len(back.Kept) != 1 { - t.Errorf("a have keeping what runs came back %+v, %v", back, err) - } - for name, h := range map[string]Have{ - "running build alone": {Phase: PhaseRunning, Health: HealthOK, BuildAlone: true}, - "neither judged": {Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedNeither, Waits: waits.Waits}, - "no waiting digest": {Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedMovesData}, - "running judged": {Phase: PhaseRunning, Health: HealthOK, Judgement: JudgedRecreates}, - } { - if _, err := EncodeHave(h); err == nil { - t.Errorf("%s: encoded", name) - } - } -} - -// 1131/AC7: the pulse's time to live is 120 seconds minus the move's age; at the bound the move is stalled. -func testPulseTTL(t *testing.T) { - for _, c := range []struct { - age time.Duration - ttl time.Duration - stalled bool - }{ - {0, 120 * time.Second, false}, - {30 * time.Second, 90 * time.Second, false}, - {119 * time.Second, time.Second, false}, - {120 * time.Second, 0, true}, - {200 * time.Second, 0, true}, - } { - ttl, stalled := PulseTTL(c.age) - if ttl != c.ttl || stalled != c.stalled { - t.Errorf("PulseTTL(%v) = %v, %v; want %v, %v", c.age, ttl, stalled, c.ttl, c.stalled) - } - } - if PulseMoveBound != 120*time.Second || HaveHistory != 20 || PresenceHistory != 20 { - t.Error("constants") - } -} - -// 1131/AC8: equal values say same, different ones do not. -func TestSame(t *testing.T) { - t.Run("have removed", testHaveRemoved) - a := Have{V: 1, Build: "b", Phase: PhaseRunning, Health: HealthOK} - b := a - if !SameHave(a, b) { - t.Error("equal haves differ") - } - b.Detail = "x" - if SameHave(a, b) { - t.Error("different haves same") - } - // The sealed values a self-composing node runs are part of its have (issue 1126). - sa, sb := a, a - sa.Sealed, sb.Sealed = []string{`{"box":1}`}, []string{`{"box":1}`} - if !SameHave(sa, sb) || SameHave(sa, a) { - t.Error("equal sealed values differ, or one said and one not are the same") - } - if raw, err := EncodeHave(sa); err != nil { - t.Error(err) - } else if back, err := DecodeHave(raw); err != nil || !SameHave(back, sa) { - t.Errorf("sealed values do not travel: %+v, %v", back, err) - } - // A failed have carries the pull it waits on, compared by what it says (issue 1966 ENG-9); only a failed one does. - began := time.Date(2026, 11, 4, 14, 0, 0, 0, time.UTC) - pa, pb := a, a - pa.Phase, pb.Phase = PhaseFailed, PhaseFailed - pa.PullWait = &PullWait{Image: "reg/x@sha256:1", Seat: "container-image-registry", Holder: "distribution", Began: began, - Retried: "distribution settled", RetriedAt: began.Add(time.Minute)} - w := *pa.PullWait - w.Began, w.RetriedAt = began.In(time.Local), w.RetriedAt.In(time.Local) - pb.PullWait = &w - if !SameHave(pa, pb) || SameHave(pa, Have{V: 1, Build: "b", Phase: PhaseFailed, Health: HealthOK}) { - t.Error("equal pull waits differ, or one said and one not are the same") - } - if raw, err := EncodeHave(pa); err != nil { - t.Error(err) - } else if back, err := DecodeHave(raw); err != nil || !SameHave(back, pa) { - t.Errorf("a pull wait does not travel: %+v, %v", back, err) - } - pb.PullWait = &PullWait{Image: "reg/x@sha256:1", Holder: "distribution", Began: began} - if SameHave(pa, pb) { - t.Error("pull waits retried and not are the same") - } - for name, h := range map[string]Have{ - "on a running have": {Phase: PhaseRunning, Health: HealthOK, PullWait: pa.PullWait}, - "naming no holder": {Phase: PhaseFailed, Health: HealthOK, PullWait: &PullWait{Image: "reg/x@sha256:1"}}, - } { - if _, err := EncodeHave(h); err == nil { - t.Errorf("a pull wait %s was encoded", name) - } - } - // The user list is compared by what it says, never by where it is held (ADR 0547 §8). - at := time.Date(2026, 10, 25, 21, 0, 0, 0, time.UTC) - ua, ub := a, a - ua.UserList = &UserListWritten{Source: "k", Revision: 1, Wanted: "sha256:a", Digest: "sha256:a", Users: 2, At: at} - ub.UserList = &UserListWritten{Source: "k", Revision: 1, Wanted: "sha256:a", Digest: "sha256:a", Users: 2, At: at.In(time.Local)} - if !SameHave(ua, ub) || SameHave(ua, a) { - t.Error("equal user lists differ, or one said and one not are the same") - } - ub.UserList.Revision = 2 - if SameHave(ua, ub) { - t.Error("different user lists same") - } - // A handing is compared by what it says: one said and one not differ, as do two to other keys. - ub.UserList.Revision = 1 - ua.UserList.Handed = &AccountsHanded{From: "sha256:a", To: "sha256:k", Users: 2, At: at} - if SameHave(ua, ub) { - t.Error("a handing said and one not are the same") - } - ub.UserList.Handed = &AccountsHanded{From: "sha256:a", To: "sha256:k", Users: 2, At: at.In(time.Local)} - if !SameHave(ua, ub) { - t.Error("equal handings differ") - } - ub.UserList.Handed.To = "sha256:j" - if SameHave(ua, ub) { - t.Error("handings to other keys same") - } - n := HaveNode{V: 1, Busy: json.RawMessage(`true`)} - if !SameHaveNode(n, HaveNode{V: 1, Busy: json.RawMessage(`true`)}) || SameHaveNode(n, HaveNode{V: 1}) { - t.Error("have node") - } - if !SamePresence(Presence{V: 1, Online: true}, Presence{V: 1, Online: true}) || SamePresence(Presence{V: 1}, Presence{V: 1, Online: true}) { - t.Error("presence") - } - p := Pulse{V: 1, Bucket: "b", Key: "k", Revision: 1, Owes: "ace"} - q := p - if !SamePulse(p, q) { - t.Error("equal pulses differ") - } - q.Revision = 2 - if SamePulse(p, q) { - t.Error("different pulses same") - } -} - -func testHaveRemoved(t *testing.T) { // 1370/AC3 - in := Have{Build: "b1", Phase: PhaseRemoved, Health: HealthUnknown, Detail: "the declaration no longer holds it"} - b, err := EncodeHave(in) - if err != nil { - t.Fatal(err) - } - out, err := DecodeHave(b) - if err != nil { - t.Fatal(err) - } - in.V = Version - if !SameHave(out, in) || out.Phase != "removed" { - t.Errorf("round trip: %+v", out) - } -} diff --git a/go/delivery/enginedata.go b/go/delivery/enginedata.go deleted file mode 100644 index 19273d3..0000000 --- a/go/delivery/enginedata.go +++ /dev/null @@ -1,72 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// **The data items of each module placed on a node, as its engine composed them** (novox/hq ADR 0577 clauses 1 and 4): -// the node-engine's state `data` (EngineDataBucket), one key `.` (EngineDataKey) per module placed on the -// node that declares data items, written by that node's engine alone, when the module's composition changes and only -// when the value differs, and deleted when the module leaves the node. That node's disk-load watches its node's keys -// (EngineDataNodeKeys) and measures what they name; it composes nothing itself. Only the contract is here: the state's -// names, its key and its value; the engine composes the items and disk-load measures them. - -// EngineDataState, EngineDataBucket and EngineDataRead are the node-engine's state data, by its local name, as the bus -// names it and as a reader declares it. -const ( - EngineDataState = "data" - EngineDataBucket = "mesh-host_" + EngineDataState - EngineDataRead = "mesh-host." + EngineDataState -) - -// EngineDataVersion is the version of EngineData's layout. -const EngineDataVersion = 1 - -// EngineData is one key's value: the module's data items on the node as the engine composed them (each item's id, its -// path resolved on that node, its measure, class, protection and what covers it), and the digest of the module's -// composition they came from (ComposedDigestOf). -type EngineData struct { - V int `json:"v"` - Node string `json:"node"` - Module string `json:"module"` - Digest string `json:"digest"` - Items []catalogue.PlacedDataItem `json:"items"` -} - -// EngineDataKey is a module's key on a node, `.`, its module part escaped as a have's is (HaveKey). -func EngineDataKey(node, module string) (string, error) { return HaveKey(node, module) } - -// EngineDataNodeKeys is the pattern of every key of a node, `.*`: what that node's disk-load watches. -func EngineDataNodeKeys(node string) (string, error) { - if err := checkName("node", node); err != nil { - return "", err - } - return node + ".*", nil -} - -// EncodeEngineData writes a value at this layout's version. -func EncodeEngineData(d EngineData) ([]byte, error) { - d.V = EngineDataVersion - if _, err := EngineDataKey(d.Node, d.Module); err != nil { - return nil, err - } - if d.Items == nil { - d.Items = []catalogue.PlacedDataItem{} - } - return json.Marshal(d) -} - -// DecodeEngineData reads a value; it refuses another version. -func DecodeEngineData(raw []byte) (EngineData, error) { - var d EngineData - if err := json.Unmarshal(raw, &d); err != nil { - return EngineData{}, fmt.Errorf("delivery: a module's data items are not readable: %w", err) - } - if d.V != EngineDataVersion { - return EngineData{}, fmt.Errorf("delivery: a module's data items of version %d, and this reads %d", d.V, EngineDataVersion) - } - return d, nil -} diff --git a/go/delivery/enginepackages.go b/go/delivery/enginepackages.go deleted file mode 100644 index 4d197ad..0000000 --- a/go/delivery/enginepackages.go +++ /dev/null @@ -1,137 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "slices" - "sort" - - "git.novox.be/novox/mesh-sdk/go/declaration" -) - -// **A node's declared packages, worked out by its engine** (novox/hq ADR 0565 §1, §6; issue 1953): the derivation and -// the comparison of the node-engine's state `packages` (EnginePackagesState, layout DeclaredPackages), so the engine -// that writes the key, the node seat's verb that compares it with the controller's mirror, and any reader work them out -// one way. The controller's own copy of the derivation (mesh-controller internal/inventory DeclaredPackagesValue) is -// not changed (ADR 0547 §1) and ends with its lease. - -// EnginePackagesRead is the engine's state `packages` as a reader names it in `reads`: the node-engine's manifest is -// mesh-host's, so its bucket is mesh-host_packages, read as disk-load reads mesh-host.data (EngineDataRead). -const EnginePackagesRead = "mesh-host." + EnginePackagesState - -// DeclaredPackagesOf is a node's key of `packages` from the compositions its engine composed for the node's wants: -// each resource of type package, by its name and whether it is declared absent, with the modules that declare it so, -// in the mirror's layout and order (names sorted, absent after present, modules sorted). A resource's module is the -// one it names, else its composition's, else the guess from its id (declaration.ModuleGuess); a package of no module -// lists none. -func DeclaredPackagesOf(node string, compositions []Composed) (DeclaredPackages, error) { - by := map[packageKey]map[string]bool{} - for _, c := range compositions { - for _, raw := range c.Resources { - var r struct { - ID string `json:"id"` - Type string `json:"type"` - Package string `json:"package"` - Absent bool `json:"absent"` - Module string `json:"module"` - } - if err := json.Unmarshal(raw, &r); err != nil { - return DeclaredPackages{}, fmt.Errorf("delivery: a resource of %s's composition %q is not readable: %w", node, c.Module, err) - } - if r.Type != "package" || r.Package == "" { - continue - } - k := packageKey{r.Package, r.Absent} - if by[k] == nil { - by[k] = map[string]bool{} - } - module := r.Module - if module == "" { - module = c.Module - } - if module == "" { - module, _ = declaration.ModuleGuess(r.ID) - } - if module != "" { - by[k][module] = true - } - } - } - v := DeclaredPackages{Node: node, Packages: []PackageDeclared{}} - for _, k := range sortedPackageKeys(by) { - modules := make([]string, 0, len(by[k])) - for m := range by[k] { - modules = append(modules, m) - } - sort.Strings(modules) - v.Packages = append(v.Packages, PackageDeclared{Package: k.name, Modules: modules, Absent: k.absent}) - } - return v, nil -} - -// PackageDifference is one package two declarations of a node hold differently: the modules each side declares it -// with (present, or absent when Absent), none on a side that does not hold it. -type PackageDifference struct { - Package string `json:"package"` - Absent bool `json:"absent"` - Engine []string `json:"engine"` - Mirror []string `json:"mirror"` -} - -// DiffDeclaredPackages is every package the engine's key and the controller's mirror hold differently for one node, in -// the layout's order: what the node seat's verb packages answers until the lease write (ADR 0565 §6). Empty when they -// agree. -func DiffDeclaredPackages(engine, mirror DeclaredPackages) []PackageDifference { - index := func(d DeclaredPackages) map[packageKey][]string { - out := map[packageKey][]string{} - for _, p := range d.Packages { - out[packageKey{p.Package, p.Absent}] = append([]string{}, p.Modules...) - } - return out - } - e, m := index(engine), index(mirror) - union := map[packageKey]bool{} - for k := range e { - union[k] = true - } - for k := range m { - union[k] = true - } - out := []PackageDifference{} - for _, k := range sortedPackageKeys(union) { - em, inE := e[k] - mm, inM := m[k] - if inE && inM && slices.Equal(em, mm) { - continue - } - if em == nil { - em = []string{} - } - if mm == nil { - mm = []string{} - } - out = append(out, PackageDifference{Package: k.name, Absent: k.absent, Engine: em, Mirror: mm}) - } - return out -} - -// packageKey is a package as the layout tells it apart: its name, and whether it is declared absent. -type packageKey struct { - name string - absent bool -} - -// sortedPackageKeys is the keys of m by name, absent after present. -func sortedPackageKeys[V any](m map[packageKey]V) []packageKey { - keys := make([]packageKey, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Slice(keys, func(i, j int) bool { - if keys[i].name != keys[j].name { - return keys[i].name < keys[j].name - } - return !keys[i].absent && keys[j].absent - }) - return keys -} diff --git a/go/delivery/enrolment.go b/go/delivery/enrolment.go deleted file mode 100644 index 7afa69d..0000000 --- a/go/delivery/enrolment.go +++ /dev/null @@ -1,308 +0,0 @@ -package delivery - -import ( - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "fmt" - "net/netip" - "strconv" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// **The state of the seat mesh-enrolment** (novox/hq ADR 0587 §2, issue 774): the holder's bucket `enrolments`, holding -// no secret, with two kinds of key. `node.`, one per open enrolment, holds an Enrolment; `address.
` -// reserves one private-network address for one open enrolment and holds the node's name and, from the ask, the ask and the issue's time. Every write is a -// compare-and-set at the revision read. The bucket keeps per-key expiry (`key-ttl`): a key at stage live carries the -// token's expiry, at asked and redeeming none (§5). It is read by the holder and, as a named reader, by the grants part, -// which composes the user `enrol.` from a key at live and the vault's hash (§4). These constants are the seat's -// contract: every reader takes the names from here. -const ( - // EnrolmentModule is the module holding the seat mesh-enrolment. - EnrolmentModule = "mesh-enrolment" - // EnrolmentsState is its state, EnrolmentsBucket the bucket, EnrolmentsRead the name a reader's manifest declares. - EnrolmentsState = "enrolments" - EnrolmentsBucket = EnrolmentModule + "_" + EnrolmentsState - EnrolmentsRead = EnrolmentModule + "." + EnrolmentsState - - // The stages of an open enrolment (§2, §4, §5). - EnrolmentAsked = "asked" - EnrolmentLive = "live" - EnrolmentRedeeming = "redeeming" - - // DefaultTokenLife is a token's life when issue names none; MostTokenLife the longest it may name (§4). - DefaultTokenLife = time.Hour - MostTokenLife = 24 * time.Hour - - // EnrolmentUserPrefix is the bus user a joining machine dials as, `enrol.` (§4). - EnrolmentUserPrefix = "enrol." - - enrolmentNodePrefix = "node." - enrolmentAddressPrefix = "address." -) - -// Enrolment is one key `node.` of `enrolments`: the node's name, the private-network address chosen at issue, the -// why, the caller as the runtime proved it, the ask's id, the stage, when it was issued and when the token expires, and -// from redeem the digest of the public keys presented (KeysDigest). No secret and nothing derived from the token. -type Enrolment struct { - V int `json:"v"` - Node string `json:"node"` - Address string `json:"address"` - Why string `json:"why"` - Caller string `json:"caller"` - Ask string `json:"ask,omitempty"` - Stage string `json:"stage"` - Issued time.Time `json:"issued"` - Expires time.Time `json:"expires"` - KeysDigest string `json:"keys-digest,omitempty"` - // OverlayKey is the machine's tunnel public key, given at issue (the operator's decision of 2026-11-02 01:05), - // base64 of 32 bytes: at every stage, since the node enters placement with it on the operator's yes. - OverlayKey string `json:"overlay-key"` -} - -// EnrolmentAddress is one key `address.
`: the node the address is reserved for and, from when the issue's ask -// is raised, that ask's id and the issue's time (ADR 0589 §6), so the reservation names the enrolment it holds the -// address for. The key is written when the address is reserved, before the ask: Ask and Issued are then both empty, and -// from the ask both set; one without the other is refused. -type EnrolmentAddress struct { - V int `json:"v"` - Node string `json:"node"` - Ask string `json:"ask,omitempty"` - Issued time.Time `json:"issued,omitzero"` -} - -func (a EnrolmentAddress) check() error { - if err := checkName("node", a.Node); err != nil { - return err - } - if (strings.TrimSpace(a.Ask) == "") != a.Issued.IsZero() { - return fmt.Errorf("delivery: the address reserved for %s names its ask without the issue's time, or the reverse", a.Node) - } - return nil -} - -// EnrolmentIssuedID is the id of the enrolment-issued event of node's enrolment issued at issued: -// `enrolment-issued..`, what enrolment-expired names (EnrolmentExpiry). -func EnrolmentIssuedID(node string, issued time.Time) string { - return catalogue.EnrolmentIssuedEvent + "." + node + "." + strconv.FormatInt(issued.UnixNano(), 10) -} - -// EnrolmentNodeKey is a node's key in `enrolments`. -func EnrolmentNodeKey(node string) string { return enrolmentNodePrefix + node } - -// EnrolmentAddressKey is an address's key in `enrolments`: the address as it is, its dots kept (a key's tokens). -func EnrolmentAddressKey(address string) string { return enrolmentAddressPrefix + address } - -// EnrolmentKeyNode is the node a key `node.` names, and whether it is one. -func EnrolmentKeyNode(key string) (string, bool) { - node, ok := strings.CutPrefix(key, enrolmentNodePrefix) - return node, ok && checkName("node", node) == nil -} - -// EnrolmentKeyAddress is the address a key `address.
` names, and whether it is one. -func EnrolmentKeyAddress(key string) (string, bool) { - address, ok := strings.CutPrefix(key, enrolmentAddressPrefix) - if !ok { - return "", false - } - _, err := netip.ParseAddr(address) - return address, err == nil -} - -// EnrolmentUser is the bus user a node enrols as. -func EnrolmentUser(node string) string { return EnrolmentUserPrefix + node } - -func (e Enrolment) check() error { - if err := checkName("node", e.Node); err != nil { - return err - } - if _, err := netip.ParseAddr(e.Address); err != nil { - return fmt.Errorf("delivery: the enrolment of %s names the address %q, which is not an address", e.Node, e.Address) - } - if strings.TrimSpace(e.Why) == "" || strings.TrimSpace(e.Caller) == "" { - return fmt.Errorf("delivery: the enrolment of %s names no why or no caller", e.Node) - } - if k, err := base64.StdEncoding.DecodeString(e.OverlayKey); err != nil || len(k) != 32 { - return fmt.Errorf("delivery: the enrolment of %s names no tunnel key of 32 bytes in base64", e.Node) - } - switch e.Stage { - case EnrolmentAsked, EnrolmentLive: - if e.KeysDigest != "" { - return fmt.Errorf("delivery: the enrolment of %s at %s names presented keys, which only a redeem gives", e.Node, e.Stage) - } - case EnrolmentRedeeming: - if err := checkDigest(e.KeysDigest, "the enrolment's keys digest"); err != nil { - return err - } - default: - return fmt.Errorf("delivery: the enrolment of %s is at stage %q, not asked, live or redeeming", e.Node, e.Stage) - } - if e.Issued.IsZero() || !e.Expires.After(e.Issued) { - return fmt.Errorf("delivery: the enrolment of %s names no issue or an expiry not after it", e.Node) - } - return nil -} - -// EncodeEnrolment is e as JSON at Version; it refuses what DecodeEnrolment refuses. -func EncodeEnrolment(e Enrolment) ([]byte, error) { - if err := e.check(); err != nil { - return nil, err - } - e.V = Version - return json.Marshal(e) -} - -// DecodeEnrolment reads a key `node.`; it refuses an unknown version (ErrUnknownVersion), a node that is not a -// name, an address that is not one, an unknown stage, presented keys before redeem or none at it, and an expiry not -// after the issue. -func DecodeEnrolment(data []byte) (Enrolment, error) { - var e Enrolment - if err := decode(data, &e); err != nil { - return Enrolment{}, err - } - if err := e.check(); err != nil { - return Enrolment{}, err - } - return e, nil -} - -// EncodeEnrolmentAddress is a key `address.
` reserving it for node: with no ask and a zero issued when it is -// reserved, with the issue's ask and time once the ask is raised. It refuses what DecodeEnrolmentAddress refuses. -func EncodeEnrolmentAddress(node, ask string, issued time.Time) ([]byte, error) { - a := EnrolmentAddress{V: Version, Node: node, Ask: ask, Issued: issued} - if err := a.check(); err != nil { - return nil, err - } - return json.Marshal(a) -} - -// DecodeEnrolmentAddress reads a key `address.
`; it refuses no node, and an ask without the issue's time or -// the reverse. -func DecodeEnrolmentAddress(data []byte) (EnrolmentAddress, error) { - var a EnrolmentAddress - if err := decode(data, &a); err != nil { - return EnrolmentAddress{}, err - } - if err := a.check(); err != nil { - return EnrolmentAddress{}, err - } - return a, nil -} - -// TokenLife is the life issue's `expires` names: DefaultTokenLife when empty, refused when it is not a duration, not -// positive or longer than MostTokenLife. -func TokenLife(expires string) (time.Duration, error) { - if strings.TrimSpace(expires) == "" { - return DefaultTokenLife, nil - } - d, err := time.ParseDuration(strings.TrimSpace(expires)) - if err != nil || d <= 0 || d > MostTokenLife { - return 0, fmt.Errorf("delivery: a token lasts a duration up to %s, such as 1h, not %q", MostTokenLife, expires) - } - return d, nil -} - -// EnrolmentExpiry is what enrolment-expired carries (§2): the node, and the enrolment-issued event that carried the -// key's content, named by its message id (the bundle's publish answers no stream sequence, and §2 asks only that the -// event be named), since the bus deleted the key before the holder heard of it. -type EnrolmentExpiry struct { - V int `json:"v"` - Node string `json:"node"` - IssuedID string `json:"issued-event"` -} - -func (x EnrolmentExpiry) check() error { - if err := checkName("node", x.Node); err != nil { - return err - } - if strings.TrimSpace(x.IssuedID) == "" { - return fmt.Errorf("delivery: the expiry of %s's enrolment names no enrolment-issued event", x.Node) - } - return nil -} - -// EncodeEnrolmentExpiry is x as JSON at Version; it refuses what DecodeEnrolmentExpiry refuses. -func EncodeEnrolmentExpiry(x EnrolmentExpiry) ([]byte, error) { - if err := x.check(); err != nil { - return nil, err - } - x.V = Version - return json.Marshal(x) -} - -// DecodeEnrolmentExpiry reads enrolment-expired's body; it refuses an unknown version, a node that is not a name and -// no issued event named. -func DecodeEnrolmentExpiry(data []byte) (EnrolmentExpiry, error) { - var x EnrolmentExpiry - if err := decode(data, &x); err != nil { - return EnrolmentExpiry{}, err - } - if err := x.check(); err != nil { - return EnrolmentExpiry{}, err - } - return x, nil -} - -// **Redeem** (§6): what a joining node-engine sends, and what the holder answers. Shared by the holder and the engine, -// which live in two repositories. - -// Redeem is the redeem verb's call: the node, the public halves of its keys and its identity key's signature over -// RedeemProof. No token: the bus's login check verified it. -type Redeem struct { - Node string `json:"node"` - PublicKey string `json:"public_key"` - OverlayKey string `json:"overlay_key,omitempty"` - SealingKey string `json:"sealing_key"` - ServingKey string `json:"serving_key,omitempty"` - Profile map[string]any `json:"profile,omitempty"` - Tunnel json.RawMessage `json:"tunnel,omitempty"` - Proof string `json:"proof"` -} - -// RedeemProof is the bytes a node signs with its identity key when it redeems: its name and every public key it -// presents, so a proof cannot be moved to another node or other keys. The holder builds the same bytes. -func RedeemProof(node, public, overlay, sealing, serving string) []byte { - return []byte("novox-mesh-redeem v1\x00" + node + "\x00" + public + "\x00" + overlay + "\x00" + sealing + "\x00" + serving) -} - -// KeysDigest is the digest of the public keys a redeem presents, `sha256:`: what the key at redeeming and the -// events carry, the one hash an event carries (§1). -func KeysDigest(public, overlay, sealing, serving string) string { - sum := sha256.Sum256([]byte(strings.Join([]string{public, overlay, sealing, serving}, "\x00"))) - return "sha256:" + hex.EncodeToString(sum[:]) -} - -// KeysDigest is r's keys' digest. -func (r Redeem) KeysDigest() string { - return KeysDigest(r.PublicKey, r.OverlayKey, r.SealingKey, r.ServingKey) -} - -// ProofBytes is r's proof decoded from base64. -func (r Redeem) ProofBytes() ([]byte, error) { return base64.StdEncoding.DecodeString(r.Proof) } - -// RedeemAnswer is what redeem answers (§6): the node, the login it dials as next, its account sealed to the sealing key it -// presented (base64), its certificate as PEM, the vault's public signing half (base64), and the vault's signature over -// SignedBytes under the context of KindEnrolment. The engine refuses an answer not signed by the key its token carries. -type RedeemAnswer struct { - Node string `json:"node"` - Login string `json:"login"` - Account string `json:"account"` - Certificate string `json:"certificate"` - Signer string `json:"signer"` - Signature string `json:"signature,omitempty"` -} - -// SignedBytes is what the vault signs of a: a without its signature, as JSON. -func (a RedeemAnswer) SignedBytes() []byte { - a.Signature = "" - raw, _ := json.Marshal(a) - return raw -} - -// The vault's enrolment verbs (ADR 0561 §5, ADR 0587 §3, §5), asked by mesh-enrolment alone, and their answers are the -// vault's contract's (mesh-contracts meshvault: VerbEnrolmentToken, EnrolmentTokenAnswer, VerbNodeAccount, -// NodeAccountAnswer; novox/hq ADR 0627 §1). diff --git a/go/delivery/fingerprint.go b/go/delivery/fingerprint.go deleted file mode 100644 index 6c1f0f0..0000000 --- a/go/delivery/fingerprint.go +++ /dev/null @@ -1,17 +0,0 @@ -package delivery - -// SourceInputs are what the ended controller's source fingerprint (src1) was of, as its build records hold them -// (BuildRecord), read and never computed again (novox/hq ADR 0437 §5): the module, the git tree of -// its directory, its bases (references, of which only the digest counts), the root tree of each repository its -// artifacts read (by artifact), each artifact's toolchain line, and the reason the source does not pin the build -// when it does not (a build that resolves packages when built). A context is the value the builder records for it: -// a repository's root tree, or for an image that compiles a Go program the `narrow:` hash of the build source it was -// handed (mesh-controller internal/builder/builder.go:1146-1156). -type SourceInputs struct { - Module string - Tree string - Bases []string - Contexts map[string]string - Toolchains map[string]string - Unpinned string -} diff --git a/go/delivery/fingerprint2.go b/go/delivery/fingerprint2.go deleted file mode 100644 index b3150e8..0000000 --- a/go/delivery/fingerprint2.go +++ /dev/null @@ -1,43 +0,0 @@ -package delivery - -import ( - "fmt" - "strings" -) - -// SourcePrefix2 opens every source fingerprint of the new form. The fingerprint is computed by the indexer alone, in -// its own package (novox/hq ADR 0593 §2); this is its form, read by every part. -const SourcePrefix2 = "src2:" - -// Toolchain2 is what an artifact's toolchain enters the new fingerprint as (novox/hq ADR 0405 clause 7): the -// artifact's language, the installed toolchain's version and the build module's recipe version, in place of the -// toolchain image's digest. A toolchain update so changes the fingerprints of that language's modules only. -type Toolchain2 struct { - Language string - Version string - Recipe string -} - -// FingerprintKeyPart is a new-form fingerprint as a key part: its 64 hex digits without the prefix, since a bus key -// may not hold ':'. It refuses a value that is not `src2:` and 64 lowercase hex digits, a src1 fingerprint included, -// naming it. -func FingerprintKeyPart(fp string) (string, error) { - part, ok := strings.CutPrefix(fp, SourcePrefix2) - if !ok || !isHex64(part) { - return "", fmt.Errorf("delivery: %q is not a fingerprint of the new form, %s and 64 lowercase hex digits", fp, SourcePrefix2) - } - return part, nil -} - -// ParseFingerprintKeyPart is the inverse of FingerprintKeyPart. It refuses a part that is not 64 lowercase hex -// digits, a src1 fingerprint or a prefixed value included, naming it. -func ParseFingerprintKeyPart(part string) (string, error) { - if !isHex64(part) { - return "", fmt.Errorf("delivery: %q is not the key part of a fingerprint, 64 lowercase hex digits", part) - } - return SourcePrefix2 + part, nil -} - -func isHex64(s string) bool { - return len(s) == 64 && strings.Trim(s, "0123456789abcdef") == "" -} diff --git a/go/delivery/fingerprint2_test.go b/go/delivery/fingerprint2_test.go deleted file mode 100644 index 4a10342..0000000 --- a/go/delivery/fingerprint2_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package delivery - -import ( - "strings" - "testing" -) - -// 1356/AC6: the key part holds only bus key characters and parsing gives the fingerprint back; a src1 or malformed -// value is refused, naming it. -func TestFingerprintKeyPart(t *testing.T) { - fp := SourcePrefix2 + strings.Repeat("ab", 32) - part, err := FingerprintKeyPart(fp) - if err != nil { - t.Fatal(err) - } - if len(part) != 64 || strings.Trim(part, "0123456789abcdef") != "" || !nameOK.MatchString(part) { - t.Errorf("key part %q is not 64 lowercase hex characters a key allows", part) - } - back, err := ParseFingerprintKeyPart(part) - if err != nil || back != fp { - t.Errorf("parse(%q) = %q, %v; want %q", part, back, err, fp) - } - src1 := SourcePrefix + strings.Repeat("c", 64) - for _, bad := range []string{src1, "src2:", "src2:xyz", "", "src2:" + strings.Repeat("A", 64), "src2:" + strings.Repeat("a", 63)} { - if _, err := FingerprintKeyPart(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("FingerprintKeyPart(%q) = %v, want a refusal naming it", bad, err) - } - } - for _, bad := range []string{src1, "src2:" + strings.Repeat("a", 64), strings.Repeat("a", 63), strings.Repeat("A", 64), "", "zz"} { - if _, err := ParseFingerprintKeyPart(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ParseFingerprintKeyPart(%q) = %v, want a refusal naming it", bad, err) - } - } -} diff --git a/go/delivery/forge_inputs_test.go b/go/delivery/forge_inputs_test.go deleted file mode 100644 index 7b1893a..0000000 --- a/go/delivery/forge_inputs_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package delivery - -import ( - "strings" - "testing" -) - -const oddManifest = "{\n\t\"module\": \"a\",\n \"z\": 1, \"a\": [ \"x\",\"y\" ],\n \"why\": \"é — & \\n\"\n}\n\n" - -func mergedWithInputs() Forge { - return Forge{Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa", Merge: "cccc", Root: "t0", - Trees: map[string]string{"a": "t1", "b": "t2"}, - Manifests: map[string]string{"a": oddManifest}, - Locks: map[string]map[string]string{"a": {"go.sum": strings.Repeat("ab", 32)}}} -} - -// 1442/AC1 -func TestForgeMergedCarriesManifestsAndLocks(t *testing.T) { - f := mergedWithInputs() - data, err := EncodeForge(f) - if err != nil { - t.Fatal(err) - } - for _, frag := range []string{`"manifests":{"a":`, `"locks":{"a":{"go.sum":`} { - if !strings.Contains(string(data), frag) { - t.Errorf("the encoding lacks %s: %s", frag, data) - } - } - got, err := DecodeForge(data) - if err != nil { - t.Fatal(err) - } - if got.Manifests["a"] != oddManifest || !SameForge(got, withV(f)) { - t.Errorf("the message changed on the way: %q", got.Manifests["a"]) - } - // ADR 0565 §2 (issue 1953 D1): a merged message carries the predefined list exact, or says the merge holds none; a - // pull message carries neither. - f.Predefined = []byte(`{"domain":{"type":"string"}}`) - if data, err := EncodeForge(f); err != nil { - t.Fatal(err) - } else if got, err := DecodeForge(data); err != nil || string(got.Predefined) != `{"domain":{"type":"string"}}` { - t.Errorf("the predefined list read back as %s, %v", got.Predefined, err) - } - f.Predefined, f.PredefinedRead = nil, true - if _, err := EncodeForge(f); err != nil { - t.Errorf("a merge holding no predefined list: %v", err) - } - pull := Forge{Kind: ForgePull, Repository: "novox/mesh-sdk", Number: 1, Head: "h", Action: PullOpened, PredefinedRead: true} - if _, err := EncodeForge(pull); err == nil || !strings.Contains(err.Error(), "only a merged message") { - t.Errorf("a pull message carrying the predefined list: %v", err) - } -} - -func withV(f Forge) Forge { f.V = Version; return f } - -// 1442/AC2 -func TestForgeMergedRefusesBadInputs(t *testing.T) { - for name, tc := range map[string]struct { - mut func(*Forge) - want string - }{ - "manifest outside trees": {func(f *Forge) { f.Manifests["zz"] = "{}" }, `"zz"`}, - "locks outside trees": {func(f *Forge) { f.Locks["yy"] = map[string]string{"go.sum": "h"} }, `"yy"`}, - "empty manifest": {func(f *Forge) { f.Manifests["b"] = "" }, `"b"`}, - "empty lock hash": {func(f *Forge) { f.Locks["a"]["go.sum"] = "" }, `"a"`}, - // No repository holds placement (ADR 0567 §11): a message carrying placement files is refused, on a merge or a pull. - "placement files": {func(f *Forge) { f.RetiredPlacements = []byte(`{"anchor":"{}"}`) }, "mesh-placement"}, - "placements read": {func(f *Forge) { f.RetiredRead = true }, "mesh-placement"}, - // ADR 0565 §2 (issue 1953 D1): the predefined list is one JSON object, said present or absent, never both. - "predefined not an object": {func(f *Forge) { f.Predefined = []byte(`["domain"]`) }, "one JSON object"}, - "predefined both": {func(f *Forge) { f.Predefined = []byte(`{}`); f.PredefinedRead = true }, "says the merge holds none"}, - // A deleted branch names its branch and carries nothing else (design 51 *Tagged versions*). - "branch-deleted with no branch": {func(f *Forge) { *f = Forge{Kind: ForgeBranchDeleted, Repository: "novox/mesh-sdk"} }, "needs the branch"}, - "branch-deleted with a head": {func(f *Forge) { - *f = Forge{Kind: ForgeBranchDeleted, Repository: "novox/mesh-sdk", Branch: "feat/x", Head: "aaaa"} - }, "carries nothing else"}, - } { - f := mergedWithInputs() - tc.mut(&f) - if _, err := EncodeForge(f); err == nil || !strings.Contains(err.Error(), tc.want) { - t.Errorf("%s: %v, want a refusal naming %s", name, err, tc.want) - } - } -} - -// 1442/AC3 -func TestForgePullCarriesManifestsAndLocks(t *testing.T) { - pull := func(action string) Forge { - return Forge{Kind: ForgePull, Repository: "novox/mesh-sdk", Number: 1, Head: "h", Action: action, - Dirs: []string{"a"}, DirsKnown: true, Manifests: map[string]string{"a": oddManifest}, - Locks: map[string]map[string]string{"a": {"go.sum": strings.Repeat("0", 64)}}} - } - for _, action := range []string{PullOpened, PullUpdated} { - data, err := EncodeForge(pull(action)) - if err != nil { - t.Fatalf("%s: %v", action, err) - } - if got, err := DecodeForge(data); err != nil || got.Manifests["a"] != oddManifest { - t.Errorf("%s: %v", action, err) - } - } - f := pull(PullOpened) - f.Manifests["b"] = "{}" - if _, err := EncodeForge(f); err == nil || !strings.Contains(err.Error(), `"b"`) { - t.Errorf("a manifest outside Dirs: %v", err) - } - f = pull(PullOpened) - f.Locks["c"] = map[string]string{"go.sum": "h"} - if _, err := EncodeForge(f); err == nil || !strings.Contains(err.Error(), `"c"`) { - t.Errorf("locks outside Dirs: %v", err) - } - for name, f := range map[string]Forge{ - "refused manifests": {Kind: ForgeRefused, Repository: "a/b", Act: "x", Words: "no", Manifests: map[string]string{"a": "{}"}}, - "refused locks": {Kind: ForgeRefused, Repository: "a/b", Act: "x", Words: "no", Locks: map[string]map[string]string{"a": {"go.sum": "h"}}}, - "open manifests": {Kind: ForgeOpen, Manifests: map[string]string{"a": "{}"}}, - "open locks": {Kind: ForgeOpen, Locks: map[string]map[string]string{"a": {"go.sum": "h"}}}, - } { - if _, err := EncodeForge(f); err == nil { - t.Errorf("%s was encoded", name) - } - } -} - -// 1442/AC4 -func TestForgeFromAnOlderBridge(t *testing.T) { - old := `{"v":1,"kind":"merged","repository":"novox/mesh-sdk","number":36,"head":"aaaa","merge":"cccc","trees":{"a":"t1"},"root":"t0"}` - f, err := DecodeForge([]byte(old)) - if err != nil || f.Manifests != nil || f.Locks != nil { - t.Fatalf("%+v, %v", f, err) - } - data, err := EncodeForge(f) - // the older message is written with the struct's key order, so the bytes compare as they are - if err != nil || string(data) != old { - t.Errorf("%s, %v, want exactly %s", data, err, old) - } -} diff --git a/go/delivery/gate.go b/go/delivery/gate.go deleted file mode 100644 index c6b593b..0000000 --- a/go/delivery/gate.go +++ /dev/null @@ -1,527 +0,0 @@ -package delivery - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "reflect" - "regexp" - "sort" - "strconv" - "strings" -) - -// The gate's buckets and the teller's (ADR 0437 §6, §8). The gate writes merges (each pull request's expected -// head, its state and each merge commit), its asks (`merge..`, the router's ask whole) and owed -// (one key per act it owes the forge, which the forge bridge performs once per revision). The teller writes its -// answer under the ask's own key in its bucket answers. -const ( - MergesBucket = GateModule + "_merges" - AsksBucket = GateModule + "_asks" - OwedBucket = GateModule + "_owed" - AnswersBucket = TellerModule + "_answers" - MergesHistory = 10 - AsksHistory = 5 - OwedHistory = 5 - AnswersHistory = 5 -) - -// The contexts of the statuses the mesh owes the forge: the gate's, the delivery's view of a head and of a group -// (design 47), and the checker's. From R4e the checker's mesh/check is the one status a repository's main requires, -// in place of mesh/merge-gate and mesh/repo-check (ADR 0503 clauses 3 and 5); a main that requires only -// mesh/repo-check (hq) keeps it, and the controller writes it for a repository the checker writes no status for. -const ( - StatusContextMergeGate = "mesh/merge-gate" - StatusContextDelivery = "mesh/delivery" - StatusContextDeliveryGroup = "mesh/delivery-group" - StatusContextRepoCheck = "mesh/repo-check" // the gate's pending status of a recheck (mesh-delivery.recheck) - StatusContextCheck = "mesh/check" -) - -var statusContexts = []string{StatusContextMergeGate, StatusContextDelivery, StatusContextDeliveryGroup, StatusContextRepoCheck, StatusContextCheck} - -// AskKindMerge is the kind of the gate's ask for a group's merge, the first part of its key. -const AskKindMerge = "merge" - -// The acts the gate owes the forge, the last part of an owed key. -const ( - OwedStatus = "status" - OwedView = "view" - OwedNote = "note" - OwedComment = "comment" -) - -// The states of a pull request in a merge. -type MergeState string - -// The states a merge's pull request is in. -const ( - MergeMerging MergeState = "merging" - MergeMerged MergeState = "merged" - MergeRefused MergeState = "refused" -) - -// The states a status the gate owes the forge may carry. -var statusStates = []string{"pending", "success", "failure", "error", "warning"} - -var hashOK = regexp.MustCompile(`^[0-9a-f]{12}$`) - -// MergesKey is the key, in MergesBucket, of a group: ``. -func MergesKey(group string) (string, error) { - return groupKey(group) -} - -// MergeAskKey is the key, in AsksBucket, of the ask for merging a group at these heads: `merge..`. The teller's answer has the same key in AnswersBucket (AnswerKey). -func MergeAskKey(group string, heads []Head) (string, error) { - part, err := groupKey(group) - if err != nil { - return "", err - } - hash, err := HeadsHash(heads) - if err != nil { - return "", err - } - return AskKindMerge + "." + part + "." + hash, nil -} - -// SettleGroupPrefix begins the group of a settle ask (novox/hq ADR 0556 clause 5): a person's ask to settle deliveries held -// because their merge reached the trunk without a passing check. It is kept in the gate's asks under MergeAskKey like a merge -// ask, so the teller carries it unchanged; its options bind the act AskVerb names for it. Git forbids a colon in a branch -// name, so no pull request's group (its branch) ever begins with it. -const SettleGroupPrefix = "settle:" - -// SettleGroup is the group of the settle ask of these delivery ids: the prefix and twelve hex digits of the sha256 over the -// ids, sorted and joined by a newline, so the same deliveries asked again keep one key. -func SettleGroup(ids []string) string { - sorted := append([]string(nil), ids...) - sort.Strings(sorted) - sum := sha256.Sum256([]byte(strings.Join(sorted, "\n"))) - return SettleGroupPrefix + hex.EncodeToString(sum[:])[:12] -} - -// AskVerb is the act the options of an ask of the gate bind, by its group: a settle ask's `mesh-delivery.settle`, any other -// `mesh-delivery.merge`. The gate composes the act with it and the teller checks a warrant against it. -func AskVerb(group string) string { - if strings.HasPrefix(group, SettleGroupPrefix) { - return "mesh-delivery.settle" - } - return "mesh-delivery.merge" -} - -// ParseAskKey is the inverse of MergeAskKey: the kind, the group and the heads hash. -func ParseAskKey(key string) (kind, group, hash string, err error) { - parts := strings.Split(key, ".") - if len(parts) != 3 || parts[0] != AskKindMerge { - return "", "", "", fmt.Errorf("delivery: %q is not an ask key, merge..", key) - } - group, err = parseGroupKey(parts[1]) - if err != nil { - return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %w", key, err) - } - if !hashOK.MatchString(parts[2]) { - return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %q is not 12 hex digits", key, parts[2]) - } - return parts[0], group, parts[2], nil -} - -// AnswerKey is the key, in AnswersBucket, of the teller's answer to the ask at askKey: the same string. -func AnswerKey(askKey string) string { return askKey } - -// AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots (its usable id), -// so in the readable part a dot becomes `-` and any other byte outside letters, digits, `_` and `-` becomes `_`; that part is cut to fit, and every id ends in `-` and -// the first 12 hex digits of the sha256 of the whole key, so two keys never give one id. It cannot be read back: -// the teller keeps the key beside it. -func AskID(askKey string) string { - const most = 64 // the router's bound on an id - const tail = 13 // "-" and 12 hex digits - readable := []byte(askKey) - for i, c := range readable { - switch { - case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9': - case c == '.': - readable[i] = '-' - default: // a slash, an equals sign or any other byte the router's id refuses - readable[i] = '_' - } - } - if len(readable) > 0 && (readable[0] == '-' || readable[0] == '_') { - readable[0] = 'k' // an id starts with a letter or digit - } - if len(readable) > most-tail { - readable = readable[:most-tail] - } - sum := sha256.Sum256([]byte(askKey)) - return string(readable) + "-" + hex.EncodeToString(sum[:])[:12] -} - -// AskingID is the id of the asking-th writing of the ask at askKey. Asking 0 and 1 give AskID(askKey), so the first -// asking's id is unchanged; asking n of 2 or more gives the AskID form of askKey + "." + n, so each asking has an id -// of its own (at most 64 bytes, ending in the 12 hex digits of the hash of that whole string) and the router, which -// drops an ask under an id it keeps as a redelivery, takes each as new and an answer to an earlier asking is never -// taken for a later one. -func AskingID(askKey string, asking int) string { - if asking < 2 { - return AskID(askKey) - } - return AskID(askKey + "." + strconv.Itoa(asking)) -} - -// checkStatusContext refuses a status context the mesh does not owe the forge. -func checkStatusContext(context string) error { - for _, c := range statusContexts { - if context == c { - return nil - } - } - return fmt.Errorf("delivery: the status context %q is not one of %s", context, strings.Join(statusContexts, ", ")) -} - -func checkOwedAct(act string) (kind, context string, err error) { - kind, context, hasContext := strings.Cut(act, "=") - switch kind { - case OwedStatus: - if !hasContext { - return "", "", fmt.Errorf("delivery: the owed act %q is a status without its context, status=", act) - } - if err := checkStatusContext(context); err != nil { - return "", "", err - } - return kind, context, nil - case OwedView, OwedNote: - if hasContext { - return "", "", fmt.Errorf("delivery: the owed act %q takes no context", act) - } - return kind, "", nil - } - if kind, seq, ok := strings.Cut(act, "."); ok && kind == OwedComment { - if n, err := strconv.Atoi(seq); err != nil || n < 1 || strconv.Itoa(n) != seq { - return "", "", fmt.Errorf("delivery: the owed act %q is a comment without its sequence, comment. with seq of 1 or more", act) - } - return OwedComment, seq, nil - } - return "", "", fmt.Errorf("delivery: the owed act %q is not status=, view, note or comment.", act) -} - -// OwedCommentAct is the act of the seq-th comment on a head: `comment.`, seq of 1 or more, so a second comment -// has a key of its own and does not overwrite the first. -func OwedCommentAct(seq int) (string, error) { - if seq < 1 { - return "", fmt.Errorf("delivery: a comment's sequence is 1 or more, not %d", seq) - } - return OwedComment + "." + strconv.Itoa(seq), nil -} - -// ParseOwedAct splits an owed act into its kind and, for a status, its context, for a comment its sequence as -// digits. -func ParseOwedAct(act string) (kind, context string, err error) { return checkOwedAct(act) } - -// OwedKey is the key, in OwedBucket, of an act the gate owes the forge on a pull request at a head: -// `...`, the act being `status=` (mesh/merge-gate), `view`, `note` or -// `comment.`. The number is 0 for a commit no pull request names, and never negative. -func OwedKey(repository string, number int, head, act string) (string, error) { - repo, err := escapeRepository(repository) - if err != nil { - return "", err - } - if number < 0 { - return "", fmt.Errorf("delivery: an owed key's pull request number is 0 (none) or more, not %d", number) - } - if err := checkName("head", head); err != nil { - return "", err - } - if _, _, err := checkOwedAct(act); err != nil { - return "", err - } - return repo + "." + strconv.Itoa(number) + "." + head + "." + act, nil -} - -// ParseOwedKey is the inverse of OwedKey; act is whole, as `status=` for a status. -func ParseOwedKey(key string) (repository string, number int, head, act string, err error) { - parts := strings.SplitN(key, ".", 4) // a comment's act holds a dot - if len(parts) != 4 { - return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key, ...", key) - } - number, convErr := strconv.Atoi(parts[1]) - if convErr != nil { - return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %q is not a pull request number", key, parts[1]) - } - repository, err = unescapeRepository(parts[0]) - if err != nil { - return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %w", key, err) - } - if _, err := OwedKey(repository, number, parts[2], parts[3]); err != nil { - return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %w", key, err) - } - return repository, number, parts[2], parts[3], nil -} - -// Merge is the value of a merges key: the ask approved and, for each pull request, the head the approval expects -// and how far its merge has come. Approved is the Approve's heads of every member of the group, ApprovedKey of -// the member to its full head sha: the forge's merge tool wants them for every sibling whichever member it is -// handed (ADR 0450 §3). A merge written before Approved existed has none and is not checked against it. -type Merge struct { - V int `json:"v"` - Group string `json:"group"` - Ask string `json:"ask"` - Approved map[string]string `json:"approved,omitempty"` - Pulls []MergePull `json:"pulls"` -} - -// ApprovedKey is the key of a member in Merge.Approved: `@`. -func ApprovedKey(repository string, number int) string { - return repository + "@" + strconv.Itoa(number) -} - -// MergePull is one pull request of a merge: its expected head, its state, the merge commit once merged, the -// forge's words when refused. -type MergePull struct { - Head - State MergeState `json:"state"` - Commit string `json:"commit,omitempty"` - Words string `json:"words,omitempty"` -} - -func (m Merge) check() error { - if m.Group == "" { - return fmt.Errorf("delivery: a merge names no group") - } - if m.Ask == "" { - return fmt.Errorf("delivery: a merge names no ask") - } - if len(m.Pulls) == 0 { - return fmt.Errorf("delivery: a merge names no pull request") - } - for key, sha := range m.Approved { - if sha == "" { - return fmt.Errorf("delivery: the approved head of %s is empty", key) - } - } - for _, p := range m.Pulls { - who := fmt.Sprintf("%s#%d", p.Repository, p.Number) - if len(m.Approved) > 0 { - sha, ok := m.Approved[ApprovedKey(p.Repository, p.Number)] - if !ok { - return fmt.Errorf("delivery: the pull request %s is handed to the merge but is not among the approved heads", who) - } - if sha != p.Head.Head { - return fmt.Errorf("delivery: the pull request %s is handed at head %q but the Approve was for %q", who, p.Head.Head, sha) - } - } - switch p.State { - case MergeMerging: - case MergeMerged: - if p.Commit == "" { - return fmt.Errorf("delivery: the merged pull request %s has no merge commit", who) - } - case MergeRefused: - if p.Words == "" { - return fmt.Errorf("delivery: the refused pull request %s has no words of the forge", who) - } - default: - return fmt.Errorf("delivery: the state %q of %s is not merging, merged or refused", p.State, who) - } - } - return nil -} - -// Ask is the value of an asks key: the router's ask whole as the gate composes it, so the teller publishes it -// unchanged, and the group and heads it was composed for. The ask is the operator-channel seat's (mesh-contracts -// operatorchannel.Ask), carried here as its JSON: mesh-sdk names no seat defined whole (novox/hq ADR 0627 §4), so its -// id (AskingID of its key and asking), its options and its digest are checked by the gate and the teller through -// operator-channel's client. -type Ask struct { - V int `json:"v"` - Group string `json:"group"` - Heads []Head `json:"heads"` - Ask json.RawMessage `json:"ask"` - // Asking counts the times the gate wrote this group's ask at these heads: 0 or 1 for the first, n for the n-th. - // The router drops an ask whose id it has already kept as a redelivery, so each asking has its own id - // (AskingID); the first asking's id is unchanged and the field is omitted for it. - Asking int `json:"asking,omitempty"` -} - -func (a Ask) check() error { - if a.Asking < 0 { - return fmt.Errorf("delivery: the ask's asking %d is negative", a.Asking) - } - if _, err := MergeAskKey(a.Group, a.Heads); err != nil { - return fmt.Errorf("delivery: an ask needs its group and at least one pull request's head: %w", err) - } - return routerAsk(a.Ask) -} - -// routerAsk refuses a value that carries no router's ask: none, null, or JSON that is no object. What the ask says -// is operator-channel's to check, through its client (novox/hq ADR 0627 §4). -func routerAsk(raw json.RawMessage) error { - var fields map[string]json.RawMessage - if len(bytes.TrimSpace(raw)) == 0 || json.Unmarshal(raw, &fields) != nil || fields == nil { - return fmt.Errorf("delivery: the value carries no router's ask") - } - return nil -} - -// Answer is the teller's answer to an ask: the option chosen, the warrant's id and its words. -type Answer struct { - V int `json:"v"` - Option string `json:"option"` - Warrant string `json:"warrant"` - Words string `json:"words"` -} - -func (a Answer) check() error { - for _, f := range []struct{ name, value string }{{"option", a.Option}, {"warrant", a.Warrant}, {"words", a.Words}} { - if f.value == "" { - return fmt.Errorf("delivery: an answer has no %s", f.name) - } - } - return nil -} - -// Owed is the value of an owed key: what the forge bridge performs. A status needs Context, State and Target (the -// address the status points at), a view and a comment need Text, a note needs Text and Ref (the notes ref, -// mesh-plan). -type Owed struct { - V int `json:"v"` - Act string `json:"act"` - Context string `json:"context,omitempty"` - Target string `json:"target,omitempty"` - State string `json:"state,omitempty"` - Text string `json:"text,omitempty"` - Ref string `json:"ref,omitempty"` -} - -func (o Owed) check() error { - switch o.Act { - case OwedStatus: - if o.Context == "" { - return fmt.Errorf("delivery: an owed status has no context") - } - if err := checkStatusContext(o.Context); err != nil { - return err - } - if o.Target == "" { - return fmt.Errorf("delivery: an owed status has no target") - } - for _, s := range statusStates { - if o.State == s { - return nil - } - } - return fmt.Errorf("delivery: the owed status state %q is not %s", o.State, strings.Join(statusStates, ", ")) - case OwedView, OwedComment: - if o.Text == "" { - return fmt.Errorf("delivery: an owed %s has no text", o.Act) - } - case OwedNote: - if o.Text == "" { - return fmt.Errorf("delivery: an owed note has no text") - } - if o.Ref == "" { - return fmt.Errorf("delivery: an owed note has no ref") - } - default: - return fmt.Errorf("delivery: the owed act %q is not status, view, note or comment", o.Act) - } - return nil -} - -// EncodeMerge is m as JSON at Version; it refuses a merged pull request without its merge commit, a refused one -// without the forge's words and a state outside the list. -func EncodeMerge(m Merge) ([]byte, error) { - if err := m.check(); err != nil { - return nil, err - } - m.V = Version - return json.Marshal(m) -} - -// DecodeMerge reads a merge; it refuses an unknown version (ErrUnknownVersion) and what EncodeMerge refuses. -func DecodeMerge(data []byte) (Merge, error) { - var m Merge - if err := decode(data, &m); err != nil { - return Merge{}, err - } - if err := m.check(); err != nil { - return Merge{}, err - } - return m, nil -} - -// EncodeAsk is a as JSON at Version; it refuses an ask whose id is not the one its group and heads give, or with -// no option. -func EncodeAsk(a Ask) ([]byte, error) { - if err := a.check(); err != nil { - return nil, err - } - a.V = Version - return json.Marshal(a) -} - -// DecodeAsk reads an ask; it refuses an unknown version (ErrUnknownVersion) and what EncodeAsk refuses. -func DecodeAsk(data []byte) (Ask, error) { - var a Ask - if err := decode(data, &a); err != nil { - return Ask{}, err - } - if err := a.check(); err != nil { - return Ask{}, err - } - return a, nil -} - -// EncodeAnswer is a as JSON at Version; it refuses an answer missing its option, warrant or words. -func EncodeAnswer(a Answer) ([]byte, error) { - if err := a.check(); err != nil { - return nil, err - } - a.V = Version - return json.Marshal(a) -} - -// DecodeAnswer reads an answer; it refuses an unknown version (ErrUnknownVersion) and what EncodeAnswer refuses. -func DecodeAnswer(data []byte) (Answer, error) { - var a Answer - if err := decode(data, &a); err != nil { - return Answer{}, err - } - if err := a.check(); err != nil { - return Answer{}, err - } - return a, nil -} - -// EncodeOwed is o as JSON at Version; it refuses an act missing what it needs. -func EncodeOwed(o Owed) ([]byte, error) { - if err := o.check(); err != nil { - return nil, err - } - o.V = Version - return json.Marshal(o) -} - -// DecodeOwed reads an owed act; it refuses an unknown version (ErrUnknownVersion) and what EncodeOwed refuses. -func DecodeOwed(data []byte) (Owed, error) { - var o Owed - if err := decode(data, &o); err != nil { - return Owed{}, err - } - if err := o.check(); err != nil { - return Owed{}, err - } - return o, nil -} - -// SameMerge says whether two merges are the same value, so a writer writes only when they differ. -func SameMerge(a, b Merge) bool { return reflect.DeepEqual(a, b) } - -// SameAsk says whether two asks are the same value. -func SameAsk(a, b Ask) bool { return reflect.DeepEqual(a, b) } - -// SameAnswer says whether two answers are the same value. -func SameAnswer(a, b Answer) bool { return a == b } - -// SameOwed says whether two owed acts are the same value. -func SameOwed(a, b Owed) bool { return a == b } diff --git a/go/delivery/gate_test.go b/go/delivery/gate_test.go deleted file mode 100644 index 4cb2c40..0000000 --- a/go/delivery/gate_test.go +++ /dev/null @@ -1,648 +0,0 @@ -package delivery - -import ( - "encoding/json" - "errors" - "regexp" - "strconv" - "strings" - "testing" - "time" -) - -func gateHeads() []Head { - return []Head{ - {Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"}, - {Repository: "novox/mesh-control", Number: 7, Head: "bbbb"}, - } -} - -func TestMergeKeys(t *testing.T) { // 1201/AC1 - if k, err := MergesKey("feat/x"); err != nil || k != "feat/x" { - t.Fatalf("merges key = %q, %v", k, err) - } - if _, err := MergesKey(""); err == nil { - t.Error("an empty group accepted") - } - hh, _ := HeadsHash(gateHeads()) - key, err := MergeAskKey("feat/x", gateHeads()) - if err != nil || key != "merge.feat/x."+hh { - t.Fatalf("ask key = %q, %v", key, err) - } - kind, group, hash, err := ParseAskKey(key) - if err != nil || kind != AskKindMerge || group != "feat/x" || hash != hh { - t.Fatalf("parse ask key = %q %q %q %v", kind, group, hash, err) - } - if AnswerKey(key) != key { - t.Error("the answer's key is not the ask's key") - } - if _, err := MergeAskKey("feat/x", nil); err == nil { - t.Error("an ask key without heads accepted") - } - if _, err := MergeAskKey("", gateHeads()); err == nil { - t.Error("an ask key without a group accepted") - } - for _, bad := range []string{"", "merge", "merge.g", "merge.g.h.x", "other.g.0123456789ab", "merge..0123456789ab", "merge.g.nothex", "merge.a=zz.0123456789ab", "merge.a=4.0123456789ab", "merge.a=4A.0123456789ab", "merge.a+b.0123456789ab"} { - if _, _, _, err := ParseAskKey(bad); err == nil { - t.Errorf("parse ask key %q accepted", bad) - } - } -} - -func TestAskID(t *testing.T) { // 1201/AC2 - k1, _ := MergeAskKey("feat/x", gateHeads()) - k2, _ := MergeAskKey("feat/y", gateHeads()) - k3, _ := MergeAskKey("feat/x", gateHeads()[:1]) - long, _ := MergeAskKey("feat/"+strings.Repeat("long-", 20), gateHeads()) - long2, _ := MergeAskKey("feat/"+strings.Repeat("long-", 20)+"2", gateHeads()) - slash, _ := MergeAskKey("a/b", gateHeads()) - under, _ := MergeAskKey("a_b", gateHeads()) - eq, _ := MergeAskKey("feat=x", gateHeads()) - eqs, _ := MergeAskKey("feat_x", gateHeads()) - seen := map[string]string{} - for _, k := range []string{k1, k2, k3, long, long2, slash, under, eq, eqs, "=odd.key"} { - id := AskID(k) - if strings.Contains(id, ".") { - t.Errorf("id %q holds a dot", id) - } - if !routerUsableID(id) { - t.Errorf("id %q is not usable by the router", id) - } - if other, dup := seen[id]; dup { - t.Errorf("keys %q and %q give the id %q", k, other, id) - } - seen[id] = k - } - if AskID(k1) != AskID(k1) { - t.Error("the id is not stable") - } - // 1280 (ADR 0556 clause 5): a settle ask's group is the same for the same ids in any order, keys like any group, and its - // options bind mesh-delivery.settle; every other ask binds mesh-delivery.merge. - g := SettleGroup([]string{"novox/b@222222222222", "novox/a@111111111111"}) - if g != SettleGroup([]string{"novox/a@111111111111", "novox/b@222222222222"}) || !strings.HasPrefix(g, SettleGroupPrefix) || len(g) != len(SettleGroupPrefix)+12 { - t.Errorf("SettleGroup = %q", g) - } - if k, err := MergeAskKey(g, gateHeads()); err != nil || !routerUsableID(AskID(k)) { - t.Errorf("the settle ask's key %q: %v", k, err) - } - for group, want := range map[string]string{g: "mesh-delivery.settle", "feat/x": "mesh-delivery.merge", - "settle/foo": "mesh-delivery.merge", "settle": "mesh-delivery.merge"} { // a branch named settle/... is a merge's group - if got := AskVerb(group); got != want { - t.Errorf("AskVerb(%q) = %q, want %q", group, got, want) - } - } -} - -func TestOwedKey(t *testing.T) { // 1201/AC3 - for _, act := range []string{"status=mesh/merge-gate", "view", "note", "comment.1", "comment.12"} { - key, err := OwedKey("novox/mesh-sdk", 36, "aaaa", act) - if err != nil || key != "novox/mesh-sdk.36.aaaa."+act { - t.Fatalf("owed key for %s = %q, %v", act, key, err) - } - repo, number, head, gotAct, err := ParseOwedKey(key) - if err != nil || repo != "novox/mesh-sdk" || number != 36 || head != "aaaa" || gotAct != act { - t.Fatalf("parse %q = %q %d %q %q %v", key, repo, number, head, gotAct, err) - } - } - kind, context, err := ParseOwedAct("status=mesh/merge-gate") - if err != nil || kind != OwedStatus || context != "mesh/merge-gate" { - t.Errorf("owed act = %q %q %v", kind, context, err) - } - for _, bad := range []string{"", "merge", "status", "status=", "view=x", "status=a.b", "statuses=x", "comment", "comment.", "comment.0", "comment.-1", "comment.01", "comment.x", "comment=1"} { - if _, err := OwedKey("novox/mesh-sdk", 36, "aaaa", bad); err == nil { - t.Errorf("owed act %q accepted", bad) - } - } - if _, err := OwedKey("nope", 36, "aaaa", "view"); err == nil { - t.Error("a bad repository accepted") - } - if _, err := OwedKey("novox/mesh-sdk", -1, "aaaa", "view"); err == nil { - t.Error("a negative pull request number accepted") - } - if _, err := OwedKey("novox/mesh-sdk", 36, "", "view"); err == nil { - t.Error("an empty head accepted") - } - for _, bad := range []string{"", "novox/mesh-sdk.36.aaaa", "novox/mesh-sdk.x.aaaa.view", "novox/mesh-sdk.-1.aaaa.view", "novox/mesh-sdk.36.aaaa.comment", "novox/mesh-sdk.36.aaaa.merge", "a.b.36.aaaa.view"} { - if _, _, _, _, err := ParseOwedKey(bad); err == nil { - t.Errorf("parse owed key %q accepted", bad) - } - } -} - -func sampleMerge() Merge { - return Merge{Group: "feat/x", Ask: "merge.feat/x.0123456789ab", Pulls: []MergePull{ - {Head: gateHeads()[0], State: MergeMerged, Commit: "cccc"}, - {Head: gateHeads()[1], State: MergeRefused, Words: "the forge says no"}, - {Head: Head{Repository: "novox/other", Number: 1, Head: "dddd"}, State: MergeMerging}, - }} -} - -func sampleTurn() Turn { - at := time.Date(2026, 10, 26, 9, 0, 0, 0, time.UTC) - return Turn{Repository: "novox/mesh-sdk", Waiting: []TurnWait{ - {Group: "feat/x", Heads: mustHash(gateHeads()), Ask: "merge.feat/x." + mustHash(gateHeads()), Approved: at, Since: at, - WaitsFor: "feat/y.0123456789ab", WaitsSince: at.Add(-time.Minute), Step: "the check ask of novox/mesh-sdk#40"}, - {Group: "fix/1.2", Heads: "0123456789ac", Approved: at.Add(time.Minute), Since: at.Add(time.Minute)}, - }} -} - -func sampleAsk() Ask { - key, _ := MergeAskKey("feat/x", gateHeads()) - return Ask{Group: "feat/x", Heads: gateHeads(), Ask: routerAskJSON(AskID(key), "Merge into main: group feat/x")} -} - -// routerAskJSON is a router's ask as the gate carries it: the operator-channel seat's JSON, which mesh-sdk does not -// type (novox/hq ADR 0627 §4). -func routerAskJSON(id, headline string) json.RawMessage { - raw, _ := json.Marshal(map[string]any{"id": id, "headline": headline, "explanation": "The group passed its checks.", - "who": "operator", "expires": "2026-10-20T08:00:00Z", "on-expiry": "nothing is merged", - "options": []map[string]string{{"id": "approve", "label": "Approve", "does": "merges", "level": "approve", "binds": "sha256:00"}}}) - return raw -} - -// routerUsableID is the router's rule for an ask's id (operator-channel's client, UsableID), held here to check that -// AskID and AskingID give ids the router takes. -var routerUsableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`).MatchString - -func sampleOwed() []Owed { - return []Owed{ - {Act: OwedStatus, Context: "mesh/merge-gate", State: "success", Target: "https://mesh.example/delivery/36"}, - {Act: OwedView, Text: "the delivery reaches ace"}, - {Act: OwedNote, Text: "plan: mesh-mail to ace", Ref: "mesh-plan"}, - {Act: OwedComment, Text: "merged"}, - } -} - -func TestGateValuesRoundTrip(t *testing.T) { // 1201/AC4 - hasV := func(name string, data []byte, err error) { - t.Helper() - if err != nil { - t.Fatalf("%s: %v", name, err) - } - var probe map[string]any - if json.Unmarshal(data, &probe) != nil || probe["v"] != float64(1) { - t.Fatalf("%s: %s does not carry v:1", name, data) - } - } - m := sampleMerge() - data, err := EncodeMerge(m) - hasV("merge", data, err) - m.V = Version - if got, err := DecodeMerge(data); err != nil || !SameMerge(got, m) { - t.Errorf("merge round trip: %+v %v", got, err) - } - a := sampleAsk() - data, err = EncodeAsk(a) - hasV("ask", data, err) - a.V = Version - if got, err := DecodeAsk(data); err != nil || !SameAsk(got, a) { - t.Errorf("ask round trip: %+v %v", got, err) - } - an := Answer{Option: "approve", Warrant: "w-1", Words: "approved on telegram"} - data, err = EncodeAnswer(an) - hasV("answer", data, err) - an.V = Version - if got, err := DecodeAnswer(data); err != nil || !SameAnswer(got, an) { - t.Errorf("answer round trip: %+v %v", got, err) - } - for _, o := range sampleOwed() { - data, err = EncodeOwed(o) - hasV("owed "+o.Act, data, err) - o.V = Version - if got, err := DecodeOwed(data); err != nil || !SameOwed(got, o) { - t.Errorf("owed %s round trip: %+v %v", o.Act, got, err) - } - } - tu := sampleTurn() - data, err = EncodeTurn(tu) - hasV("turn", data, err) - tu.V = Version - if got, err := DecodeTurn(data); err != nil || !SameTurn(got, tu) { - t.Errorf("turn round trip: %+v %v", got, err) - } - for _, field := range []string{`"waits-for":"feat/y.0123456789ab"`, `"waits-since":`, `"approved":`, `"step":`} { - if !strings.Contains(string(data), field) { - t.Errorf("the turn lacks %s: %s", field, data) - } - } - // ADR 0548 §3: a turn holds off every check but those of the heads that wait in it, whoever else waits there. - if !TurnHolds(tu, "fedcba987654") || TurnHolds(tu, tu.Waiting[0].Heads) || TurnHolds(tu, tu.Waiting[1].Heads) || TurnHolds(Turn{}, "fedcba987654") { - t.Error("TurnHolds: a turn holds other heads' checks and never the check of heads waiting in it") - } - for name, dec := range map[string]func([]byte) error{ - "turn": func(b []byte) error { _, err := DecodeTurn(b); return err }, - "merge": func(b []byte) error { _, err := DecodeMerge(b); return err }, - "ask": func(b []byte) error { _, err := DecodeAsk(b); return err }, - "answer": func(b []byte) error { _, err := DecodeAnswer(b); return err }, - "owed": func(b []byte) error { _, err := DecodeOwed(b); return err }, - } { - for _, in := range []string{`{"group":"g"}`, `{"v":2}`} { - if err := dec([]byte(in)); !errors.Is(err, ErrUnknownVersion) { - t.Errorf("%s decode of %s = %v, want ErrUnknownVersion", name, in, err) - } - } - } -} - -func TestGateValuesRefused(t *testing.T) { // 1201/AC5 - wantErr := func(name string, err error, words ...string) { - t.Helper() - if err == nil { - t.Errorf("%s accepted", name) - return - } - for _, w := range words { - if !strings.Contains(err.Error(), w) { - t.Errorf("%s: error %q does not name %q", name, err, w) - } - } - } - m := sampleMerge() - m.Pulls[0].Commit = "" - _, err := EncodeMerge(m) - wantErr("merged without a merge commit", err, "commit") - m = sampleMerge() - m.Pulls[1].Words = "" - _, err = EncodeMerge(m) - wantErr("refused without words", err, "words") - m = sampleMerge() - m.Pulls[2].State = "done" - _, err = EncodeMerge(m) - wantErr("unknown state", err, "done") - m = sampleMerge() - m.Pulls = nil - _, err = EncodeMerge(m) - wantErr("merge without pulls", err, "pull") - m = sampleMerge() - m.Ask = "" - _, err = EncodeMerge(m) - wantErr("merge without its ask", err, "ask") - - a := sampleAsk() - for _, raw := range []string{"", "null", "[]", `"ask"`} { - a.Ask = json.RawMessage(raw) - _, err = EncodeAsk(a) - wantErr("a value with no router's ask "+raw, err, "no router's ask") - } - a = sampleAsk() - a.Heads = nil - _, err = EncodeAsk(a) - wantErr("ask without heads", err, "pull request") - - for name, an := range map[string]Answer{ - "option": {Warrant: "w", Words: "x"}, - "warrant": {Option: "approve", Words: "x"}, - "words": {Option: "approve", Warrant: "w"}, - } { - _, err = EncodeAnswer(an) - wantErr("answer without "+name, err, name) - } - - for name, o := range map[string]Owed{ - "context": {Act: OwedStatus, State: "success", Target: "t"}, - "state": {Act: OwedStatus, Context: "mesh/merge-gate", State: "green", Target: "t"}, - "target": {Act: OwedStatus, Context: "mesh/merge-gate", State: "success"}, - "text": {Act: OwedView}, - "ref": {Act: OwedNote, Text: "line"}, - "comment": {Act: OwedComment}, - "act": {Act: "merge"}, - } { - _, err = EncodeOwed(o) - wantErr("owed missing "+name, err) - } - _, err = EncodeOwed(Owed{Act: OwedNote, Text: "line"}) - wantErr("note without ref", err, "ref") - - for name, c := range map[string]struct { - edit func(*Turn) - words []string - }{ - "no delivery": {func(t *Turn) { t.Waiting = nil }, []string{"no delivery", "deleted"}}, - "a repository in capitals": {func(t *Turn) { t.Repository = "novox/Mesh-SDK" }, []string{"lowercase"}}, - "no repository": {func(t *Turn) { t.Repository = "" }, []string{"repository"}}, - "no group": {func(t *Turn) { t.Waiting[0].Group = "" }, []string{"group"}}, - "a bad heads hash": {func(t *Turn) { t.Waiting[0].Heads = "xyz" }, []string{"xyz"}}, - "the same heads twice": {func(t *Turn) { t.Waiting[1].Heads = t.Waiting[0].Heads }, []string{"twice"}}, - "no Approve time": {func(t *Turn) { t.Waiting[1].Approved = time.Time{} }, []string{"Approve"}}, - "out of the Approve's order": {func(t *Turn) { t.Waiting[1].Approved = t.Waiting[0].Approved.Add(-time.Hour) }, []string{"order"}}, - "waits for no verdict key": {func(t *Turn) { t.Waiting[0].WaitsFor = "feat/y" }, []string{"verdict key"}}, - "a step and no check": {func(t *Turn) { t.Waiting[1].Step = "x" }, []string{"names no check"}}, - } { - tu := sampleTurn() - c.edit(&tu) - _, err = EncodeTurn(tu) - wantErr("turn with "+name, err, c.words...) - } - _, err = EncodeOwed(Owed{Act: OwedStatus, State: "success"}) - wantErr("status without context", err, "context") -} - -func mustHash(h []Head) string { s, _ := HeadsHash(h); return s } - -func TestGateSame(t *testing.T) { // 1201/AC6 - if !SameMerge(sampleMerge(), sampleMerge()) || !SameAsk(sampleAsk(), sampleAsk()) { - t.Error("equal values differ") - } - m := sampleMerge() - m.Pulls[0].Commit = "other" - if SameMerge(m, sampleMerge()) { - t.Error("different merges are the same") - } - a := sampleAsk() - a.Ask = routerAskJSON(AskID("merge.feat/x."+mustHash(gateHeads())), "other") - if SameAsk(a, sampleAsk()) { - t.Error("different asks are the same") - } - if !SameAnswer(Answer{Option: "a", Warrant: "w", Words: "x"}, Answer{Option: "a", Warrant: "w", Words: "x"}) || - SameAnswer(Answer{Option: "a", Warrant: "w", Words: "x"}, Answer{Option: "b", Warrant: "w", Words: "x"}) { - t.Error("SameAnswer wrong") - } - if !SameOwed(sampleOwed()[0], sampleOwed()[0]) || SameOwed(sampleOwed()[0], sampleOwed()[1]) { - t.Error("SameOwed wrong") - } -} - -// today's keys: names of letters, digits, - / and _ key exactly as before the escape. -func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2 - // And the buckets and their history are the decided layout (once TestGateBuckets; merged to pay for issue 774's - // token test). - if MergesBucket != "mesh-delivery-gate_merges" || AsksBucket != "mesh-delivery-gate_asks" || - OwedBucket != "mesh-delivery-gate_owed" || AnswersBucket != "mesh-delivery-teller_answers" || - MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 || - TurnsBucket != "mesh-delivery-gate_turns" || TurnsHistory != 5 { - t.Error("a bucket or history differs from the decided layout") - } - hh, _ := HeadsHash(gateHeads()) - for _, name := range []string{"feat/x", "fix/1323-escape", "feat/delivery_source-keys", "main", "A/B_c-9"} { - if k, err := MergesKey(name); err != nil || k != name { - t.Errorf("merges key %q = %q, %v", name, k, err) - } - if k, err := CandidatesKey(name); err != nil || k != name { - t.Errorf("candidates key %q = %q, %v", name, k, err) - } - if k, err := MergeAskKey(name, gateHeads()); err != nil || k != "merge."+name+"."+hh { - t.Errorf("ask key %q = %q, %v", name, k, err) - } - } - if k, err := OwedKey("novox/mesh-sdk", 36, "aaaa", "status=mesh/merge-gate"); err != nil || k != "novox/mesh-sdk.36.aaaa.status=mesh/merge-gate" { - t.Errorf("owed key = %q, %v", k, err) - } -} - -func TestKeysEscapeAndRoundTrip(t *testing.T) { // 1323/AC1 - hh, _ := HeadsHash(gateHeads()) - for _, name := range []string{"fix/1.2", "feat/a+b", "feat/a=b", "a.b+c=d", "fix/ü", "x y"} { - esc := EscapeName(name) - if strings.ContainsAny(esc, ".+ ") { - t.Errorf("escape of %q = %q holds a dot, plus or space", name, esc) - } - if back, err := UnescapeName(esc); err != nil || back != name { - t.Errorf("unescape(%q) = %q, %v; want %q", esc, back, err, name) - } - if k, err := MergesKey(name); err != nil || k != esc { - t.Errorf("merges key %q = %q, %v", name, k, err) - } - ck, err := CandidatesKey(name) - if err != nil { - t.Fatal(err) - } - if g, err := ParseCandidatesKey(ck); err != nil || g != name { - t.Errorf("candidates round trip %q = %q, %v", name, g, err) - } - ak, err := MergeAskKey(name, gateHeads()) - if err != nil { - t.Fatal(err) - } - if _, g, h, err := ParseAskKey(ak); err != nil || g != name || h != hh { - t.Errorf("ask round trip %q = %q %q %v", name, g, h, err) - } - if AnswerKey(ak) != ak { - t.Error("answer key differs from the ask key") - } - // the ask and the answer of such a group encode and decode - ask := Ask{Group: name, Heads: gateHeads(), Ask: routerAskJSON(AskID(ak), "ok")} - if data, err := EncodeAsk(ask); err != nil { - t.Errorf("encode ask for %q: %v", name, err) - } else if _, err := DecodeAsk(data); err != nil { - t.Errorf("decode ask for %q: %v", name, err) - } - repo := "novox/" + name[strings.Index(name, "/")+1:] - if !strings.Contains(name, "/") { - repo = "novox/" + name - } - ok, err := OwedKey(repo, 3, "aaaa", "view") - if err != nil { - t.Errorf("owed key for repository %q: %v", repo, err) - continue - } - if r, n, h, a, err := ParseOwedKey(ok); err != nil || r != repo || n != 3 || h != "aaaa" || a != "view" { - t.Errorf("owed round trip %q = %q %d %q %q %v", repo, r, n, h, a, err) - } - // ADR 0548 §3: a repository's turn key is ., lowercase, each part escaped. - tk, err := TurnsKey(repo) - if err != nil || strings.Count(tk, ".") != 1 { - t.Errorf("turns key for repository %q = %q, %v", repo, tk, err) - } else if r, err := ParseTurnsKey(tk); err != nil || r != strings.ToLower(repo) { - t.Errorf("turns round trip %q = %q, %v", repo, r, err) - } - } - if k, err := TurnsKey("novox/Mesh-SDK"); err != nil || k != "novox.mesh-sdk" { - t.Errorf("turns key = %q, %v; want novox.mesh-sdk", k, err) - } - for _, bad := range []string{"", "novox", "novox.", ".x", "a.b.c", "Novox.mesh-sdk", "novox.a=zz"} { - if _, err := ParseTurnsKey(bad); err == nil { - t.Errorf("parse turns key %q accepted", bad) - } - } - if EscapeName("a.b") != "a=2eb" || EscapeName("a=b") != "a=3db" || EscapeName("a+b") != "a=2bb" { - t.Errorf("escapes: %q %q %q", EscapeName("a.b"), EscapeName("a=b"), EscapeName("a+b")) - } - m := Merge{Group: "fix/1.2+x=y", Ask: "merge.x.0123456789ab", Pulls: []MergePull{{Head: gateHeads()[0], State: MergeMerging}}} - if data, err := EncodeMerge(m); err != nil { - t.Errorf("encode merge: %v", err) - } else if back, err := DecodeMerge(data); err != nil || back.Group != m.Group { - t.Errorf("decode merge: %v %v", back.Group, err) - } -} - -func approvedMerge() Merge { - m := sampleMerge() - m.Approved = map[string]string{ - ApprovedKey("novox/mesh-sdk", 36): "aaaa", - ApprovedKey("novox/mesh-control", 7): "bbbb", - ApprovedKey("novox/other", 1): "dddd", - } - return m -} - -func TestMergeCarriesApprovedHeads(t *testing.T) { // 1321/AC1 - m := approvedMerge() - m.Pulls = m.Pulls[:1] // two members approved besides the one handed so far - m.Pulls[0].State = MergeMerging - m.Pulls[0].Commit = "" - data, err := EncodeMerge(m) - if err != nil { - t.Fatal(err) - } - back, err := DecodeMerge(data) - if err != nil { - t.Fatal(err) - } - m.V = Version - if !SameMerge(back, m) || len(back.Approved) != 3 || back.Approved["novox/mesh-control@7"] != "bbbb" { - t.Errorf("decoded %+v, want %+v", back, m) - } -} - -func TestMergeRefusesAPullNotApproved(t *testing.T) { // 1321/AC2 - m := approvedMerge() - delete(m.Approved, ApprovedKey("novox/other", 1)) - if _, err := EncodeMerge(m); err == nil || !strings.Contains(err.Error(), "novox/other#1") { - t.Errorf("a handed pull missing from the approved heads: %v", err) - } - m = approvedMerge() - m.Approved[ApprovedKey("novox/other", 1)] = "eeee" - if _, err := EncodeMerge(m); err == nil { - t.Error("a handed pull at another head than the Approve's accepted") - } - m = approvedMerge() - m.Approved[ApprovedKey("novox/other", 1)] = "" - if _, err := EncodeMerge(m); err == nil { - t.Error("an empty approved head accepted") - } - data, _ := json.Marshal(approvedMerge()) - var raw map[string]any - _ = json.Unmarshal(data, &raw) - raw["v"] = Version - raw["approved"] = map[string]string{"novox/mesh-sdk@36": "aaaa"} - bad, _ := json.Marshal(raw) - if _, err := DecodeMerge(bad); err == nil { - t.Error("decoding a merge whose pulls are not all approved accepted") - } -} - -func TestMergeWithoutApprovedHeadsDecodes(t *testing.T) { // 1321/AC3 - old := `{"v":` + strconv.Itoa(Version) + `,"group":"feat/x","ask":"merge.feat/x.0123456789ab","pulls":[{"repository":"novox/mesh-sdk","number":36,"head":"aaaa","state":"merging"}]}` - m, err := DecodeMerge([]byte(old)) - if err != nil || len(m.Approved) != 0 || len(m.Pulls) != 1 { - t.Fatalf("old merge = %+v, %v", m, err) - } - data, err := EncodeMerge(sampleMerge()) - if err != nil || strings.Contains(string(data), "approved") { - t.Errorf("a merge without approved heads wrote them: %s %v", data, err) - } -} - -func TestOwedStatusCarriesItsTarget(t *testing.T) { // 1322/AC1 - o := Owed{Act: OwedStatus, Context: "mesh/delivery", State: "success", Target: "https://mesh.example/delivery/36"} - data, err := EncodeOwed(o) - if err != nil { - t.Fatal(err) - } - back, err := DecodeOwed(data) - o.V = Version - if err != nil || !SameOwed(back, o) || back.Target != o.Target { - t.Errorf("decoded %+v, %v; want %+v", back, err, o) - } - if _, err := EncodeOwed(Owed{Act: OwedStatus, Context: "mesh/delivery", State: "success"}); err == nil || !strings.Contains(err.Error(), "target") { - t.Errorf("a status without a target: %v", err) - } -} - -func TestOwedKeyNumberZero(t *testing.T) { // 1322/AC2 - key, err := OwedKey("novox/mesh-sdk", 0, "aaaa", "status=mesh/delivery") - if err != nil || key != "novox/mesh-sdk.0.aaaa.status=mesh/delivery" { - t.Fatalf("owed key for number 0 = %q, %v", key, err) - } - repo, number, head, act, err := ParseOwedKey(key) - if err != nil || repo != "novox/mesh-sdk" || number != 0 || head != "aaaa" || act != "status=mesh/delivery" { - t.Errorf("parse = %q %d %q %q %v", repo, number, head, act, err) - } - if _, err := OwedKey("novox/mesh-sdk", -1, "aaaa", "view"); err == nil { - t.Error("a negative number accepted by OwedKey") - } - if _, _, _, _, err := ParseOwedKey("novox/mesh-sdk.-1.aaaa.view"); err == nil { - t.Error("a negative number accepted by ParseOwedKey") - } -} - -func TestOwedCommentsHaveASequence(t *testing.T) { // 1322/AC3 - a1, err := OwedCommentAct(1) - a2, err2 := OwedCommentAct(2) - if err != nil || err2 != nil || a1 != "comment.1" || a2 != "comment.2" { - t.Fatalf("acts = %q %q %v %v", a1, a2, err, err2) - } - if _, err := OwedCommentAct(0); err == nil { - t.Error("comment sequence 0 accepted") - } - k1, err := OwedKey("novox/mesh-sdk", 36, "aaaa", a1) - k2, err2 := OwedKey("novox/mesh-sdk", 36, "aaaa", a2) - if err != nil || err2 != nil || k1 == k2 { - t.Fatalf("keys = %q %q %v %v", k1, k2, err, err2) - } - for key, seq := range map[string]string{k1: "1", k2: "2"} { - _, _, _, act, err := ParseOwedKey(key) - if err != nil { - t.Fatal(err) - } - kind, got, err := ParseOwedAct(act) - if err != nil || kind != OwedComment || got != seq { - t.Errorf("parse %q = %q %q %v; want comment %s", key, kind, got, err, seq) - } - } -} - -func TestAskingID(t *testing.T) { // 1370/AC1 - k, _ := MergeAskKey("feat/x", gateHeads()) - long, _ := MergeAskKey("feat/"+strings.Repeat("long-", 20), gateHeads()) - for _, key := range []string{k, long} { - if AskingID(key, 1) != AskID(key) || AskingID(key, 0) != AskID(key) { - t.Errorf("the first asking's id is not AskID: %q %q %q", AskingID(key, 0), AskingID(key, 1), AskID(key)) - } - seen := map[string]int{AskID(key): 1} - for n := 2; n <= 12; n++ { - id := AskingID(key, n) - if other, dup := seen[id]; dup { - t.Errorf("askings %d and %d give the id %q", n, other, id) - } - seen[id] = n - if len(id) > 64 || !routerUsableID(id) { - t.Errorf("id %q of asking %d is not usable by the router", id, n) - } - if id != AskID(key+"."+strconv.Itoa(n)) { - t.Errorf("id of asking %d is not the AskID form of key.%d: %q", n, n, id) - } - } - } -} - -func TestAskChecksItsAsking(t *testing.T) { // 1370/AC2 - key, _ := MergeAskKey("feat/x", gateHeads()) - a := sampleAsk() - if _, err := EncodeAsk(a); err != nil { - t.Fatalf("asking 0: %v", err) - } - a.Asking = 1 - if _, err := EncodeAsk(a); err != nil { - t.Fatalf("asking 1 under the first id: %v", err) - } - a.Asking = 3 - a.Ask = routerAskJSON(AskingID(key, 3), "Merge into main: group feat/x") - data, err := EncodeAsk(a) - if err != nil { - t.Fatalf("asking 3 under its id: %v", err) - } - a.V = Version - if got, err := DecodeAsk(data); err != nil || !SameAsk(got, a) || got.Asking != 3 { - t.Errorf("asking round trip: %+v %v", got, err) - } - a.Asking = -1 - if _, err := EncodeAsk(a); err == nil { - t.Error("a negative asking was accepted") - } - // the first asking is written without the field - data, _ = EncodeAsk(sampleAsk()) - if strings.Contains(string(data), `"asking"`) { - t.Errorf("asking 0 is written: %s", data) - } -} diff --git a/go/delivery/grants.go b/go/delivery/grants.go deleted file mode 100644 index 3ed03e6..0000000 --- a/go/delivery/grants.go +++ /dev/null @@ -1,95 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "reflect" - "strings" -) - -// **The grants issued** (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, ADR 0547 §4, §5, ADR 0590): when the grants -// part has issued a module's bus credentials for a fingerprint, it writes `.` in its state -// `grants` (PartGrantsBucket). The assigner reads it: a node's want moves to a fingerprint only once its grant exists. -// Only the grants part writes the bucket. -const ( - // PartGrantsState is the grants part's state of issued credentials by fingerprint, as its manifest names it. - PartGrantsState = "grants" - // PartGrantsBucket is that state's bucket, `_`. - PartGrantsBucket = "mesh-grants_" + PartGrantsState - // PartGrantsRead is that state as a reader names it in `reads`. - PartGrantsRead = "mesh-grants." + PartGrantsState - // PartGrantsHistory is the revisions the bucket keeps per key. - PartGrantsHistory = 5 -) - -// PartGrantKey is the key, in PartGrantsBucket, of a module's credential issued for a fingerprint: `.`, -// the hex being the fingerprint's key part (FingerprintKeyPart). -func PartGrantKey(module, fingerprint string) (string, error) { - return BuilderBuildKey(module, fingerprint) -} - -// ParsePartGrantKey is the inverse of PartGrantKey; it refuses a want or held key, naming it. -func ParsePartGrantKey(key string) (module, fingerprint string, err error) { - if module, fingerprint, err = ParseBuilderBuildKey(key); err != nil { - return "", "", fmt.Errorf("delivery: %q is not a grant key, .: %w", key, err) - } - return module, fingerprint, nil -} - -// Grant is the value of a grant key: the bus accounts (their public keys) the module's processes were issued -// for that fingerprint, none for a module whose grants the tool runner's account carries, so a reissue for another -// account is a new revision. The entry's own time says when it was issued. -type Grant struct { - V int `json:"v"` - Accounts []string `json:"accounts,omitempty"` -} - -func (g Grant) check() error { - for _, a := range g.Accounts { - if strings.TrimSpace(a) == "" || strings.ContainsAny(a, " \t\n") { - return fmt.Errorf("delivery: a grant names an empty or spaced account %q", a) - } - } - return nil -} - -// EncodeGrant is g as JSON at Version; it refuses an empty or spaced account. -func EncodeGrant(g Grant) ([]byte, error) { - if err := g.check(); err != nil { - return nil, err - } - g.V = Version - return json.Marshal(g) -} - -// DecodeGrant reads a grant; it refuses an unknown version (ErrUnknownVersion) and an empty or spaced account. -func DecodeGrant(data []byte) (Grant, error) { - var g Grant - if err := decode(data, &g); err != nil { - return Grant{}, err - } - if err := g.check(); err != nil { - return Grant{}, err - } - return g, nil -} - -// SameGrant says whether two grants are the same value. -func SameGrant(a, b Grant) bool { return reflect.DeepEqual(a, b) } - -// NamesNode says whether the grant names module's accounts on node: its own `.` or a process's -// `..`. A grant that names no account (a module that does not speak on the bus) names every -// node. The assigner wants a node only once the grant names its account (issue 1945), and a put waits for it (ADR -// 0546 clause 4). -func (g Grant) NamesNode(node, module string) bool { - if len(g.Accounts) == 0 { - return true - } - own := node + "." + module - for _, a := range g.Accounts { - if a == own || strings.HasPrefix(a, own+".") { - return true - } - } - return false -} diff --git a/go/delivery/handed.go b/go/delivery/handed.go deleted file mode 100644 index af0bc3c..0000000 --- a/go/delivery/handed.go +++ /dev/null @@ -1,125 +0,0 @@ -package delivery - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "regexp" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/seal" -) - -// The adoption handing of novox/hq ADR 0547 clause 9 (issue 1945): the vault adopts, once and before `take-over`, the -// bcrypt hashes of the bus accounts it holds no value for (the controller's own, the node accounts, the enrolled -// devices) from the list the bus has loaded. The node-engine of the node holding mesh-broker is the one part that has -// that list, so it hands the hashes in the bus module's have, beside what it says of the list it wrote: sealed to the -// vault's own key, which it learns from the vault's answer to `node-key`, so that of every reader of the have bucket -// the vault alone can open them. The have says in the clear only how many users it hands, from which list and to which -// key; the users' names stay sealed, as the list itself is (ADR 0547 clause 7: it names every account). -// -// It hands every user of the loaded list; the vault takes from it only the users it holds no value for, and never -// replaces a hash it made from a value it holds, so the node-engine never needs to read the vault's state (which the -// grants part alone is to read, clause 9). - -// AccountsHanded is the hashes of the loaded list's users, sealed to the vault, in UserListWritten. -type AccountsHanded struct { - // From is the digest of the list on the machine the hashes are taken from, `sha256:` (UserListWritten.Digest). - From string `json:"from"` - // To is the fingerprint of the vault's key the box is sealed to (KeyFingerprint). - To string `json:"to"` - // Users is how many users the box holds. - Users int `json:"users"` - // Box is the JSON of []HandedAccount, sealed to the vault's key (ADR 0206). - Box seal.Box `json:"box"` - At time.Time `json:"at"` -} - -// HandedAccount is one user of an AccountsHanded box, opened: the bus user and the bcrypt hash the loaded list -// carries for it. -type HandedAccount struct { - User string `json:"user"` - Hash string `json:"hash"` -} - -// KeyFingerprint is a public key's fingerprint as a handing names it: `sha256:` of its PEM, without the space -// around it. -func KeyFingerprint(publicPEM string) string { - sum := sha256.Sum256([]byte(strings.TrimSpace(publicPEM))) - return "sha256:" + hex.EncodeToString(sum[:]) -} - -// listedAccount is one user of a bus user list as the composer writes it (buscompose), with its password's hash. -var listedAccount = regexp.MustCompile(`\{\s*user:\s*"([^"]+)",\s*password:\s*"([^"]+)"`) - -// HandedAccountsOf is every user of a bus user list that carries a bcrypt hash, in the list's order. A user listed -// twice is handed once, as the list first names it. -func HandedAccountsOf(list []byte) []HandedAccount { - var out []HandedAccount - seen := map[string]bool{} - for _, m := range listedAccount.FindAllSubmatch(list, -1) { - user, hash := string(m[1]), string(m[2]) - if seen[user] || !IsBcrypt(hash) { - continue - } - seen[user] = true - out = append(out, HandedAccount{User: user, Hash: hash}) - } - return out -} - -// bcryptHash is the form of a bcrypt hash: version, cost, and 53 characters of salt and digest. -var bcryptHash = regexp.MustCompile(`^\$2[abxy]?\$[0-9]{2}\$[./A-Za-z0-9]{53}$`) - -// IsBcrypt says whether s has the form of a bcrypt hash. -func IsBcrypt(s string) bool { return bcryptHash.MatchString(s) } - -// HandAccounts seals the accounts to the vault's public key (SPKI PEM) as the handing of the list whose digest is from. -func HandAccounts(accounts []HandedAccount, vaultPEM, from string, at time.Time) (AccountsHanded, error) { - if strings.TrimSpace(vaultPEM) == "" { - return AccountsHanded{}, errors.New("delivery: no vault key to seal the handed accounts to") - } - body, err := json.Marshal(accounts) - if err != nil { - return AccountsHanded{}, err - } - box, err := seal.Seal(string(body), vaultPEM) - if err != nil { - return AccountsHanded{}, fmt.Errorf("delivery: the handed accounts could not be sealed to the vault's key: %w", err) - } - return AccountsHanded{From: from, To: KeyFingerprint(vaultPEM), Users: len(accounts), Box: box, At: at.UTC()}, nil -} - -// OpenHanded opens a handing with the vault's private key (PEM), refusing one sealed to another key than publicPEM's -// and a box that does not hold as many users as it says. -func OpenHanded(h AccountsHanded, publicPEM, privatePEM string) ([]HandedAccount, error) { - if h.To != KeyFingerprint(publicPEM) { - return nil, fmt.Errorf("delivery: the handing is sealed to the key %s, not to this one (%s)", h.To, - KeyFingerprint(publicPEM)) - } - plain, err := seal.Open(h.Box, privatePEM) - if err != nil { - return nil, fmt.Errorf("delivery: the handing does not open: %w", err) - } - var out []HandedAccount - if err := json.Unmarshal([]byte(plain), &out); err != nil { - return nil, fmt.Errorf("delivery: the handing holds no accounts: %w", err) - } - if len(out) != h.Users { - return nil, fmt.Errorf("delivery: the handing says %d users and holds %d", h.Users, len(out)) - } - return out, nil -} - -// sameHanded says whether two handings say the same: the same list, key, count and box, whatever zone their time is in. -func sameHanded(a, b *AccountsHanded) bool { - if a == nil || b == nil { - return a == b - } - x, y := *a, *b - x.At, y.At = time.Time{}, time.Time{} - return x == y && a.At.Equal(b.At) -} diff --git a/go/delivery/held.go b/go/delivery/held.go deleted file mode 100644 index 3cd8711..0000000 --- a/go/delivery/held.go +++ /dev/null @@ -1,104 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "sort" - "time" -) - -// Whether the artifact store still holds what a done key names (novox/hq issue 1937). A build module pushes a bundle or -// an archive as a bare blob (ADR 0498 §1), and the store's nightly collector deletes every blob no manifest names. A done -// key can therefore name a blob that is gone, and reusing it sends a definition every machine fails on. So the builder -// asks before it reuses a done key, and the artifact sweep says when it found a gone blob. Both are the seat -// mesh-artifact-sweep's (novox/hq ADR 0571 §2, ADR 0590): the controller that served them first has ended. -const ( - // HeldVerb is the artifact sweep seat's verb the builder asks, as a module's invokes names it: references in, each - // answered held or gone. - HeldVerb = "seat:mesh-artifact-sweep.held" - // ArtifactsGoneEvent is the artifact sweep seat's event said when the store does not hold an artifact a key names: - // as a consumer names it, `mesh-artifact-sweep.artifacts-gone`. - ArtifactsGoneEvent = "artifacts-gone" - // ArtifactsGoneSubject is where it is published (the seat's event subject). - ArtifactsGoneSubject = "mesh.seat.mesh-artifact-sweep.event." + ArtifactsGoneEvent -) - -// HeldAnswer is the answer of HeldVerb: the references the store holds, and those it answered 404 for. A reference the -// store could not be asked about is in neither, and named in Unknown with why. -type HeldAnswer struct { - Store string `json:"store,omitempty"` - Held []string `json:"held"` - Gone []string `json:"gone"` - Unknown map[string]string `json:"unknown,omitempty"` -} - -// DecodeHeldAnswer reads HeldVerb's answer: the verb answers {output, ok, answer}, the answer being HeldAnswer. A -// reference asked that the answer names in none of held, gone and unknown is an error: an answer that leaves one out -// would let a key with a gone blob be reused. -func DecodeHeldAnswer(raw []byte, asked []string) (HeldAnswer, error) { - var wrapped struct { - Output string `json:"output"` - OK bool `json:"ok"` - Answer json.RawMessage `json:"answer"` - } - if err := json.Unmarshal(raw, &wrapped); err != nil { - return HeldAnswer{}, fmt.Errorf("the answer of %s is not {output, ok, answer}: %w", HeldVerb, err) - } - if !wrapped.OK || len(wrapped.Answer) == 0 { - return HeldAnswer{}, fmt.Errorf("%s did not answer: %s", HeldVerb, wrapped.Output) - } - var a HeldAnswer - if err := json.Unmarshal(wrapped.Answer, &a); err != nil { - return HeldAnswer{}, fmt.Errorf("the answer of %s is not {held, gone}: %w", HeldVerb, err) - } - named := map[string]bool{} - for _, r := range a.Held { - named[r] = true - } - for _, r := range a.Gone { - named[r] = true - } - for r := range a.Unknown { - named[r] = true - } - for _, r := range asked { - if !named[r] { - return HeldAnswer{}, fmt.Errorf("the answer of %s names nothing of %s", HeldVerb, r) - } - } - return a, nil -} - -// ArtifactsGone is the body of ArtifactsGoneEvent: the module whose builder's key names a gone artifact, -// the key and its fingerprint, and every reference of it the store does not hold. -type ArtifactsGone struct { - Event string `json:"event"` - Module string `json:"module"` - Key string `json:"key"` - Fingerprint string `json:"fingerprint"` - Gone []string `json:"gone"` - At time.Time `json:"at"` -} - -// EncodeArtifactsGone is the event's body, the references sorted. -func EncodeArtifactsGone(e ArtifactsGone) ([]byte, error) { - e.Event = ArtifactsGoneEvent - e.Gone = append([]string(nil), e.Gone...) - sort.Strings(e.Gone) - if e.Module == "" || e.Fingerprint == "" || len(e.Gone) == 0 { - return nil, fmt.Errorf("delivery: %s names a module, a fingerprint and at least one gone reference", ArtifactsGoneEvent) - } - return json.Marshal(e) -} - -// DecodeArtifactsGone reads the event's body, refusing one that names no module, fingerprint or reference. -func DecodeArtifactsGone(raw []byte) (ArtifactsGone, error) { - var e ArtifactsGone - if err := json.Unmarshal(raw, &e); err != nil { - return ArtifactsGone{}, fmt.Errorf("delivery: %s is not one: %w", ArtifactsGoneEvent, err) - } - if e.Event != ArtifactsGoneEvent || e.Module == "" || e.Fingerprint == "" || len(e.Gone) == 0 { - return ArtifactsGone{}, fmt.Errorf("delivery: %s names no module, fingerprint or gone reference: %s", ArtifactsGoneEvent, raw) - } - return e, nil -} diff --git a/go/delivery/held_test.go b/go/delivery/held_test.go deleted file mode 100644 index 7b8a0c4..0000000 --- a/go/delivery/held_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package delivery - -import ( - "slices" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -const ( - refA = "artifact-store://node-tools/runtime/blobs/sha256:a46b5d51a30e8fe71107d75ceb46e3187022fe51f551d0498b8be817f22435cb" - refB = "artifact-store://mesh-host/host-arch/blobs/sha256:8a752f914f3523904a5442cae37ce3cb1ad297d6789b0f871585bce4d0c2ea00" -) - -// 1937/AC1 (the contract): the controller's answer reads into held and gone, and an answer leaving a reference out is refused. -func TestDecodeHeldAnswer(t *testing.T) { - raw := []byte(`{"output":"…","ok":true,"answer":{"store":"s:5100","held":["` + refA + `"],"gone":["` + refB + `"]}}`) - a, err := DecodeHeldAnswer(raw, []string{refA, refB}) - if err != nil { - t.Fatal(err) - } - if len(a.Held) != 1 || a.Held[0] != refA || len(a.Gone) != 1 || a.Gone[0] != refB { - t.Fatalf("read %+v", a) - } - if _, err := DecodeHeldAnswer(raw, []string{refA, refB, "artifact-store://x/y/blobs/sha256:00"}); err == nil || - !strings.Contains(err.Error(), "names nothing of") { - t.Fatalf("an answer leaving a reference out was taken: %v", err) - } - if _, err := DecodeHeldAnswer([]byte(`{"output":"the store is down","ok":false}`), nil); err == nil || - !strings.Contains(err.Error(), "the store is down") { - t.Fatalf("a failed verb was taken: %v", err) - } -} - -// 1937/AC2 (the contract): the event's body names the module, the fingerprint and the gone references, sorted. -func TestArtifactsGoneRoundTrip(t *testing.T) { - raw, err := EncodeArtifactsGone(ArtifactsGone{Module: "node-tools", Key: "node-tools.75", Fingerprint: "src2:75", - Gone: []string{refB, refA}, At: time.Unix(0, 0).UTC()}) - if err != nil { - t.Fatal(err) - } - e, err := DecodeArtifactsGone(raw) - if err != nil { - t.Fatal(err) - } - if e.Event != ArtifactsGoneEvent || e.Module != "node-tools" || len(e.Gone) != 2 || e.Gone[0] != refB { - t.Fatalf("read %+v", e) - } - if _, err := EncodeArtifactsGone(ArtifactsGone{Module: "m", Fingerprint: "src2:1"}); err == nil { - t.Fatal("an event naming no gone reference was made") - } - if _, err := DecodeArtifactsGone([]byte(`{"event":"artifacts-gone","module":"m"}`)); err == nil { - t.Fatal("an event naming no fingerprint was read") - } -} - -// The verb and the event are on the artifact sweep seat's contract under the names the builder uses (ADR 0571 §2). -func TestHeldOnTheArtifactSweepSeat(t *testing.T) { - seat, ok := catalogue.SeatNamed(catalogue.ArtifactSweepSeat) - if !ok || HeldVerb != "seat:"+seat.Name+".held" || ArtifactsGoneSubject != "mesh.seat."+seat.Name+".event."+ArtifactsGoneEvent { - t.Fatalf("HeldVerb %q, ArtifactsGoneSubject %q", HeldVerb, ArtifactsGoneSubject) - } - if !slices.ContainsFunc(seat.Serves, func(v catalogue.Verb) bool { return v.Name == "held" }) || !slices.Contains(seat.Emits, ArtifactsGoneEvent) { - t.Fatalf("the sweep seat serves %v and emits %v", seat.Serves, seat.Emits) - } -} diff --git a/go/delivery/history_records.go b/go/delivery/history_records.go deleted file mode 100644 index fd4b5bd..0000000 --- a/go/delivery/history_records.go +++ /dev/null @@ -1,309 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "sort" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// **Who holds which seat, and the builder's log** (novox/hq ADR 0571 clauses 1 and 4, issue 1955). - -// SeatHoldersState is the state the seat mesh-placement's holder writes of who holds which seat on which node (clause 4): -// one key per seat held once for the mesh, ``, and one per node of a seat held on every node, `.`. -const ( - SeatHoldersState = "seat-holders" - SeatHoldersBucket = AssignerModule + "_" + SeatHoldersState - SeatHoldersRead = AssignerModule + "." + SeatHoldersState -) - -// SeatHolder is one module holding a seat on a node. -type SeatHolder struct { - Module string `json:"module"` - Node string `json:"node"` -} - -// SeatHolders is one key of seat-holders: the seat, its scope, the node for a node seat's key, and its holders -// (several only for a replicated mesh seat), sorted. Outside says the claim names no seat of the set (catalogue.Seats): -// a module registered before the set closed, or built outside the indexed repositories, keeps its claim listed, as the -// controller's `seats` listed it under outside. -type SeatHolders struct { - V int `json:"v"` - Seat string `json:"seat"` - Scope string `json:"scope"` - Node string `json:"node,omitempty"` - Outside bool `json:"outside,omitempty"` - Holders []SeatHolder `json:"holders"` -} - -// SeatHoldersKey is a seat's key: `` for a seat held once for the mesh (node empty), `.` for a node -// seat on one node. -func SeatHoldersKey(seat, node string) (string, error) { - if err := checkName("seat", seat); err != nil { - return "", err - } - if node == "" { - return seat, nil - } - if err := checkName("node", node); err != nil { - return "", err - } - return seat + "." + node, nil -} - -// SeatHoldersOf is the state composed from every node's placement (ADR 0567 §1): each holding by the seat it claims, -// keyed as SeatHoldersKey says, with the module holding it and its node. A claim naming a seat's former name is keyed by -// the seat's name (ADR 0122); one naming no seat is kept, marked Outside. Deterministic, so a writer writes only a key -// that differs. -func SeatHoldersOf(placements map[string]Placement) map[string]SeatHolders { - out := map[string]SeatHolders{} - nodes := make([]string, 0, len(placements)) - for n := range placements { - nodes = append(nodes, n) - } - sort.Strings(nodes) - for _, n := range nodes { - for _, h := range placements[n].Holds { - node := "" - if h.Scope == "node" { - node = n - } - seat, known := catalogue.SeatNamed(h.Claim) - name := h.Claim - if known { - name = seat.Name - } - key, err := SeatHoldersKey(name, node) - if err != nil { - continue - } - sh := out[key] - sh.V, sh.Seat, sh.Scope, sh.Node, sh.Outside = Version, name, h.Scope, node, !known - sh.Holders = append(sh.Holders, SeatHolder{Module: h.Module, Node: n}) - out[key] = sh - } - } - for k, sh := range out { - sort.Slice(sh.Holders, func(i, j int) bool { - return sh.Holders[i].Node+"/"+sh.Holders[i].Module < sh.Holders[j].Node+"/"+sh.Holders[j].Module - }) - out[k] = sh - } - return out -} - -// HoldersOf reads who holds seat (on node, for a node seat) from the state's keys as a reader watched them: the node's -// key first, then the seat's. None when nobody holds it. -func HoldersOf(keys map[string][]byte, seat, node string) ([]SeatHolder, error) { - for _, n := range []string{node, ""} { - key, err := SeatHoldersKey(seat, n) - if err != nil { - if n == "" { - return nil, err - } - continue - } - raw, ok := keys[key] - if !ok { - continue - } - var sh SeatHolders - if err := json.Unmarshal(raw, &sh); err != nil { - return nil, fmt.Errorf("delivery: the holders of %s are not readable: %w", key, err) - } - if sh.V != Version { - return nil, fmt.Errorf("delivery: the holders of %s are of version %d, and this reads %d", key, sh.V, Version) - } - return sh.Holders, nil - } - return nil, nil -} - -// The builder's log (clause 1; the lead and the operator, 2026-10-29): the builder's own module log (ADR 0297) `build-log`, -// kept 30 days (max-age-days), one entry under the key `` when a build of a fingerprint starts and one when it -// ends, so the newest entry of a key is the module's newest start or end. The builder's tools read it through the -// runtime's log verbs; another module reads it by naming BuildLogRead in its manifest's `reads`: a bundle through the -// runtime (stdio.Log(BuildLogRead)), a process on its own credential (process.Conn.ReadLog), each by newest per key, -// from a time, or following new entries. The catalogue module asks the builder's tool BuildsSinceTool instead. -const ( - BuilderLog = "build-log" - BuildLogRead = BuilderModule + "." + BuilderLog - BuildStartedEvent = "build.started" - BuildEndedEvent = "build.ended" - // BuildSaidEvent is a build's narration between its start and its end (design 51: a build narrates its publish, - // asked, waiting on a seat, stored, refused, at least every five minutes while it waits): its module, fingerprint, - // line and why. - BuildSaidEvent = "build.said" - // BuilderLogDays is how long the log keeps its entries. - BuilderLogDays = 30 - // BuildsSinceTool is the builder's tool answering every ended build since a time, in order (clause 9): its - // argument `since` (RFC 3339), its answer BuildsSince. - BuildsSinceTool = "builder_builds_since" -) - -// BuildLogKey is the key of a module's entries in the builder's log: the module's name, refused when a log key could not -// hold it. -func BuildLogKey(module string) (string, error) { - if err := checkName("module", module); err != nil { - return "", err - } - return module, nil -} - -// BuildLogEntry is one entry of the builder's log: the module, the fingerprint, the source revision, where the module is -// built from (its repository, its directory there, the commit and the ref), the build node and the time; at the end, -// the outcome, the manifest built (its digest, read from the indexer's state `manifests` under ManifestsKey), what it was -// built against (the bases' references, as the controller's built-before said them) and each artifact made -// (BuiltArtifact, the done key's: its name, kind, digest and reference). The repository, directory and commit are what -// the indexer's PathOf, the outside-built registrations and the assigner's running commits read; the manifest, against -// and made what the catalogue module registers a build from (the lead, 2026-10-29). -type BuildLogEntry struct { - V int `json:"v"` - Event string `json:"event"` - Module string `json:"module"` - FP string `json:"fp"` - Source string `json:"source"` - Repository string `json:"repository"` - Directory string `json:"directory"` - Commit string `json:"commit"` - Ref string `json:"ref,omitempty"` - Node string `json:"node"` - At time.Time `json:"at"` - Outcome string `json:"outcome,omitempty"` - Manifest string `json:"manifest,omitempty"` - Against []string `json:"against,omitempty"` - Made []BuiltArtifact `json:"made,omitempty"` - // Imports are, at a build's end, the mesh packages (import paths) the built programs import, their tests left out, - // sorted and each once, as the build module answered them (BuildAnswer.Imports; novox/hq ADR 0593 §2): the indexer - // keeps them on the module (KeptInputs2.Imports) when the build's fingerprint is the module's. - Imports []string `json:"imports,omitempty"` - // OutOfStep are, at a build's end, the generated files its test step found out of step with what their generator - // writes, as the build module answered them (its test step's `out-of-step`): the indexer raises a finding naming them - // for a trunk build, and only a clean build of the same module clears it. A start or a narration names none. - OutOfStep []string `json:"out-of-step,omitempty"` - // Line is a narration's words, Why what it narrates (asked, waiting, stored, refused): a build.said entry's alone. - Line string `json:"line,omitempty"` - Why string `json:"why,omitempty"` -} - -// BuildsSince is the answer of BuildsSinceTool: every ended build since a time, oldest first, and whether the log held -// more than one answer carries (then ask again from the last one's time). -type BuildsSince struct { - Since time.Time `json:"since"` - Builds []BuildLogEntry `json:"builds"` - More bool `json:"more,omitempty"` -} - -// NewestPerModule is each module's newest entry of entries (one kind, read from the last per subject or whole), by -// module: the reader's helper for the five newest and the running commits. -func NewestPerModule(entries []BuildLogEntry) map[string]BuildLogEntry { - out := map[string]BuildLogEntry{} - for _, e := range entries { - if have, ok := out[e.Module]; !ok || e.At.After(have.At) { - out[e.Module] = e - } - } - return out -} - -// EndedSince is, of entries, the ended builds at or after since, oldest first: the reader's helper for history and for -// BuildsSinceTool's answer. -func EndedSince(entries []BuildLogEntry, since time.Time) []BuildLogEntry { - var out []BuildLogEntry - for _, e := range entries { - if e.Event == BuildEndedEvent && !e.At.Before(since) { - out = append(out, e) - } - } - sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) }) - return out -} - -// EncodeBuildLogEntry is e as JSON at Version; it refuses an entry a reader could not place. -func EncodeBuildLogEntry(e BuildLogEntry) ([]byte, error) { - e.V = Version - switch { - case e.Event == BuildSaidEvent: - switch { - case e.Module == "" || e.FP == "" || e.At.IsZero() || e.Line == "": - return nil, fmt.Errorf("delivery: a build's narration names its module, fingerprint, line and time") - case e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0 || len(e.OutOfStep) > 0: - return nil, fmt.Errorf("delivery: a build's narration names no outcome, manifest, base, artifact, import or file out of step") - } - return json.Marshal(e) - case e.Event != BuildStartedEvent && e.Event != BuildEndedEvent: - return nil, fmt.Errorf("delivery: a build log entry is %s, %s or %s, not %q", BuildStartedEvent, BuildSaidEvent, BuildEndedEvent, e.Event) - case e.Line != "" || e.Why != "": - return nil, fmt.Errorf("delivery: a build's start or end carries no narration's line") - case e.Module == "" || e.FP == "" || e.Node == "" || e.At.IsZero() || e.Repository == "" || e.Commit == "": - return nil, fmt.Errorf("delivery: a build log entry names its module, fingerprint, repository, commit, node and time") - case e.Event == BuildEndedEvent && e.Outcome == "": - return nil, fmt.Errorf("delivery: a build's end names its outcome") - case e.Event == BuildStartedEvent && (e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0 || - len(e.OutOfStep) > 0): - return nil, fmt.Errorf("delivery: a build's start names no outcome, manifest, base, artifact, import or file out of step") - } - if err := CheckImports(e.Imports); err != nil { - return nil, err - } - return json.Marshal(e) -} - -// DecodeBuildLogEntry reads one entry. -func DecodeBuildLogEntry(data []byte) (BuildLogEntry, error) { - var e BuildLogEntry - if err := json.Unmarshal(data, &e); err != nil { - return BuildLogEntry{}, fmt.Errorf("delivery: a build log entry is not readable: %w", err) - } - if e.V != Version { - return BuildLogEntry{}, fmt.Errorf("delivery: a build log entry of version %d, and this reads %d", e.V, Version) - } - return e, nil -} - -// BuildDuration is a build's duration, its end less its start (clause 1): false unless both are the same module's -// build of the same fingerprint, the start first. -func BuildDuration(start, end BuildLogEntry) (time.Duration, bool) { - if start.Event != BuildStartedEvent || end.Event != BuildEndedEvent || start.Module != end.Module || start.FP != end.FP || - end.At.Before(start.At) { - return 0, false - } - return end.At.Sub(start.At), true -} - -// **Which modules exist and which machines the mesh has, from the bus** (novox/hq ADR 0571 clauses 4, 5; the lead for a6, -// 2026-10-29): the tool runner reads the modules from the indexer's state `modules` and the machines from the placement -// seat's keys, and no longer asks the controller's `modules` or `nodes`. - -// ModuleNames is every module the indexer holds, sorted, from its state `modules` as a reader holds its keys (key to -// value; a deleted key absent). A key that is not a module's is refused, naming it. -func ModuleNames(keys map[string][]byte) ([]string, error) { - out := make([]string, 0, len(keys)) - for k := range keys { - module, err := ParseModulesKey(k) - if err != nil { - return nil, err - } - out = append(out, module) - } - sort.Strings(out) - return out, nil -} - -// MachineNames is every machine the placement seat places modules on, sorted, from its state `placement` as a reader -// holds its keys: each with a node key `.node` or, in the form before, `placement.` (placement_keys.go). -// Module keys and keys of the state's other kinds (a proposal's) are passed over. -func MachineNames(keys map[string][]byte) []string { - seen := map[string]bool{} - out := []string{} - for k := range keys { - if node, _, kind, err := ParsePlacementKey(k); err == nil && kind != PlacementModuleKind && !seen[node] { - seen[node] = true - out = append(out, node) - } - } - sort.Strings(out) - return out -} diff --git a/go/delivery/indexer2_test.go b/go/delivery/indexer2_test.go deleted file mode 100644 index 4aa1fe0..0000000 --- a/go/delivery/indexer2_test.go +++ /dev/null @@ -1,321 +0,0 @@ -package delivery - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "strings" - "testing" -) - -// The values below that today's SDK wrote were produced by EncodeCandidate(sampleCandidate()) and EncodeModule at -// mesh-sdk 5786ba19 (source_test.go's samples), and are pasted as the bytes it wrote. -const ( - oldCandidateJSON = `{"v":1,"group":"feat/x","base":12,"heads":"4b5491557853","pulls":[{"repository":"novox/mesh-sdk","number":36,"head":"aaaa","modules":["mesh-mail"]},{"repository":"novox/mesh-control","number":7,"head":"bbbb","modules":["mesh-controller"]}]}` - oldModuleJSON = `{"v":1,"fp":"src1:abc","src":5,"repository":"novox/mesh-sdk","commit":"cccc"}` - oldNoneJSON = `{"v":1,"none":"a build that resolves packages when built","repository":"novox/mesh-sdk","commit":"cccc"}` -) - -// The reader structs of today's shape (source.go at 5786ba19): what the gate and the view are built on until they move -// to this SDK. They decode with plain json.Unmarshal after the version check, as decode does. -type oldModule struct { - V int `json:"v"` - FP string `json:"fp,omitempty"` - None string `json:"none,omitempty"` - Src uint64 `json:"src,omitempty"` - Repository string `json:"repository"` - Commit string `json:"commit"` -} - -type oldCandidate struct { - V int `json:"v"` - Group string `json:"group"` - Base uint64 `json:"base"` - HeadsHash string `json:"heads"` - Pulls []struct { - Head - Modules []string `json:"modules"` - } `json:"pulls"` -} - -func fp2of(c byte) string { return SourcePrefix2 + strings.Repeat(string(c), 64) } - -func digestOf(c byte) string { return strings.Repeat(string(c), 64) } - -func newCandidate() Candidate { - c := sampleCandidate() - c.Pulls[0].Fps = map[string]CandidateModule{"mesh-mail": {FP2: fp2of('a'), Manifest: digestOf('1')}} - c.Pulls[1].Fps = map[string]CandidateModule{"mesh-controller": {None2: "no lock", Manifest: digestOf('2')}} - c.FPSet = FingerprintSet(c.Pulls) - return c -} - -// 1357/AC1: the digest is the hex sha256 of exactly the bytes; the key is that hex; a key that is not 64 lowercase hex -// is refused. -func TestManifestKeys(t *testing.T) { - raw := []byte("{\n \"module\": \"x\"\n}\n") - sum := sha256.Sum256(raw) - want := hex.EncodeToString(sum[:]) - if got := ManifestDigest(raw); got != want { - t.Errorf("ManifestDigest = %s, want %s", got, want) - } - key, err := ManifestsKey(want) - if err != nil || key != want { - t.Errorf("ManifestsKey = %q, %v", key, err) - } - back, err := ParseManifestsKey(key) - if err != nil || back != want { - t.Errorf("ParseManifestsKey = %q, %v", back, err) - } - for _, bad := range []string{"", "abc", strings.ToUpper(want), want + "0", want[:63], "sha256:" + want, strings.Repeat("g", 64)} { - if _, err := ManifestsKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ManifestsKey(%q) = %v, want a refusal naming it", bad, err) - } - if _, err := ParseManifestsKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ParseManifestsKey(%q) = %v, want a refusal naming it", bad, err) - } - } - if ManifestsBucket != "mesh-delivery-indexer_manifests" || ManifestsHistory != 1 { - t.Errorf("bucket %s history %d", ManifestsBucket, ManifestsHistory) - } -} - -// 1357/AC2 (the shape of it; the 21 KB case is in manifests_mailu_test.go): a manifests value encodes and decodes with -// its manifest byte for byte, whatever its spacing, escapes and characters, and its digest is the key. -func TestManifestRoundTripBytesForByte(t *testing.T) { - // odd spacing, a tab, an escaped newline and quote, an em dash and an HTML-looking character, a trailing newline: - // what a JSON re-encoder would change. - raw := []byte("{\n\t\"module\": \"x\",\n \"why\": \"a — b & \\n \\\" d é\",\n\n \"list\": [ \"a\",\"b\" ]\n}\n\n") - m := Manifest{Module: "x", JSON: string(raw)} - data, err := EncodeManifest(m) - if err != nil { - t.Fatal(err) - } - got, err := DecodeManifest(data) - if err != nil { - t.Fatal(err) - } - if got.JSON != string(raw) || ManifestDigest([]byte(got.JSON)) != ManifestDigest(raw) || got.Module != "x" || got.V != Version { - t.Errorf("the manifest changed on the way: %q", got.JSON) - } - for name, bad := range map[string]Manifest{ - "no module": {JSON: string(raw)}, - "a dotted module": {Module: "a.b", JSON: string(raw)}, - "no manifest": {Module: "x"}, - "not JSON": {Module: "x", JSON: "{nope"}, - "not UTF-8": {Module: "x", JSON: "{\"a\":\"\xff\"}"}, - } { - if _, err := EncodeManifest(bad); err == nil { - t.Errorf("a manifest with %s was encoded", name) - } - } - if _, err := DecodeManifest([]byte(`{"v":2,"module":"m","manifest":"{}"}`)); !errors.Is(err, ErrUnknownVersion) { - t.Errorf("version 2: %v", err) - } -} - -// 1357/AC3: a Module and a Candidate written by today's SDK decode and check under the new one, new fields empty. -func TestOldValuesDecodeUnderTheNewSDK(t *testing.T) { - c, err := DecodeCandidate([]byte(oldCandidateJSON)) - if err != nil { - t.Fatal(err) - } - if c.FPSet != "" || c.Pulls[0].Fps != nil || c.Pulls[1].Fps != nil || c.Group != "feat/x" { - t.Errorf("a candidate of today grew fields: %+v", c) - } - for _, in := range []string{oldModuleJSON, oldNoneJSON} { - m, err := DecodeModule([]byte(in)) - if err != nil { - t.Fatal(err) - } - if m.FP2 != "" || m.None2 != "" || m.Manifest != "" { - t.Errorf("a module of today grew fields: %+v", m) - } - } - // And the new SDK writes today's bytes for today's value: nothing new appears when nothing new is set. - data, err := EncodeCandidate(sampleCandidate()) - if err != nil || string(data) != oldCandidateJSON { - t.Errorf("EncodeCandidate of an old value = %s, %v", data, err) - } - data, err = EncodeModule(Module{FP: "src1:abc", Src: 5, Repository: "novox/mesh-sdk", Commit: "cccc"}) - if err != nil || string(data) != oldModuleJSON { - t.Errorf("EncodeModule of an old value = %s, %v", data, err) - } -} - -// 1357/AC4: a value with the new fields decodes, under a reader struct of today's shape, with no error. -func TestNewValuesDecodeUnderTheOldReader(t *testing.T) { - m := Module{FP: "src1:abc", FP2: fp2of('b'), Manifest: digestOf('3'), Src: 5, Repository: "novox/mesh-sdk", Commit: "cccc"} - data, err := EncodeModule(m) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), `"fp2"`) || !strings.Contains(string(data), `"manifest"`) { - t.Fatalf("the new fields are not in %s", data) - } - if err := checkVersion(data); err != nil { - t.Fatal(err) - } - var om oldModule - if err := json.Unmarshal(data, &om); err != nil || om.FP != "src1:abc" || om.Commit != "cccc" { - t.Errorf("the old reader of a module: %+v %v", om, err) - } - data, err = EncodeCandidate(newCandidate()) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), `"fpset"`) || !strings.Contains(string(data), `"fp2"`) { - t.Fatalf("the new fields are not in %s", data) - } - var oc oldCandidate - if err := json.Unmarshal(data, &oc); err != nil || oc.Group != "feat/x" || len(oc.Pulls) != 2 || oc.Pulls[0].Modules[0] != "mesh-mail" { - t.Errorf("the old reader of a candidate: %+v %v", oc, err) - } - // and the new reader reads it back whole - got, err := DecodeCandidate(data) - want := newCandidate() - want.V = Version - if err != nil || !SameCandidate(got, want) { - t.Errorf("round trip: %+v %v", got, err) - } - gm, err := DecodeModule(mustJSON(t, m)) - m.V = Version - if err != nil || !SameModule(gm, m) { - t.Errorf("module round trip: %+v %v", gm, err) - } -} - -func mustJSON(t *testing.T, m Module) []byte { - t.Helper() - data, err := EncodeModule(m) - if err != nil { - t.Fatal(err) - } - return data -} - -// 1357/AC5: a Module with an fp2 not of the new form's prefix, or with both fp2 and none2, is refused naming it; a -// Candidate whose fpset is not the hash of its fingerprints is refused. -func TestNewFieldsAreChecked(t *testing.T) { - wantErr := func(name string, err error, words ...string) { - t.Helper() - if err == nil { - t.Errorf("%s accepted", name) - return - } - for _, w := range words { - if !strings.Contains(err.Error(), w) { - t.Errorf("%s: error %q does not name %q", name, err, w) - } - } - } - base := Module{FP: "src1:abc", Repository: "a/b", Commit: "c"} - m := base - m.FP2 = "src1:" + strings.Repeat("a", 64) - _, err := EncodeModule(m) - wantErr("fp2 of the old form", err, "src1:"+strings.Repeat("a", 64)) - m.FP2 = "src2:xyz" - _, err = EncodeModule(m) - wantErr("fp2 malformed", err, "src2:xyz") - m = base - m.FP2, m.None2 = fp2of('a'), "no lock" - _, err = EncodeModule(m) - wantErr("fp2 and none2", err, "both", "fp2", "none2") - m = base - m.Manifest = "abc" - _, err = EncodeModule(m) - wantErr("a manifest digest malformed", err, "abc") - m = base - m.None2 = "no lock" - if _, err = EncodeModule(m); err != nil { - t.Errorf("none2 alone refused: %v", err) - } - // decode refuses what encode refuses - _, err = DecodeModule([]byte(`{"v":1,"fp":"src1:abc","fp2":"src2:zz","repository":"a/b","commit":"c"}`)) - wantErr("decoded fp2 malformed", err, "src2:zz") - - c := newCandidate() - c.FPSet = "000000000000" - _, err = EncodeCandidate(c) - wantErr("a stale fpset", err, "fpset", "000000000000") - c = newCandidate() - c.FPSet = "" - _, err = EncodeCandidate(c) - wantErr("fingerprints and no fpset", err, "fpset") - c = sampleCandidate() - c.FPSet = FingerprintSet(newCandidate().Pulls) - _, err = EncodeCandidate(c) - wantErr("an fpset and no fingerprints", err, "fpset") - c = newCandidate() - c.Pulls[0].Fps["mesh-mail"] = CandidateModule{FP2: "src1:abc"} - c.FPSet = FingerprintSet(c.Pulls) - _, err = EncodeCandidate(c) - wantErr("a candidate's fp2 of the old form", err, "src1:abc") - c = newCandidate() - c.Pulls[0].Fps["mesh-mail"] = CandidateModule{FP2: fp2of('a'), None2: "x"} - c.FPSet = FingerprintSet(c.Pulls) - _, err = EncodeCandidate(c) - wantErr("a candidate's module with both", err, "both", "mesh-mail") - c = newCandidate() - c.Pulls[0].Fps["other"] = CandidateModule{FP2: fp2of('a')} - c.FPSet = FingerprintSet(c.Pulls) - _, err = EncodeCandidate(c) - wantErr("a fingerprint of a module the pull does not touch", err, "other") -} - -// The fpset is the same for any order and changes with any fingerprint or reason (research 063 section 01: -// verdicts..check. is keyed by it). -func TestFingerprintSet(t *testing.T) { - c := newCandidate() - want := FingerprintSet(c.Pulls) - if len(want) != 12 || strings.Trim(want, "0123456789abcdef") != "" { - t.Errorf("fpset %q is not 12 lowercase hex digits", want) - } - c.Pulls[0], c.Pulls[1] = c.Pulls[1], c.Pulls[0] - if got := FingerprintSet(c.Pulls); got != want { - t.Errorf("the order of pull requests changes it: %s", got) - } - c = newCandidate() - c.Pulls[0].Fps["mesh-mail"] = CandidateModule{FP2: fp2of('b')} - if got := FingerprintSet(c.Pulls); got == want { - t.Error("a changed fingerprint keeps it") - } - c = newCandidate() - c.Pulls[1].Fps["mesh-controller"] = CandidateModule{None2: "no tree"} - if got := FingerprintSet(c.Pulls); got == want { - t.Error("a changed reason keeps it") - } - c = newCandidate() - c.Pulls[0].Fps["mesh-mail"] = CandidateModule{FP2: fp2of('a'), Manifest: digestOf('9')} - if got := FingerprintSet(c.Pulls); got != want { - t.Error("a manifest digest, which the fingerprint already covers, changes it") - } -} - -// 1357/AC6: both values keep version 1, and a value of another version is refused with ErrUnknownVersion. -func TestVersionsStayOne(t *testing.T) { - if Version != 1 { - t.Fatalf("Version = %d", Version) - } - for _, data := range [][]byte{mustJSON(t, Module{FP: "src1:a", FP2: fp2of('a'), Repository: "a/b", Commit: "c"})} { - if !strings.HasPrefix(string(data), `{"v":1,`) { - t.Errorf("module at %s", data) - } - } - data, _ := EncodeCandidate(newCandidate()) - if !strings.HasPrefix(string(data), `{"v":1,`) { - t.Errorf("candidate at %s", data) - } - for _, in := range []string{ - `{"v":2,"fp":"src1:a","fp2":"` + fp2of('a') + `","repository":"a/b","commit":"c"}`, - `{"fp2":"` + fp2of('a') + `","repository":"a/b","commit":"c"}`, - } { - if _, err := DecodeModule([]byte(in)); !errors.Is(err, ErrUnknownVersion) { - t.Errorf("DecodeModule(%s) = %v", in, err) - } - } - if _, err := DecodeCandidate([]byte(`{"v":2,"group":"g","fpset":"abc"}`)); !errors.Is(err, ErrUnknownVersion) { - t.Errorf("candidate version 2: %v", err) - } -} diff --git a/go/delivery/manifests.go b/go/delivery/manifests.go deleted file mode 100644 index d0d0bcd..0000000 --- a/go/delivery/manifests.go +++ /dev/null @@ -1,85 +0,0 @@ -package delivery - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "unicode/utf8" -) - -// ManifestDigest is the hex sha256 of a manifest's bytes exactly as read at the commit: the key of a manifests value -// and the digest a Module and a candidate's module carry (novox/hq ADR 0429 clause 16). -func ManifestDigest(raw []byte) string { - sum := sha256.Sum256(raw) - return hex.EncodeToString(sum[:]) -} - -// ManifestsKey is the key, in ManifestsBucket, of a manifest: its digest, 64 lowercase hex digits. The indexer -// writes it once, by a create that fails when the key exists. -func ManifestsKey(digest string) (string, error) { - if !isHex64(digest) { - return "", fmt.Errorf("delivery: %q is not a manifest digest, 64 lowercase hex digits", digest) - } - return digest, nil -} - -// ParseManifestsKey is the inverse of ManifestsKey. -func ParseManifestsKey(key string) (digest string, err error) { - if digest, err = ManifestsKey(key); err != nil { - return "", fmt.Errorf("delivery: %q is not a manifests key: %w", key, err) - } - return digest, nil -} - -// Manifest is the value of a manifests key: the module and its manifest, the raw text of its module.json as read at the -// commit, byte for byte (ManifestDigest of JSON is the key). It is the one value of delivery that is larger than about -// 500 bytes: the largest manifest of the catalogue is about 21 KB. It is a string, not nested JSON, because nesting -// would re-write the manifest's spacing and so change its digest. -type Manifest struct { - V int `json:"v"` - Module string `json:"module"` - JSON string `json:"manifest"` -} - -func (m Manifest) check() error { - if err := checkName("module", m.Module); err != nil { - return err - } - if m.JSON == "" { - return fmt.Errorf("delivery: the manifest of %s is empty", m.Module) - } - if !utf8.ValidString(m.JSON) { - return fmt.Errorf("delivery: the manifest of %s is not UTF-8 text, which a value cannot hold byte for byte", m.Module) - } - if !json.Valid([]byte(m.JSON)) { - return fmt.Errorf("delivery: the manifest of %s is not JSON", m.Module) - } - return nil -} - -// EncodeManifest is m as JSON at Version; it refuses a module that is not a name, and a manifest that is empty, not -// UTF-8 or not JSON. -func EncodeManifest(m Manifest) ([]byte, error) { - if err := m.check(); err != nil { - return nil, err - } - m.V = Version - return json.Marshal(m) -} - -// DecodeManifest reads a manifests value; it refuses an unknown version (ErrUnknownVersion) and what EncodeManifest -// refuses. -func DecodeManifest(data []byte) (Manifest, error) { - var m Manifest - if err := decode(data, &m); err != nil { - return Manifest{}, err - } - if err := m.check(); err != nil { - return Manifest{}, err - } - return m, nil -} - -// SameManifest says whether two manifests are the same value, so a writer writes only when they differ. -func SameManifest(a, b Manifest) bool { return a == b } diff --git a/go/delivery/manifests_mailu_test.go b/go/delivery/manifests_mailu_test.go deleted file mode 100644 index a4c9f44..0000000 --- a/go/delivery/manifests_mailu_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package delivery - -import ( - "os" - "testing" -) - -// 1357/AC2: a manifests value of 21 KB encodes and decodes with its manifest byte for byte. The manifest is a copy of -// mesh-catalog modules/mailu/module.json at origin/main c0880c4dc27194cbb99955579a97f3212c5be93e (20,713 bytes), the -// largest manifest of the catalogue. -func TestMailuManifestRoundTripBytesForByte(t *testing.T) { - raw, err := os.ReadFile("testdata/manifest-mailu.json") - if err != nil { - t.Fatal(err) - } - if len(raw) != 20713 { - t.Fatalf("the fixture is %d bytes, the catalogue's is 20713", len(raw)) - } - data, err := EncodeManifest(Manifest{Module: "mailu", JSON: string(raw)}) - if err != nil { - t.Fatal(err) - } - got, err := DecodeManifest(data) - if err != nil { - t.Fatal(err) - } - if got.JSON != string(raw) || ManifestDigest([]byte(got.JSON)) != ManifestDigest(raw) || got.Module != "mailu" { - t.Errorf("the manifest changed on the way: %d bytes", len(got.JSON)) - } -} diff --git a/go/delivery/module_newform_test.go b/go/delivery/module_newform_test.go deleted file mode 100644 index 3932e57..0000000 --- a/go/delivery/module_newform_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package delivery - -import ( - "strings" - "testing" -) - -// From R4e a modules value needs no old form (novox/hq ADR 0453 clause 6; issue 1851): a value with only the new -// fingerprint, or only the reason it has none, is written and read; an old value with fp or none still reads; a value -// with no fingerprint of either form and no reason is still refused. -func TestAModulesValueNeedsNoOldForm(t *testing.T) { - fp2 := SourcePrefix2 + strings.Repeat("a", 64) - for _, m := range []Module{ - {FP2: fp2, Repository: "a/b", Commit: "c"}, - {None2: "no lock", Repository: "a/b", Commit: "c"}, - } { - data, err := EncodeModule(m) - if err != nil { - t.Fatalf("%+v was refused: %v", m, err) - } - back, err := DecodeModule(data) - if err != nil || back.FP2 != m.FP2 || back.None2 != m.None2 || back.FP != "" || back.None != "" { - t.Fatalf("decoded %+v, %v", back, err) - } - } - if _, err := DecodeModule([]byte(`{"v":1,"fp":"src1:x","repository":"a/b","commit":"c"}`)); err != nil { - t.Fatalf("an old value no longer reads: %v", err) - } - if _, err := EncodeModule(Module{Repository: "a/b", Commit: "c"}); err == nil { - t.Fatal("a value with no fingerprint and no reason was accepted") - } -} diff --git a/go/delivery/module_paths_test.go b/go/delivery/module_paths_test.go deleted file mode 100644 index 1579762..0000000 --- a/go/delivery/module_paths_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package delivery - -import ( - "strings" - "testing" -) - -// The indexer's modules value carries each artifact's named paths (novox/hq ADR 0499; the lead's decision L8 of R4e): -// the checker tells from it whether a change touches lib's paths without reading a manifest. An older value has none. -func TestAModulesValueCarriesEachArtifactsNamedPaths(t *testing.T) { - m := Module{FP2: SourcePrefix2 + strings.Repeat("a", 64), Repository: "novox/mesh-sdk", Commit: "c", - ArtifactPaths: map[string][]string{"lib": {"package-lock.json", "package.json", "src", "test", "tsconfig.json"}}} - data, err := EncodeModule(m) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), `"paths":{"lib":[`) { - t.Fatalf("the value carries no paths: %s", data) - } - back, err := DecodeModule(data) - if err != nil || len(back.ArtifactPaths["lib"]) != 5 || back.ArtifactPaths["lib"][2] != "src" { - t.Fatalf("decoded %+v, %v", back.ArtifactPaths, err) - } - if old, err := DecodeModule([]byte(`{"v":1,"fp":"src1:x","repository":"a/b","commit":"c"}`)); err != nil || old.ArtifactPaths != nil { - t.Fatalf("an older value: %+v, %v", old, err) - } - for _, bad := range []map[string][]string{{"": {"src"}}, {"lib": {}}, {"lib": {"../x"}}, {"lib": {""}}} { - m.ArtifactPaths = bad - if _, err := EncodeModule(m); err == nil { - t.Errorf("%v was accepted", bad) - } - } -} diff --git a/go/delivery/motion.go b/go/delivery/motion.go deleted file mode 100644 index 94c94c5..0000000 --- a/go/delivery/motion.go +++ /dev/null @@ -1,75 +0,0 @@ -package delivery - -import "time" - -// **Whether a module is being delivered on a node, read from its want and its have** (novox/hq ADR 0564 clause 13 and -// Limits 12, ADR 0540 §7; issue 1952 S7): one derivation, used by the delivery view, which says a module `moving`, -// `settled` or `stuck` (the messenger's hold hears those), and by the condition keeper, which holds a module's own -// findings while it is delivered there and raises what still stands when the delivery ends. Before, it lived in the view -// alone (mesh-delivery-view internal/view motionOf), and a second reader would have judged a delivery its own way. - -// The motion of a module on a node, as the view says it. -const ( - MotionMoving = "moving" - MotionSettled = "settled" - MotionStuck = "stuck" -) - -// The outcomes of a motion that settled or stuck. -const ( - // OutcomeRunning is a settled module: the have reached the want, running and healthy. - OutcomeRunning = "running" - // OutcomeFailed is a have at the wanted fingerprint whose apply or health check failed. - OutcomeFailed = "failed" - // OutcomeStopped is a module held, or a want the watchman says stopped moving past its bound. - OutcomeStopped = "stopped" -) - -// DefaultDeliveryBound is how long a module's delivery holds its own findings when its manifest names no bound (ADR 0564 -// clause 13 (d)): past it, what is held is raised, saying the delivery did not end. -const DefaultDeliveryBound = 15 * time.Minute - -// Motion is where one module's want stands on one node. -type Motion struct { - // State is MotionMoving, MotionSettled or MotionStuck; empty when there is no want to move to (none, removed, or - // naming no fingerprint). - State string - // Outcome and Words are a settled or stuck motion's: running; failed with the have's words; stopped with the have's - // words (held) or the watchman's (stopped moving). - Outcome string - Words string - // Delivering says the module is being delivered there (clause 13 (a)): its want names a fingerprint other than its - // have's, or its have's phase is applying. - Delivering bool - // Failed says the delivery ended failed (Limits 12): the node refused or failed its apply (the have's phase failed, - // at whichever fingerprint it kept), or the have at the wanted fingerprint is unhealthy. What a hold kept is raised - // at once. Apart from State, which says failed only at the wanted fingerprint, as the view always has. - Failed bool -} - -// Ended says the delivery ended, failed or settled healthy at the want: what a hold kept is judged now. -func (m Motion) Ended() bool { return m.Failed || (m.State == MotionSettled && !m.Delivering) } - -// MotionOf is the motion of a module on a node from its want (wanted: one was read), its have (has: one was read) and -// the watchman's words when it says the want stopped moving (stuck: it says so and the want has not moved since). -func MotionOf(w Want, wanted bool, h Have, has bool, stuckWords string, stuck bool) Motion { - if !wanted || w.Removed || w.FP == "" { - return Motion{} - } - m := Motion{Delivering: !has || h.FP != w.FP || h.Phase == PhaseApplying, - Failed: has && (h.Phase == PhaseFailed || (h.FP == w.FP && h.Health == HealthFailed))} - applied := has && h.FP == w.FP && (h.Composed == "" || h.Composed == w.Composed) - switch { - case applied && h.Phase == PhaseRunning && h.Health == HealthOK: - m.State, m.Outcome = MotionSettled, OutcomeRunning - case has && h.Phase == PhaseHeld: - m.State, m.Outcome, m.Words = MotionStuck, OutcomeStopped, h.Detail - case has && h.FP == w.FP && (h.Health == HealthFailed || h.Phase == PhaseFailed): - m.State, m.Outcome, m.Words = MotionStuck, OutcomeFailed, h.Detail - case stuck: - m.State, m.Outcome, m.Words = MotionStuck, OutcomeStopped, stuckWords - default: - m.State = MotionMoving - } - return m -} diff --git a/go/delivery/motion_test.go b/go/delivery/motion_test.go deleted file mode 100644 index 42b34ba..0000000 --- a/go/delivery/motion_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package delivery - -import "testing" - -// 1952 S7 (ADR 0564 clause 13 (a), (c) and Limits 12; ADR 0540 §7): one derivation of whether a module is being -// delivered on a node, read by the view (moving, settled, stuck, as its motionOf said them) and by the condition keeper -// (delivering, failed, ended). Wants and haves as the assigner and the node-engine write them (assigner.go Want, -// delivery.go Have; agent.go writes a refusal as phase failed at the prior fingerprint). -func TestMotionOf(t *testing.T) { - want := Want{V: Version, FP: "fp-new", Composed: "sha256:new"} - at := func(fp, composed string, phase Phase, health Health, detail string) Have { - return Have{V: Version, FP: fp, Composed: composed, Phase: phase, Health: health, Detail: detail} - } - for _, c := range []struct { - name string - w Want - wanted bool - h Have - has bool - stuck string - state string - outcome string - delivering bool - failed bool - ended bool - }{ - {name: "no want", h: at("fp-old", "", PhaseRunning, HealthOK, ""), has: true}, - {name: "a removed want", w: Want{V: Version, Removed: true}, wanted: true}, - {name: "a want no node has yet", w: want, wanted: true, state: MotionMoving, delivering: true}, - {name: "the old fingerprint still runs", w: want, wanted: true, h: at("fp-old", "sha256:old", PhaseRunning, HealthOK, ""), has: true, - state: MotionMoving, delivering: true}, - {name: "applying the wanted fingerprint", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseApplying, HealthUnknown, ""), has: true, - state: MotionMoving, delivering: true}, - {name: "checking at the want: no longer delivering, not yet settled", w: want, wanted: true, - h: at("fp-new", "sha256:new", PhaseChecking, HealthUnknown, ""), has: true, state: MotionMoving}, - {name: "running healthy at the want", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseRunning, HealthOK, ""), has: true, - state: MotionSettled, outcome: OutcomeRunning, ended: true}, - {name: "running healthy at another composition", w: want, wanted: true, h: at("fp-new", "sha256:other", PhaseRunning, HealthOK, ""), has: true, - state: MotionMoving}, - {name: "unhealthy at the want", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseRunning, HealthFailed, "exit 1"), has: true, - state: MotionStuck, outcome: OutcomeFailed, failed: true, ended: true}, - {name: "its apply failed at the want", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseFailed, HealthUnknown, "no image"), has: true, - state: MotionStuck, outcome: OutcomeFailed, failed: true, ended: true}, - {name: "refused, the prior fingerprint kept", w: want, wanted: true, - h: at("fp-old", "sha256:old", PhaseFailed, HealthOK, "not composed: no grant"), has: true, - state: MotionMoving, delivering: true, failed: true, ended: true}, - {name: "held on the node", w: want, wanted: true, h: at("fp-old", "sha256:old", PhaseHeld, HealthOK, "held by a person"), has: true, - state: MotionStuck, outcome: OutcomeStopped, delivering: true}, - {name: "the watchman says it stopped moving", w: want, wanted: true, h: at("fp-old", "sha256:old", PhaseRunning, HealthOK, ""), has: true, - stuck: "ace to apply fp-new", state: MotionStuck, outcome: OutcomeStopped, delivering: true}, - } { - m := MotionOf(c.w, c.wanted, c.h, c.has, c.stuck, c.stuck != "") - if m.State != c.state || m.Outcome != c.outcome || m.Delivering != c.delivering || m.Failed != c.failed || m.Ended() != c.ended { - t.Errorf("%s: %+v (ended %v), want state %q outcome %q delivering %v failed %v ended %v", c.name, m, m.Ended(), - c.state, c.outcome, c.delivering, c.failed, c.ended) - } - } -} diff --git a/go/delivery/namedtree.go b/go/delivery/namedtree.go deleted file mode 100644 index 0ee97a2..0000000 --- a/go/delivery/namedtree.go +++ /dev/null @@ -1,5 +0,0 @@ -package delivery - -// NamedTreePrefix opens the tree input of a module whose every artifact names its paths, so such a value is never read -// as a git tree id and a reader of a kept Tree can tell the two apart. -const NamedTreePrefix = "named:" diff --git a/go/delivery/namedtree_layouts_test.go b/go/delivery/namedtree_layouts_test.go deleted file mode 100644 index 17324f8..0000000 --- a/go/delivery/namedtree_layouts_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package delivery - -import ( - "strings" - "testing" -) - -// The named paths' git objects travel beside a module's other new-form inputs (novox/hq ADR 0499 clauses 2 and 3): in -// the forge bridge's messages (Forge.Paths), in the merge preview (PreviewModule.Paths) and in what the indexer keeps -// (KeptInputs2.Paths), so a computation from kept inputs gives the merge's value. Each is read as absent by an older -// reader, and an older value, without them, still decodes. -func TestTheNamedPathsObjectsRoundTripBesideTheOtherInputs(t *testing.T) { - manifest := strings.Repeat("e", 64) - paths := map[string]string{"src": strings.Repeat("a", 40), "package.json": strings.Repeat("b", 40)} - tree := NamedTreePrefix + strings.Repeat("f", 64) - - m := Module{FP2: SourcePrefix2 + strings.Repeat("1", 64), Manifest: manifest, Repository: "novox/mesh-sdk", - Commit: strings.Repeat("c", 40), Inputs2: &KeptInputs2{Tree: tree, Paths: paths}} - m.FP = SourcePrefix + strings.Repeat("0", 64) - data, err := EncodeModule(m) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), `"paths":{`) { - t.Fatalf("the kept inputs carry no paths: %s", data) - } - back, err := DecodeModule(data) - if err != nil || back.Inputs2.Paths["src"] != paths["src"] || back.Inputs2.Tree != tree { - t.Fatalf("decoded %+v, %v", back.Inputs2, err) - } - // A kept path must name an object; whether the paths compute to the kept tree is the indexer's to check (ADR 0593 §2). - bad := m - bad.Inputs2 = &KeptInputs2{Tree: tree, Paths: map[string]string{"src": ""}} - if _, err := EncodeModule(bad); err == nil { - t.Fatal("a kept path naming no object was accepted") - } - // An older value has no paths, and its directory tree stays its tree. - old := `{"v":1,"fp":"` + m.FP + `","manifest":"` + manifest + `","inputs2":{"tree":"` + strings.Repeat("e", 40) + - `"},"repository":"novox/x","commit":"` + strings.Repeat("c", 40) + `"}` - if o, err := DecodeModule([]byte(old)); err != nil || o.Inputs2.Paths != nil { - t.Fatalf("an older value: %+v, %v", o, err) - } - - f := Forge{Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 1, Head: strings.Repeat("h", 40), - Merge: strings.Repeat("m", 40), Root: strings.Repeat("r", 40), Trees: map[string]string{".": strings.Repeat("r", 40)}, - Paths: map[string]map[string]string{".": paths}} - data, err = EncodeForge(f) - if err != nil { - t.Fatal(err) - } - if fb, err := DecodeForge(data); err != nil || fb.Paths["."]["package.json"] != paths["package.json"] { - t.Fatalf("decoded forge %+v, %v", fb.Paths, err) - } - f.Paths = map[string]map[string]string{"other": paths} - if _, err := EncodeForge(f); err == nil { - t.Fatal("named paths of a directory the message has no tree of were accepted") - } - f.Paths = map[string]map[string]string{".": {"src": ""}} - if _, err := EncodeForge(f); err == nil { - t.Fatal("a named path with no object was accepted") - } - - p := Preview{Repository: "novox/mesh-sdk", Head: strings.Repeat("a", 40), Trunk: strings.Repeat("b", 40), - Merge: strings.Repeat("c", 40), Modules: map[string]PreviewModule{".": {Tree: tree, Manifest: manifest, Paths: paths}}} - data, err = EncodePreview(p) - if err != nil { - t.Fatal(err) - } - if pb, err := DecodePreview(data); err != nil || pb.Modules["."].Paths["src"] != paths["src"] { - t.Fatalf("decoded preview %+v, %v", pb.Modules, err) - } - // The preview's tree is the directory's git tree, raw (ADR 0593 §2): the indexer computes the named tree. - p.Modules["."] = PreviewModule{Tree: strings.Repeat("e", 40), Manifest: manifest, Paths: map[string]string{"src": ""}} - if _, err := EncodePreview(p); err == nil { - t.Fatal("a preview whose named path names no object was accepted") - } -} diff --git a/go/delivery/node_logins.go b/go/delivery/node_logins.go deleted file mode 100644 index 2a65964..0000000 --- a/go/delivery/node_logins.go +++ /dev/null @@ -1,137 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "strings" - "time" -) - -// **A node's two logins** (novox/hq ADR 0583, issue 774). A node's bus account rotates between the login `node.` and -// `node.--b`, each with the node's permissions and its inbox `_INBOX.node.` unchanged (§1). Which one a node uses -// is the vault's state `node-logins`, one key per node (§1, insight 2026-11-01), written by the vault alone: the login -// in use, the login rotating if any, and the rotation's stage. Each node-engine reads its own node's key alone; the -// grants part reads them all and composes both logins while a node rotates (§2). The key lives while the node does: -// its rotating part is cleared at rotation-done or abandon, and the key deleted when the node leaves placement. -const ( - NodeLoginsState = "node-logins" - NodeLoginsBucket = VaultModule + "_" + NodeLoginsState - NodeLoginsRead = VaultModule + "." + NodeLoginsState - - // The stages of a rotation (§2-§4): begun when the vault made the other login's value and wrote it rotating; ready - // when the bus node's have shows a list holding both, said node-account-ready; confirmed when the engine called - // node-account-confirm as the new login. Done (rotation-done) is no stage: the rotating part is cleared. - RotationBegun = "begun" - RotationReady = "ready" - RotationConfirmed = "confirmed" - - // The vault's rotation verbs on its seat (§2, §3, §5). - RotateNodeVerb = "rotate-node" - NodeAccountConfirmVerb = "node-account-confirm" - AbandonVerb = "abandon" - - // The vault's events of a rotation, said as its own (§2, §4), each carrying the node's NodeLogin. - RotationBegunEvent = "rotation-begun" - NodeAccountReadyEvent = "node-account-ready" - RotationDoneEvent = "rotation-done" - - // NodeAccountFallbackFinding is what a node-engine says when it fell back to its old login after repeated - // authentication failures with the new one, before rotation-done; the watchman raises it (§6). - NodeAccountFallbackFinding = "node-account-fallback" - - nodeLoginPrefix = "node." - // SecondLoginSuffix marks a node's second login, as a module's account rotates over `--b` (ADR 0114). - SecondLoginSuffix = "--b" -) - -// RotationEventSubject is where the vault says event of a rotation. -func RotationEventSubject(event string) string { return "mesh.mod." + VaultModule + ".event." + event } - -// NodeLogin is one key of `node-logins`, the node's name: the login in use, the login rotating to (empty when none, and -// then no stage), the rotation's stage, since when the key says what it says, and the user list's revision that held -// both logins, from ready on. -type NodeLogin struct { - V int `json:"v"` - Node string `json:"node"` - Login string `json:"login"` - Rotating string `json:"rotating,omitempty"` - Stage string `json:"stage,omitempty"` - Since time.Time `json:"since"` - ListRevision uint64 `json:"list-revision,omitempty"` - // Acts are the hand-act ids that warranted the node's rotations, oldest first (ADR 0583 §2): the vault records each. - Acts []string `json:"acts,omitempty"` -} - -// FirstLogin is a node's first login, `node.`, the one every node had before rotation; SecondLogin its other. -func FirstLogin(node string) string { return nodeLoginPrefix + node } -func SecondLogin(node string) string { return nodeLoginPrefix + node + SecondLoginSuffix } - -// OtherLogin is the login a node rotates to from login: the second from the first, the first from the second. -func OtherLogin(node, login string) string { - if login == SecondLogin(node) { - return FirstLogin(node) - } - return SecondLogin(node) -} - -// LoginNode is the node a node login names, either of its two, and whether login is one. -func LoginNode(login string) (string, bool) { - rest, ok := strings.CutPrefix(login, nodeLoginPrefix) - rest = strings.TrimSuffix(rest, SecondLoginSuffix) - return rest, ok && checkName("node", rest) == nil -} - -// Rotates says the node is rotating: a login rotating is named. -func (l NodeLogin) Rotates() bool { return l.Rotating != "" } - -func (l NodeLogin) check() error { - if err := checkName("node", l.Node); err != nil { - return err - } - if l.Login != FirstLogin(l.Node) && l.Login != SecondLogin(l.Node) { - return fmt.Errorf("delivery: the node %s logs in as %q, neither of its two logins", l.Node, l.Login) - } - for _, act := range l.Acts { - if strings.TrimSpace(act) == "" { - return fmt.Errorf("delivery: the node %s names an empty hand act among its rotations' warrants", l.Node) - } - } - if !l.Rotates() { - if l.Stage != "" || l.ListRevision != 0 { - return fmt.Errorf("delivery: the node %s rotates to nothing and names stage %q", l.Node, l.Stage) - } - return nil - } - if l.Rotating != OtherLogin(l.Node, l.Login) { - return fmt.Errorf("delivery: the node %s rotates from %s to %q, not its other login", l.Node, l.Login, l.Rotating) - } - switch l.Stage { - case RotationBegun, RotationReady, RotationConfirmed: - default: - return fmt.Errorf("delivery: the node %s's rotation is at stage %q, not begun, ready or confirmed", l.Node, l.Stage) - } - return nil -} - -// EncodeNodeLogin is l as JSON at Version; it refuses what DecodeNodeLogin refuses. -func EncodeNodeLogin(l NodeLogin) ([]byte, error) { - if err := l.check(); err != nil { - return nil, err - } - l.V = Version - return json.Marshal(l) -} - -// DecodeNodeLogin reads a key of `node-logins`; it refuses an unknown version (ErrUnknownVersion), a login that is not -// one of the node's two, a rotation to anything but the other, and a stage without a rotation or an unknown one. An -// empty rotating reads as none. -func DecodeNodeLogin(data []byte) (NodeLogin, error) { - var l NodeLogin - if err := decode(data, &l); err != nil { - return NodeLogin{}, err - } - if err := l.check(); err != nil { - return NodeLogin{}, err - } - return l, nil -} diff --git a/go/delivery/nodehealth.go b/go/delivery/nodehealth.go deleted file mode 100644 index 742a604..0000000 --- a/go/delivery/nodehealth.go +++ /dev/null @@ -1,190 +0,0 @@ -package delivery - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "time" -) - -// **A node's own health report on the bus** (issue 774, the operator's alert table of 2026-10-29): one key per node, -// ``, of the node-engine's state `health` (NodeHealthBucket), written by that node's engine alone and only when -// what it says changed. The watchman (seat mesh-watchdogs) reads it for six alerts the controller raised from the -// passing health message: machine-network (Network), network-rewritten (the resolver file's part, Writer and Owner), -// network-unreachable (Peers, read across every node's report), machine-units (Units), relogin-needed (Relogin) and -// directory-used-as-found (Found); and for node-account-fallback (Fallback, ADR 0583 §6). Moved here from mesh-host -// internal/link (65fbc1e6); the engine builds a report from its own health statement there (NodeHealthOf). - -// NodeHealthState and NodeHealthBucket are the node-engine's state health, as a reader names it and as the bus does. -const ( - NodeHealthState = "health" - NodeHealthBucket = "mesh-host_" + NodeHealthState - NodeHealthRead = "mesh-host." + NodeHealthState -) - -// NodeHealthVersion is the version of NodeHealth's layout. -const NodeHealthVersion = 1 - -// NodeHealth is one node's health report, as its engine last found it. -type NodeHealth struct { - V int `json:"v"` - Node string `json:"node"` - // Network is the machine's networking, its parts without their streaks; nil where the engine does not judge it yet. - Network *NetworkHealth `json:"network,omitempty"` - // Peers are the mesh tunnel's peers on the last look that read them, sorted by key. - Peers []PeerSeen `json:"peers,omitempty"` - // Units is the machine's service managers: each failed unit no module places. - Units *UnitsHealth `json:"units,omitempty"` - // Relogin are the accounts whose running session needs a new login for a group it was given. - Relogin []NodeAccount `json:"relogin,omitempty"` - // Found are the directories used as found. - Found []NodeDirectory `json:"found,omitempty"` - // Fallback is set when the engine fell back to its node's old login after the new one failed its test-dial or its - // confirm (novox/hq ADR 0583 §6): the watchman raises node-account-fallback from it. Nil while the node logs in as - // its login in force. - Fallback *LoginFallback `json:"fallback,omitempty"` - // Root are the engine's verdicts on each account a module declares never to become root without a person (novox/hq - // ADR 0266), sorted: the agent account's among them where a module declares it. What the self-check's DA and D-root - // judge an agent's confinement by, on the machine's own word. - Root []RootVerdict `json:"root,omitempty"` - // Contract is the engine's statement contract (mesh-host link's NodeKeyContract): a reader tells an engine older - // than a judging it reads (RootContract for Root) from one that judged nothing wrong. - Contract int `json:"contract,omitempty"` - // At is when the engine wrote this, on a change: never a reason to write. - At time.Time `json:"at"` -} - -// LoginFallback is a node's fall back to its old login (ADR 0583 §6): the node, the login it fell back from (the one the -// rotation moved to), why, in words with no credential, and when. -type LoginFallback struct { - Node string `json:"node"` - From string `json:"from"` - Why string `json:"why"` - At time.Time `json:"at"` -} - -// NetworkHealth is the machine's networking (ADR 0241): the worst of its parts, since when, and each part. -type NetworkHealth struct { - State string `json:"state"` - Since time.Time `json:"since"` - Parts []NetworkPart `json:"parts"` -} - -// NetworkPart is one part of a machine's networking, as its engine judged it on its second look. -type NetworkPart struct { - // Part is resolv-conf, names, tunnel, bus or route. - Part string `json:"part"` - State string `json:"state"` - // Reason is why it is not healthy, in words with no address, path or domain; Said the detail. - Reason string `json:"reason,omitempty"` - Said string `json:"said,omitempty"` - // Writer is the program that rewrote the resolver file, when it can be named; Owner the module whose file it is. - Writer string `json:"writer,omitempty"` - Owner string `json:"owner,omitempty"` - // Toward is what the failure points at: "hub", or each resolver's address that failed. - Toward []string `json:"toward,omitempty"` - // Targets are the mesh machines the failure points at, by node name, as the controller holds a part at the machine - // it points at: the hub's machine for "hub", and the machine whose tunnel address is each resolver's. Empty where the - // part is the machine's own: it points at nothing, at this machine, or at an address no other mesh machine has (the - // resolver a VPN client wrote in). Filled by the node-engine from its tunnel's peers, so the watchman needs no - // address of any machine (issue 774). - Targets []string `json:"targets,omitempty"` - Since time.Time `json:"since"` - Streak int `json:"streak,omitempty"` -} - -// UnitsHealth is the machine's service managers (issue 315). -type UnitsHealth struct { - // State is running, degraded or unknown. - State string `json:"state"` - // Failed is every unit failed on two looks in a row that no module places. - Failed []FailedUnit `json:"failed"` - // Unread names a manager that could not be read, with why; Away an account's own manager not running. - Unread []string `json:"unread,omitempty"` - Away []string `json:"away,omitempty"` -} - -// FailedUnit is one failed unit no module places. -type FailedUnit struct { - Unit string `json:"unit"` - // Scope is system, or user: an account's own manager. - Scope string `json:"scope"` - Load string `json:"load,omitempty"` - Result string `json:"result,omitempty"` - Resource string `json:"resource,omitempty"` - Since time.Time `json:"since"` -} - -// PeerSeen is one peer of the mesh's tunnel: its public key, its allowed addresses, and whether this machine saw a -// handshake with it within five minutes. -type PeerSeen struct { - Key string `json:"key"` - Addresses []string `json:"addresses,omitempty"` - Seen bool `json:"seen"` -} - -// NodeAccount is one account marked relogin needed, with the module whose resource it is and why. -type NodeAccount struct { - Account string `json:"account"` - Module string `json:"module,omitempty"` - Resource string `json:"resource,omitempty"` - Reason string `json:"reason"` - Since time.Time `json:"since"` -} - -// RootContract is the statement contract from which an engine judges an account's root (mesh-host link.RootContract). -const RootContract = 3 - -// RootVerdict is the engine's verdict on one account declared never to become root without a person: healthy (no way -// found), unhealthy (its reason starts "can become root without a person"), or unknown while its setuid search has not -// completed, with the module and resource declaring it. -type RootVerdict struct { - Account string `json:"account"` - Module string `json:"module"` - Resource string `json:"resource"` - State string `json:"state"` - Reason string `json:"reason,omitempty"` - Since time.Time `json:"since"` -} - -// NodeDirectory is one directory used as found: the module declaring it, its resource and path, and why. -type NodeDirectory struct { - Module string `json:"module"` - Resource string `json:"resource"` - Path string `json:"path"` - Reason string `json:"reason"` - Since time.Time `json:"since"` -} - -// EncodeNodeHealth is h as JSON at its version. -func EncodeNodeHealth(h NodeHealth) ([]byte, error) { - h.V = NodeHealthVersion - if h.Node == "" { - return nil, errors.New("a node's health report names its node") - } - if f := h.Fallback; f != nil && (f.Node != h.Node || f.From == "" || f.Why == "" || f.At.IsZero()) { - return nil, fmt.Errorf("a node's fall back to its old login names its node %s, the login fallen back from, why and when", h.Node) - } - return json.Marshal(h) -} - -// DecodeNodeHealth reads a report. -func DecodeNodeHealth(raw []byte) (NodeHealth, error) { - var h NodeHealth - if err := json.Unmarshal(raw, &h); err != nil { - return NodeHealth{}, fmt.Errorf("a node's health report is not readable: %w", err) - } - if h.V != NodeHealthVersion { - return NodeHealth{}, fmt.Errorf("a node's health report of version %d, and this reads %d", h.V, NodeHealthVersion) - } - return h, nil -} - -// SameNodeHealth says two reports say the same, whenever each was written. -func SameNodeHealth(a, b NodeHealth) bool { - a.At, b.At = time.Time{}, time.Time{} - ra, errA := json.Marshal(a) - rb, errB := json.Marshal(b) - return errA == nil && errB == nil && bytes.Equal(ra, rb) -} diff --git a/go/delivery/noderecord.go b/go/delivery/noderecord.go deleted file mode 100644 index 52fa5f3..0000000 --- a/go/delivery/noderecord.go +++ /dev/null @@ -1,111 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// A node's record is what the machine observes of itself (issue 1126, ADR 0555 §3), written by its own agent in its -// own have key, `.node` of the node-engine's have bucket (HaveNode.Record), whenever an observed fact changes and -// only when the value differs. The key's change is the event its readers hear: every node and the parts that compose -// watch it. Where a module runs, and every fact a person decides of the node (its endpoint, site, hub, address on the -// private network, public domain, accounts, the tunnel found at adoption), are the catalogue's placement -// (placement.go). Nothing in it is secret: its keys are public halves. - -// RecordTunnel is a tunnel found on a node when it was adopted, as compose.Tunnel holds it: public keys and addresses -// only. After the node took it over the tunnel is no longer there to observe, so it is kept as what was decided at -// adoption, in the node's placement (NodeDecided.Tunnel). -type RecordTunnel struct { - Interface string `json:"interface"` - Unit string `json:"unit,omitempty"` - Config string `json:"config,omitempty"` - Port int `json:"port,omitempty"` - MTU int `json:"mtu,omitempty"` - Address string `json:"address,omitempty"` - Range string `json:"range,omitempty"` - PublicKey string `json:"public-key,omitempty"` - Peers []RecordTunnelPeer `json:"peers,omitempty"` -} - -// RecordTunnelPeer is one peer of a found tunnel. -type RecordTunnelPeer struct { - PublicKey string `json:"public-key"` - Address string `json:"address,omitempty"` -} - -// NodeRecord is one node's record: what the machine observes of itself (issue 1126, ADR 0555: what a person decides -// lives in the catalogue's placement file, Placement.Decided; what the machine observes lives here). TunnelKey is the -// public key of its private-network tunnel; Certificate the one the mesh issued for its internal name, public, as the -// node holds it. The tunnel found at adoption and the names given to its peers are decisions kept in the placement; -// whether the hub took its found tunnel over, and which node enrolled under a carried peer's key, are read from both. -type NodeRecord struct { - V int `json:"v"` - Node string `json:"node"` - TunnelKey string `json:"tunnel-key,omitempty"` - Capabilities []string `json:"capabilities,omitempty"` - SealingKey string `json:"sealing-key,omitempty"` - ServingKey string `json:"serving-key,omitempty"` - Certificate string `json:"certificate,omitempty"` - OutwardLinks []string `json:"outward-links,omitempty"` - Networks []catalogue.AttachedNetwork `json:"networks,omitempty"` - NetworksSaid bool `json:"networks-said,omitempty"` - Contract int `json:"contract,omitempty"` - // Authority is the mesh's own certificate as this machine holds it (public): the one its declaration last wrote where - // a module asked for it. A node composing its own composes a certificate with it (compose.Input.Authority). - Authority string `json:"authority,omitempty"` - // Trusts is what this machine trusts a mesh signature from now (Trusts in signers.go, ADR 0550 §2): the controller's - // key until the lease is handed away, and the vault's key and authority once it reads them from `signers`. The gate - // before the lease write reads every node's from the bus. - Trusts []string `json:"trusts,omitempty"` - // ManifestReads are the reader capabilities of the shared contract this machine's node-engine is built with - // (catalogue.ManifestReads, novox/hq ADR 0614 §4): the manifest words, forms and seat names it reads. Empty for an - // engine built before it said them, which a reader takes as the baseline (catalogue.StatedManifestReads). - ManifestReads []string `json:"manifest-reads,omitempty"` -} - -func (r NodeRecord) check() error { - if err := checkName("node", r.Node); err != nil { - return err - } - for _, c := range r.Capabilities { - if c == "" { - return fmt.Errorf("delivery: the record of %s names an empty capability", r.Node) - } - } - for _, c := range r.ManifestReads { - if c == "" { - return fmt.Errorf("delivery: the record of %s names an empty reader capability", r.Node) - } - } - return nil -} - -// EncodeNodeRecord is r as JSON at Version; it refuses what DecodeNodeRecord refuses. -func EncodeNodeRecord(r NodeRecord) ([]byte, error) { - if err := r.check(); err != nil { - return nil, err - } - r.V = Version - return json.Marshal(r) -} - -// DecodeNodeRecord reads a node record; it refuses an unknown version (ErrUnknownVersion), a node that is not a name, and -// an empty capability. -func DecodeNodeRecord(data []byte) (NodeRecord, error) { - var r NodeRecord - if err := decode(data, &r); err != nil { - return NodeRecord{}, err - } - if err := r.check(); err != nil { - return NodeRecord{}, err - } - return r, nil -} - -// SameNodeRecord says whether two records are the same value, so the agent writes only when they differ. -func SameNodeRecord(a, b NodeRecord) bool { - a.V, b.V = 0, 0 - return jsonEqual(a, b) -} diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go deleted file mode 100644 index 2bdd675..0000000 --- a/go/delivery/noderecord_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package delivery - -import ( - "encoding/base64" - "reflect" - "regexp" - "strconv" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// secretNamed is facts.secretKey's pattern: a field so named would read as a secret, and the runtime's credential-field -// check (ADR 0201 §13) refuses such a field in a state every node reads. -var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`) - -// Issue 1126 (ADR 0555), and issue 774 for the enrolment's keys (ADR 0587 §2): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and -// neither it nor the placement file names a field like a secret. -func TestNodeRecord(t *testing.T) { - hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub", - Certificate: "cert", OutwardLinks: []string{"eth0"}, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, - NetworksSaid: true, Contract: 3, Trusts: []string{"controller key sha256:aa", "vault key sha256:bb"}, - ManifestReads: []string{"seat:mesh-catalog", "word:tool-runner"}} - raw, err := EncodeNodeRecord(hub) - if err != nil { - t.Fatal(err) - } - back, err := DecodeNodeRecord(raw) - if err != nil || !SameNodeRecord(back, hub) || back.V != Version { - t.Fatalf("round trip: %+v, %v", back, err) - } - // It rides in the node's own have key: a have that differs only in its record is another value. - with, without := HaveNode{V: Version, Record: &hub}, HaveNode{V: Version} - if raw, err := EncodeHaveNode(with); err != nil { - t.Fatal(err) - } else if got, err := DecodeHaveNode(raw); err != nil || got.Record == nil || !SameHaveNode(got, with) || SameHaveNode(got, without) { - t.Fatalf("the have's record: %+v, %v", got, err) - } - changed := hub - changed.Contract = 4 - if SameHaveNode(with, HaveNode{V: Version, Record: &changed}) { - t.Error("a changed record reads as the same have") - } - - for name, r := range map[string]NodeRecord{ - "empty capability": {Node: "ace", Capabilities: []string{""}}, - "empty reader capability": {Node: "ace", ManifestReads: []string{""}}, - "node that is not a name": {Node: "a.b"}, - } { - if _, err := EncodeNodeRecord(r); err == nil { - t.Errorf("%s: taken", name) - } - } - - // Every JSON field name of the record and the types it carries, walked: none reads as a secret. - var walk func(reflect.Type, string) - walk = func(ty reflect.Type, at string) { - for ty.Kind() == reflect.Pointer || ty.Kind() == reflect.Slice { - ty = ty.Elem() - } - if ty.Kind() != reflect.Struct { - return - } - for i := 0; i < ty.NumField(); i++ { - f := ty.Field(i) - name, _, _ := strings.Cut(f.Tag.Get("json"), ",") - if secretNamed.MatchString(name) || secretNamed.MatchString(f.Name) { - t.Errorf("%s.%s (%q) is named like a secret", at, f.Name, name) - } - walk(f.Type, at+"."+f.Name) - } - } - walk(reflect.TypeOf(NodeRecord{}), "NodeRecord") - // 774/S35/AC1: an enrolment key and an address key read back equal, and neither names a field like a secret: the - // grants part reads them (ADR 0587 §2). - issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC) - // The tunnel key given at issue, at every stage. - wg := base64.StdEncoding.EncodeToString(make([]byte, 32)) - for _, e := range []Enrolment{ - {Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentLive, - OverlayKey: wg, Issued: issued, Expires: issued.Add(DefaultTokenLife)}, - {Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentRedeeming, - OverlayKey: wg, Issued: issued, Expires: issued.Add(MostTokenLife), KeysDigest: KeysDigest("pub", "overlay", "sealing", "serving")}, - } { - raw, err := EncodeEnrolment(e) - if err != nil { - t.Fatal(err) - } - back, err := DecodeEnrolment(raw) - e.V = Version - if err != nil || !reflect.DeepEqual(back, e) { - t.Errorf("enrolment round trip: %+v, %v", back, err) - } - } - // ADR 0589 §6: reserved before the ask with neither ask nor issue time, and from the ask with both. - if raw, err := EncodeEnrolmentAddress("lab", "", time.Time{}); err != nil { - t.Fatal(err) - } else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Node != "lab" || back.Ask != "" || strings.Contains(string(raw), "issued") { - t.Errorf("address round trip: %+v, %v (%s)", back, err, raw) - } - if raw, err := EncodeEnrolmentAddress("lab", "a1", issued); err != nil { - t.Fatal(err) - } else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Ask != "a1" || !back.Issued.Equal(issued) { - t.Errorf("address at the ask round trip: %+v, %v", back, err) - } - for name, raw := range map[string]string{ - "no node": `{"v":1}`, - "an ask with no issue time": `{"v":1,"node":"lab","ask":"a1"}`, - "an issue time with no ask": `{"v":1,"node":"lab","issued":"2026-11-02T01:00:00Z"}`, - } { - if _, err := DecodeEnrolmentAddress([]byte(raw)); err == nil { - t.Errorf("an address with %s: taken", name) - } - } - if id := EnrolmentIssuedID("lab", issued); id != "enrolment-issued.lab."+strconv.FormatInt(issued.UnixNano(), 10) { - t.Errorf("the issued event's id: %q", id) - } - for name, e := range map[string]Enrolment{ - "keys before redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentLive, - OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour), KeysDigest: KeysDigest("a", "b", "c", "d")}, - "no keys at redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentRedeeming, - OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)}, - "no address": {Node: "lab", Why: "w", Caller: "c", Stage: EnrolmentAsked, OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)}, - "no tunnel key": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked, Issued: issued, - Expires: issued.Add(time.Hour)}, - "a tunnel key not of 32 bytes": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked, - OverlayKey: base64.StdEncoding.EncodeToString(make([]byte, 31)), Issued: issued, Expires: issued.Add(time.Hour)}, - "other stage": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: "spent", OverlayKey: wg, Issued: issued, - Expires: issued.Add(time.Hour)}, - } { - if _, err := EncodeEnrolment(e); err == nil { - t.Errorf("enrolment with %s: taken", name) - } - } - if key, ok := EnrolmentKeyAddress(EnrolmentAddressKey("10.0.0.7")); !ok || key != "10.0.0.7" { - t.Errorf("address key: %q %v", key, ok) - } - if node, ok := EnrolmentKeyNode(EnrolmentNodeKey("lab")); !ok || node != "lab" { - t.Errorf("node key: %q %v", node, ok) - } - for _, life := range []string{"", "1h", "24h"} { - if _, err := TokenLife(life); err != nil { - t.Errorf("a life of %q: %v", life, err) - } - } - for _, life := range []string{"25h", "-1h", "a day"} { - if _, err := TokenLife(life); err == nil { - t.Errorf("a life of %q: taken", life) - } - } - // 774/S38/AC1: a node's key of node-logins reads back equal, an empty rotating reads as none, and a stage without a - // rotation, a rotation to anything but the other login, or a login not the node's is refused (ADR 0583 §1). - for _, l := range []NodeLogin{ - {Node: "g14", Login: "node.g14", Since: issued}, - {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: RotationReady, Since: issued, ListRevision: 41}, - {Node: "g14", Login: "node.g14--b", Rotating: "node.g14", Stage: RotationBegun, Since: issued}, - } { - raw, err := EncodeNodeLogin(l) - if err != nil { - t.Fatal(err) - } - back, err := DecodeNodeLogin(raw) - l.V = Version - if err != nil || !reflect.DeepEqual(back, l) { - t.Errorf("node login round trip: %+v, %v", back, err) - } - } - // The hand acts that warranted its rotations, as the vault records them. - if raw, err := EncodeNodeLogin(NodeLogin{Node: "g14", Login: "node.g14--b", Acts: []string{"act-1"}}); err != nil || - !strings.Contains(string(raw), `"acts":["act-1"]`) { - t.Errorf("a login with its acts: %s, %v", raw, err) - } - if l, err := DecodeNodeLogin([]byte(`{"v":1,"node":"g14","login":"node.g14","rotating":"","since":"2026-11-02T01:00:00Z"}`)); err != nil || l.Rotates() { - t.Errorf("an empty rotating: %+v, %v", l, err) - } - for name, l := range map[string]NodeLogin{ - "a stage, no rotation": {Node: "g14", Login: "node.g14", Stage: RotationBegun}, - "another node's login": {Node: "g14", Login: "node.ace"}, - "a rotation to itself": {Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun}, - "a rotation, no stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b"}, - "a rotation, done stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: "done"}, - "an empty act": {Node: "g14", Login: "node.g14", Acts: []string{""}}, - } { - if _, err := EncodeNodeLogin(l); err == nil { - t.Errorf("node login with %s: taken", name) - } - } - if node, ok := LoginNode("node.g14--b"); !ok || node != "g14" || OtherLogin("g14", "node.g14--b") != "node.g14" { - t.Errorf("the second login's node: %q %v", node, ok) - } - walk(reflect.TypeOf(NodeLogin{}), "NodeLogin") - walk(reflect.TypeOf(Enrolment{}), "Enrolment") - walk(reflect.TypeOf(EnrolmentAddress{}), "EnrolmentAddress") - walk(reflect.TypeOf(EnrolmentExpiry{}), "EnrolmentExpiry") - // enrolment-expired names the issued event by its message id (§2), never a stream sequence. - if raw, err := EncodeEnrolmentExpiry(EnrolmentExpiry{Node: "lab", IssuedID: "enrolment-issued.lab.1"}); err != nil { - t.Errorf("an expiry: %v", err) - } else if x, err := DecodeEnrolmentExpiry(raw); err != nil || x.IssuedID != "enrolment-issued.lab.1" || - !strings.Contains(string(raw), `"issued-event":"enrolment-issued.lab.1"`) { - t.Errorf("an expiry reads back as %+v, %v (%s)", x, err, raw) - } - for name, raw := range map[string]string{ - "no issued event": `{"v":1,"node":"lab"}`, - "a sequence for it": `{"v":1,"node":"lab","issued-event":7}`, - "a node not a name": `{"v":1,"node":"Lab Pi","issued-event":"e"}`, - } { - if _, err := DecodeEnrolmentExpiry([]byte(raw)); err == nil { - t.Errorf("an expiry with %s: taken", name) - } - } - // And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential. - walk(reflect.TypeOf(Placement{}), "Placement") -} diff --git a/go/delivery/own_drift.go b/go/delivery/own_drift.go deleted file mode 100644 index 546a4ee..0000000 --- a/go/delivery/own_drift.go +++ /dev/null @@ -1,95 +0,0 @@ -package delivery - -// The own secrets a node's shadow answer finds the vault holds another value for than the node runs (novox/hq issue 1126, -// cause C4): the vault's `move-in` never replaces a value it holds, so a value the controller made anew later (a rotation -// at start, a re-issue) runs while the vault keeps the first one it was handed. The node opens both seals with its own -// key and its answer's shown bodies mask the value as MaskedAnotherValue; this reads which own secrets those are, for the -// node agent (which hands the vault what runs) and for the vault's `own_drift` (which lists them mesh-wide), so both read -// the answer one way. - -import ( - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// MaskedAnotherValue is the word a shown body masks a value with when the node opened both sides with its key and they -// are two values (MaskedPair, maskWord). -const MaskedAnotherValue = "" - -// OwnValueDiffers is one own secret of a module whose value composed (the vault's) is another than the one that runs. -type OwnValueDiffers struct { - Module string `json:"module"` - Name string `json:"name"` -} - -// ownNeedPrefix starts the id of the file a module's own secret is placed in (catalogue.NeedID). -var ownNeedPrefix = catalogue.NeedID("") - -// OwnNeedName is the name of module's own secret whose file a resource id names, and whether it names one. On a node a -// resource's id carries its module's name (`only-office.needs-jwt`, as a live shadow answer shows it); the bare form a -// declaration composes (`needs-jwt`, catalogue.NeedID) is read too. An id of another module's is not this module's. -func OwnNeedName(module, id string) (string, bool) { - if module != "" { - id = strings.TrimPrefix(id, module+".") - } - name, own := strings.CutPrefix(id, ownNeedPrefix) - return name, own && name != "" && !strings.Contains(name, ".") -} - -// OwnValuesDiffer is every own secret a's shown bodies say holds another value composed than running: a body of a -// module's own secret's file (OwnNeedName: `.needs-` on a node) masked MaskedAnotherValue on either side. Sorted by module and name, each -// once. A body the answer did not show (beyond MaxBodies) is not read. -func OwnValuesDiffer(a CandidateAnswer) []OwnValueDiffers { - seen := map[OwnValueDiffers]bool{} - var out []OwnValueDiffers - for _, d := range a.Differs { - for _, b := range d.Bodies { - name, own := OwnNeedName(d.Module, b.ID) - if !own || !anotherValue(b) { - continue - } - o := OwnValueDiffers{Module: d.Module, Name: name} - if !seen[o] { - seen[o] = true - out = append(out, o) - } - } - } - sort.Slice(out, func(i, j int) bool { - if out[i].Module != out[j].Module { - return out[i].Module < out[j].Module - } - return out[i].Name < out[j].Name - }) - return out -} - -// ValuesDiffer is how many shown bodies of a hold a value masked MaskedAnotherValue, own secrets and every other -// resource alike: the count a node's answer carries as CandidateAnswer.ValuesDiffer. -func ValuesDiffer(a CandidateAnswer) int { - n := 0 - for _, d := range a.Differs { - for _, b := range d.Bodies { - if anotherValue(b) { - n++ - } - } - } - return n -} - -// WithValuesDiffer is answers with each one's ValuesDiffer counted. -func WithValuesDiffer(answers map[string]CandidateAnswer) map[string]CandidateAnswer { - out := make(map[string]CandidateAnswer, len(answers)) - for g, a := range answers { - a.ValuesDiffer = ValuesDiffer(a) - out[g] = a - } - return out -} - -func anotherValue(b ResourceBodies) bool { - return strings.Contains(b.Composed, MaskedAnotherValue) || strings.Contains(b.Running, MaskedAnotherValue) -} diff --git a/go/delivery/packages.go b/go/delivery/packages.go deleted file mode 100644 index fc08fcc..0000000 --- a/go/delivery/packages.go +++ /dev/null @@ -1,28 +0,0 @@ -package delivery - -import ( - "strings" -) - -// A mesh package is a Go module of a repository of the mesh's forge (ForgeModulePrefix): mesh-sdk's Go module, and an -// owner module's package (novox/hq ADR 0593 §1). The indexer alone reads which packages a module imports, their content -// digests and their latest published versions, and writes them in its state (PublishedPackages, ADR 0593 §2); a build -// writes the version it is asked into its own go.mod and go.sum (StepAsk.Packages). - -// ForgeModulePrefix is the module path prefix of the Go modules the mesh's forge serves: a Go module whose path starts -// with it is a mesh package, git.novox.be/novox/[/]. -const ForgeModulePrefix = "git.novox.be/novox/" - -// PackageRepository is the repository (/) a mesh package is published from and the package's directory in -// it ("" for its root); false for a path that is not a forge module's. -func PackageRepository(pkg string) (repository, dir string, ok bool) { - rest, found := strings.CutPrefix(pkg, ForgeModulePrefix) - if !found || rest == "" { - return "", "", false - } - name, dir, _ := strings.Cut(rest, "/") - if name == "" { - return "", "", false - } - return "novox/" + name, dir, true -} diff --git a/go/delivery/placed.go b/go/delivery/placed.go deleted file mode 100644 index 3a4a749..0000000 --- a/go/delivery/placed.go +++ /dev/null @@ -1,157 +0,0 @@ -package delivery - -import ( - "fmt" - "maps" - "slices" - "sync" - "time" -) - -// Placed is where the placement (the seat mesh-placement's state `placement`, held by the assigner; ADR 0567, ADR 0549 §3) places each module, and since when: -// the one reader of that state for every part that asks "does module stand on node" (issue 774, design 47's third -// milestone), in place of each part reading the controller's assignments (`mesh-controller_assignments`, which goes -// with the controller's lease, ADR 0549 §6). -// -// Its input is the state's revisions in the order the bus wrote them, as a watch with the history (jetstream's -// IncludeHistory) hands them: Put takes each, in either form of the state's keys (placement_keys.go): a node's node key -// names its modules (a module key says nothing of which stand), and its legacy key `placement.` does until a node -// key of it is heard. A module stands since the revision of its node's key that first named it -// in an unbroken run. A module in the first revision heard of a key stands since the zero time: that revision is the -// key's first (the seed of ADR 0549 §4 placed what already ran there) or the oldest the bus still keeps -// (PlacementHistory), and either way the module may have run there before it. A key's value that does not read places -// nothing on its node, and Put says why. -// -// Safe for use from a watch and from a reader at once. -type Placed struct { - mu sync.Mutex - since map[string]map[string]time.Time // node -> module -> since when it stands - heard map[string]bool // the nodes whose key was heard at all - // newForm are the nodes whose node key `.node` was heard: their legacy key `placement.` is no longer - // read (placement_keys.go). - newForm map[string]bool -} - -// NewPlaced is a Placed that holds no placement yet. -func NewPlaced() *Placed { - return &Placed{since: map[string]map[string]time.Time{}, heard: map[string]bool{}, newForm: map[string]bool{}} -} - -// Put takes one revision of a key of the state `placement` (its value, whether it is a delete, and when the bus wrote -// it). It answers the node the key is of and the modules whose standing on it changed (placed or removed), in name -// order, and an error for a key or a value that does not read (the node then places nothing). -func (p *Placed) Put(key string, value []byte, deleted bool, at time.Time) (node string, changed []string, err error) { - node, _, kind, err := ParsePlacementKey(key) - if err != nil { - return "", nil, err - } - if kind == PlacementModuleKind { - return node, nil, nil // which modules stand is the node key's to say - } - p.mu.Lock() - if kind == PlacementLegacyKind && p.newForm[node] { - p.mu.Unlock() - return node, nil, nil // the node is read from its node key once one was heard - } - if kind == PlacementNodeKind { - p.newForm[node] = true - } - p.mu.Unlock() - now := map[string]bool{} - if !deleted { - var modules []string - var derr error - var named string - if kind == PlacementNodeKind { - var n PlacementNode - n, derr = DecodePlacementNode(value) - named = n.Node - for _, r := range n.Modules { - modules = append(modules, r.Module) - } - } else { - var pl Placement - pl, derr = DecodePlacement(value) - named, modules = pl.Node, pl.Assigned() - } - switch { - case derr != nil: - err = fmt.Errorf("the placement of %s does not read, so it places nothing there: %w", node, derr) - case named != node: - err = fmt.Errorf("the placement under %s names the node %q, so it places nothing there", key, named) - default: - for _, m := range modules { - now[m] = true - } - } - } - p.mu.Lock() - defer p.mu.Unlock() - if !p.heard[node] { - p.heard[node] = true - at = time.Time{} - } - was := p.since[node] - next := map[string]time.Time{} - for m := range now { - if s, ok := was[m]; ok { - next[m] = s - } else { - next[m] = at - changed = append(changed, m) - } - } - for m := range was { - if !now[m] { - changed = append(changed, m) - } - } - if len(next) == 0 { - delete(p.since, node) - } else { - p.since[node] = next - } - slices.Sort(changed) - return node, changed, err -} - -// Seeded says some node's placement is held: before the assigner's adopt writes the first placement nothing is placed, -// and a reader keeps what it did before (ADR 0549 §5). -func (p *Placed) Seeded() bool { - p.mu.Lock() - defer p.mu.Unlock() - return len(p.since) > 0 -} - -// Since says whether module stands on node, and since when (the zero time: since before the revisions kept). -func (p *Placed) Since(node, module string) (time.Time, bool) { - p.mu.Lock() - defer p.mu.Unlock() - s, ok := p.since[node][module] - return s, ok -} - -// PlacedNodes is every node module is placed on, in name order. -func (p *Placed) PlacedNodes(module string) []string { - p.mu.Lock() - defer p.mu.Unlock() - var out []string - for node, mods := range p.since { - if _, ok := mods[module]; ok { - out = append(out, node) - } - } - slices.Sort(out) - return out -} - -// ByNode is, by node, the modules placed on it in name order. -func (p *Placed) ByNode() map[string][]string { - p.mu.Lock() - defer p.mu.Unlock() - out := make(map[string][]string, len(p.since)) - for node, mods := range p.since { - out[node] = slices.Sorted(maps.Keys(mods)) - } - return out -} diff --git a/go/delivery/placement.go b/go/delivery/placement.go deleted file mode 100644 index ec8c438..0000000 --- a/go/delivery/placement.go +++ /dev/null @@ -1,467 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "sort" - "strconv" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// Placement of modules on nodes is the state of the seat mesh-placement (novox/hq ADR 0567), held once for the mesh by -// the assigner: in its state `placement`, one node key `.node` and one key `.` per module placed -// (placement_keys.go; research 072 row 37), written only on the operator's Approve of a proposal the nodes it touches -// answered (§2-§5), by compare-and-set at the revision the proposal was laid over. No -// repository holds it. The node agents, the grants part, mesh-config and the status view read it (§9); nothing in it is -// secret. Its states are named after the holding module, as every module state is, and these constants are the seat's -// contract: every reader takes the names from here (§1). -const ( - // PlacementState is the seat's state of placements, and PlacementBucket its bucket. PlacementRead is the name a - // reader's manifest declares in its reads. - PlacementState = "placement" - PlacementBucket = AssignerModule + "_" + PlacementState - PlacementRead = AssignerModule + "." + PlacementState - // PlacementHistory is the revisions the placement bucket keeps per key: the bus's most (§6). - PlacementHistory = 64 - - // PlacementStream is the holder's stream of the seat's events, kept PlacementStreamDays (§6, within the mesh's bound - // on a module's stream, catalogue.StreamMostDays), and declared as the holder's data so the mesh's backups keep it. - PlacementStream = "placement" - PlacementStreamDays = 30 - - // The seat's events, said by its holder as its own (§2, §4, §5, §6), each carrying a PlacementEvent. - PlacementProposedEvent = "placement-proposed" - PlacementWithdrawnEvent = "placement-withdrawn" - PlacementJudgedEvent = "placement-judged" - PlacementChangedEvent = "placement-changed" - - placementWord = "placement" -) - -// PlacementEvents are the seat's four events, in the order a proposal says them. -var PlacementEvents = []string{PlacementProposedEvent, PlacementJudgedEvent, PlacementChangedEvent, PlacementWithdrawnEvent} - -// Provider is the module on a node that answers a requirement or holds a seat: a pin's or a binding's provider. -type Provider struct { - Node string `json:"node"` - Module string `json:"module"` -} - -// Chosen is p as the composition names a chosen provider. -func (p Provider) Chosen() catalogue.Chosen { return catalogue.Chosen{Node: p.Node, Module: p.Module} } - -// PlacedModule is one module placed on the node: the host port each port it declares is published on (by the port the -// software listens on), and, by provision, the provider each of its requirements that keeps data is bound to. -// -// MeshWide and Layer are where the module's things are, as a settings layer's placement holds them (catalogue.Layer's -// Placement: `places`, `accesses`, `networks`, `endpoints`, `expose`, `reach`, `ports`; ADR 0348 §11): the whole mesh's -// layer and this node's, which the controller keeps in its table `placement` (ADR 0549 §6 names it) and composes as two -// layers, the mesh's first. Each node's file repeats the mesh-wide layer of the modules placed on it, so a node reads -// its own key alone. HasLayer says the node has a layer of its own even when it holds no placement, since a layer -// that is there and empty still carries the module's config values (Layers). -// -// MeshValues and Values are the config values of a module in the settings form (its manifest declares no config, so -// mesh-config holds none for it): what a person set in the whole mesh's layer and this node's, beside the placement -// (issue 1126; on 2026-10-26 the media modules on one node, plex's puid and pgid among them). A module in the config -// form takes its values from mesh-config and has none here. -type PlacedModule struct { - Module string `json:"module"` - Ports map[string]int `json:"ports,omitempty"` - Bindings map[string]Provider `json:"bindings,omitempty"` - MeshWide map[string]any `json:"mesh-wide,omitempty"` - HasMesh bool `json:"has-mesh-wide,omitempty"` - Layer map[string]any `json:"layer,omitempty"` - HasLayer bool `json:"has-layer,omitempty"` - MeshValues map[string]any `json:"mesh-wide-values,omitempty"` - Values map[string]any `json:"values,omitempty"` -} - -// Holding is one seat a module placed on the node holds (catalogue.Held without its node, which is the file's). -type Holding struct { - Claim string `json:"claim"` - Scope string `json:"scope,omitempty"` - Module string `json:"module"` - Site string `json:"site,omitempty"` - Kind string `json:"kind,omitempty"` -} - -// Placement is one node's placement, the value of its key. Modules are in the order the composition reads the node's -// modules in. Revision is unused since placement left the catalogue (ADR 0567): the key's own revision is the bus's. Pins is, by -// provision, the provider chosen for the node where more than one could answer. -type Placement struct { - V int `json:"v"` - Node string `json:"node"` - Revision string `json:"revision,omitempty"` - Decided NodeDecided `json:"decided"` - Modules []PlacedModule `json:"modules"` - Pins map[string]Provider `json:"pins,omitempty"` - Holds []Holding `json:"holds,omitempty"` -} - -// NodeDecided is what a person decides of the node, kept in its placement key (issue 1126's K2: what a person decides -// lives in placement, what the machine observes lives in its record, NodeRecord): where it is dialled (endpoint) and -// its site, whether it is the private network's hub, its address on the private network, the domain its routed names -// are composed under, its operator and agent logins by name and their homes, whether it is adopted rather than -// converged, the modules taken on an adopted node, the tunnel found on it at adoption and, on the hub, the names given -// to that tunnel's peers. The address is a decision written once, at enrolment, by an -// approved proposal (ADR 0567 §2): nothing allocates it at run time, and CheckPlacements refuses two nodes with one address. -// No secret and no key material: accounts are names, never credentials. -type NodeDecided struct { - Endpoint string `json:"endpoint,omitempty"` - Site string `json:"site,omitempty"` - Hub bool `json:"hub,omitempty"` - Address string `json:"address,omitempty"` - PublicDomain string `json:"public-domain,omitempty"` - Account string `json:"account,omitempty"` - AccountHome string `json:"account-home,omitempty"` - AgentAccount string `json:"agent-account,omitempty"` - AgentHome string `json:"agent-account-home,omitempty"` - Adopted bool `json:"adopted,omitempty"` - Taken []string `json:"taken,omitempty"` - // Tunnel is the tunnel found on the node when it was adopted, kept as decided then: once the node takes it over it is - // no longer there to observe. PeerNames is, on the hub, the name a person gave a peer of that tunnel, by its public key. - Tunnel *RecordTunnel `json:"tunnel,omitempty"` - PeerNames map[string]string `json:"peer-names,omitempty"` -} - -func (p Placement) check() error { - if err := checkName("node", p.Node); err != nil { - return err - } - seen := map[string]bool{} - for _, m := range p.Modules { - if err := checkName("module", m.Module); err != nil { - return err - } - if seen[m.Module] { - return fmt.Errorf("delivery: the placement of %s names %s twice", p.Node, m.Module) - } - seen[m.Module] = true - for port, at := range m.Ports { - if n, err := strconv.Atoi(port); err != nil || n <= 0 || n > 65535 { - return fmt.Errorf("delivery: %s's port %q on %s is not a port number", m.Module, port, p.Node) - } - if at <= 0 || at > 65535 { - return fmt.Errorf("delivery: %s's port %s on %s is published on %d, which is not a port number", m.Module, port, p.Node, at) - } - } - for provision, to := range m.Bindings { - if provision == "" || to.Node == "" || to.Module == "" { - return fmt.Errorf("delivery: %s on %s has a binding with no provision, node or module", m.Module, p.Node) - } - } - } - for provision, to := range p.Pins { - if provision == "" || to.Node == "" || to.Module == "" { - return fmt.Errorf("delivery: the placement of %s has a pin with no provision, node or module", p.Node) - } - } - for _, h := range p.Holds { - if h.Claim == "" || !seen[h.Module] { - return fmt.Errorf("delivery: the placement of %s has %s hold %q, which is not a module placed there or names no seat", p.Node, h.Module, h.Claim) - } - } - return nil -} - -// EncodePlacement is p as JSON at Version; it refuses what DecodePlacement refuses. -func EncodePlacement(p Placement) ([]byte, error) { - if err := p.check(); err != nil { - return nil, err - } - p.V = Version - return json.Marshal(p) -} - -// DecodePlacement reads a placement file or key value; it refuses an unknown version (ErrUnknownVersion), a node or -// module that is not a name, a module named twice, a port that is not a number, a binding or pin naming no provider, -// and a holding by a module not placed on the node. -func DecodePlacement(data []byte) (Placement, error) { - var p Placement - if err := decode(data, &p); err != nil { - return Placement{}, err - } - if err := p.check(); err != nil { - return Placement{}, err - } - return p, nil -} - -// SamePlacement says whether two placements are the same value, so the holder writes only when they differ. The -// revision is not compared. -func SamePlacement(a, b Placement) bool { - a.Revision, b.Revision = "", "" - ja, errA := EncodePlacement(a) - jb, errB := EncodePlacement(b) - return errA == nil && errB == nil && string(ja) == string(jb) -} - -// PlacementChange is what `placement-changed` says of one node (ADR 0567 §6): the node, the revision of its key before -// (0: it had none) and after (0: the key was deleted), and its modules added, removed or changed against the placement -// before. -type PlacementChange struct { - Node string `json:"node"` - Before uint64 `json:"before"` - After uint64 `json:"after"` - Added []string `json:"added,omitempty"` - Removed []string `json:"removed,omitempty"` - Changed []string `json:"changed,omitempty"` -} - -// ChangeOf is what changed from before to after on one node; before is the zero Placement for a node new to placement. -// A change of the node's pins or holdings counts as a change of every module placed on it both times, since the -// composition of each may read them. -func ChangeOf(before, after Placement) PlacementChange { - c := PlacementChange{Node: after.Node} - if c.Node == "" { - c.Node = before.Node - } - was := map[string]PlacedModule{} - for _, m := range before.Modules { - was[m.Module] = m - } - nodeWide := !jsonEqual(before.Pins, after.Pins) || !jsonEqual(before.Holds, after.Holds) - now := map[string]bool{} - for _, m := range after.Modules { - now[m.Module] = true - old, ok := was[m.Module] - switch { - case !ok: - c.Added = append(c.Added, m.Module) - case nodeWide || !jsonEqual(old, m): - c.Changed = append(c.Changed, m.Module) - } - } - for _, m := range before.Modules { - if !now[m.Module] { - c.Removed = append(c.Removed, m.Module) - } - } - sort.Strings(c.Added) - sort.Strings(c.Removed) - sort.Strings(c.Changed) - return c -} - -func jsonEqual(a, b any) bool { - ja, _ := json.Marshal(a) - jb, _ := json.Marshal(b) - return string(ja) == string(jb) -} - -// Assigned is the modules placed on the node, in the file's order (compose.Node's Assigned). -func (p Placement) Assigned() []string { - out := make([]string, 0, len(p.Modules)) - for _, m := range p.Modules { - out = append(out, m.Module) - } - return out -} - -// ChosenPins is the node's pins as the composition reads them (compose.Node's Pins); nil when it has none. -func (p Placement) ChosenPins() map[string]catalogue.Chosen { - if len(p.Pins) == 0 { - return nil - } - out := make(map[string]catalogue.Chosen, len(p.Pins)) - for provision, to := range p.Pins { - out[provision] = to.Chosen() - } - return out -} - -// Bound is, by consumer module and provision, where each binding sends it (compose.Node's Bound); nil when none. -func (p Placement) Bound() map[string]map[string]catalogue.Chosen { - var out map[string]map[string]catalogue.Chosen - for _, m := range p.Modules { - if len(m.Bindings) == 0 { - continue - } - if out == nil { - out = map[string]map[string]catalogue.Chosen{} - } - out[m.Module] = make(map[string]catalogue.Chosen, len(m.Bindings)) - for provision, to := range m.Bindings { - out[m.Module][provision] = to.Chosen() - } - } - return out -} - -// HostPorts is, by module and the port it declares, the host port it is published on (compose.Node's Ports); nil when -// none. The check has refused a port that is not a number, so none is dropped here. -func (p Placement) HostPorts() map[string]map[int]int { - var out map[string]map[int]int - for _, m := range p.Modules { - if len(m.Ports) == 0 { - continue - } - if out == nil { - out = map[string]map[int]int{} - } - out[m.Module] = make(map[int]int, len(m.Ports)) - for port, at := range m.Ports { - n, _ := strconv.Atoi(port) - out[m.Module][n] = at - } - } - return out -} - -// Layers is one module's settings layers on the node, as the composition reads them (compose.Node's Settings): the -// whole mesh's layer when it has one, then the node's, which carries the module's config values as mesh-config resolved -// them for this node (values, nil when none) beside its placement; a module with no layer and no value has none. This is -// the controller's SettingsFor for a module that takes its config values from mesh-config (issue 664). A module in the -// settings form carries its values in the file (MeshValues, Values), which then stand for mesh-config's. -func (m PlacedModule) Layers(node string, values map[string]any) []catalogue.Layer { - var out []catalogue.Layer - if m.HasMesh || len(m.MeshWide) > 0 || len(m.MeshValues) > 0 { - mesh := map[string]any{} - for k, v := range m.MeshValues { - mesh[k] = v - } - out = append(out, catalogue.Layer{From: catalogue.MeshWideLayer, Values: mesh, Placement: m.MeshWide}) - } - if len(m.Values) > 0 { - values = make(map[string]any, len(m.Values)) - for k, v := range m.Values { - values[k] = v - } - } - if m.HasLayer || len(m.Layer) > 0 || len(values) > 0 { - if values == nil { - values = map[string]any{} - } - out = append(out, catalogue.Layer{From: node, Values: values, Placement: m.Layer}) - } - return out -} - -// Held is the node's holdings as the composition reads them (compose.Input's Holdings rows for this node). -func (p Placement) Held() []catalogue.Held { - out := make([]catalogue.Held, 0, len(p.Holds)) - for _, h := range p.Holds { - out = append(out, catalogue.Held{Claim: h.Claim, Scope: h.Scope, Node: p.Node, Module: h.Module, Site: h.Site, Kind: h.Kind}) - } - return out -} - -// KeptApart are the modules never placed on one node, each row naming the record that asks it (novox/hq ADR 0564 clause 6 -// and Limits 5; issue 1952 S8): no manifest word says it, so the check below holds the table. mesh-watcher hears the -// self-check's `checked` from a second machine (ADR 0227 §6), which on the self-check's own node would fall silent with -// it. -var KeptApart = []struct{ A, B, Record string }{ - {A: "mesh-watcher", B: "mesh-self-check", Record: "ADR 0227 §6, ADR 0564 clause 6"}, -} - -// CheckPlacements is the holder's check of a proposal laid over every placement (ADR 0567 §2), over the placements by -// node, the modules the indexer holds and the nodes with a record. It returns every refusal, in node order, never only the first: a -// placement naming a node not enrolled or a module the catalogue does not hold; two modules of one node on the same host -// port; a pin, binding or holding naming a provider that is not placed where it says; a seat of scope mesh held twice, unless the seat is replicated (catalogue.Seat's Replicated, ADR 0223), which -// is held once per node; -// two nodes with one address on the private network; more than one hub; two modules KeptApart on one node. -// A host port a manifest declares without a number and a requirement left unbound are the composition's to find (its -// ForeseenPorts and Unbound), which the catalogue's composition check reads. -func CheckPlacements(placements map[string]Placement, held func(module string) bool, enrolled func(node string) bool) []string { - var refusals []string - nodes := make([]string, 0, len(placements)) - for n := range placements { - nodes = append(nodes, n) - } - sort.Strings(nodes) - placed := func(p Provider) bool { - other, ok := placements[p.Node] - if !ok { - return false - } - for _, m := range other.Modules { - if m.Module == p.Module { - return true - } - } - return false - } - meshSeats := map[string]string{} - addresses := map[string]string{} - var hubs []string - for _, node := range nodes { - p := placements[node] - if p.Node != node { - refusals = append(refusals, fmt.Sprintf("the placement of %s names the node %q", node, p.Node)) - } - if a := strings.TrimSpace(p.Decided.Address); a != "" { - if other, twice := addresses[a]; twice { - refusals = append(refusals, fmt.Sprintf("%s and %s both have the address %s on the private network", other, node, a)) - } - addresses[a] = node - } - if p.Decided.Hub { - hubs = append(hubs, node) - } - if !enrolled(node) { - refusals = append(refusals, fmt.Sprintf("%s is a node with no record", node)) - } - placedHere := map[string]bool{} - for _, m := range p.Modules { - placedHere[m.Module] = true - } - for _, apart := range KeptApart { - if placedHere[apart.A] && placedHere[apart.B] { - refusals = append(refusals, fmt.Sprintf("%s: %s and %s are never placed on one node (%s)", node, apart.A, apart.B, apart.Record)) - } - } - hostPorts := map[int]string{} - for _, m := range p.Modules { - if !held(m.Module) && !ProvidedByMesh(m.Module) { // the mesh provides the private network itself - refusals = append(refusals, fmt.Sprintf("%s: the indexer holds no module %s", node, m.Module)) - } - for _, port := range sortedKeys(m.Ports) { - at := m.Ports[port] - if other, clash := hostPorts[at]; clash && other != m.Module { - refusals = append(refusals, fmt.Sprintf("%s: %s and %s are both published on host port %d", node, other, m.Module, at)) - } - hostPorts[at] = m.Module - } - for _, provision := range sortedKeys(m.Bindings) { - if to := m.Bindings[provision]; !placed(to) { - refusals = append(refusals, fmt.Sprintf("%s: %s's %s is bound to %s/%s, which is not placed there", node, m.Module, provision, to.Node, to.Module)) - } - } - } - for _, provision := range sortedKeys(p.Pins) { - if to := p.Pins[provision]; !placed(to) { - refusals = append(refusals, fmt.Sprintf("%s: the pin of %s names %s/%s, which is not placed there", node, provision, to.Node, to.Module)) - } - } - for _, h := range p.Holds { - if h.Scope != "mesh" { - continue - } - seat := h.Claim + "/" + h.Kind - if s, known := catalogue.SeatNamed(h.Claim); known && s.Replicated { - // A replicated mesh seat (ADR 0223) is held on several nodes at once, each answering the same thing; - // it is still held once on each node. - seat += "/" + node - } - if other, twice := meshSeats[seat]; twice { - refusals = append(refusals, fmt.Sprintf("the seat %s, held once for the mesh, is held on %s and %s", h.Claim, other, node)) - } - meshSeats[seat] = node - } - } - if len(hubs) > 1 { - refusals = append(refusals, fmt.Sprintf("the private network has one hub, and %s are each placed as it", strings.Join(hubs, ", "))) - } - return refusals -} - -func sortedKeys[V any](m map[string]V) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} diff --git a/go/delivery/placement_candidate.go b/go/delivery/placement_candidate.go deleted file mode 100644 index fe97e98..0000000 --- a/go/delivery/placement_candidate.go +++ /dev/null @@ -1,252 +0,0 @@ -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 (ReturnsToNothing): a - // node with no placement key composes nothing (ADR 0590), so Removed names every module it runs and the verdict is - // theirs. - 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"` -} - -// ReturnsToNothing is what a node answers a proposal that takes its placement key away. -const ReturnsToNothing = "the proposal takes this node's placement away: it composes nothing then" - -// 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 that takes the node's placement away is compared, never refused") - } - 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 -} diff --git a/go/delivery/placement_keys.go b/go/delivery/placement_keys.go deleted file mode 100644 index 47f97bb..0000000 --- a/go/delivery/placement_keys.go +++ /dev/null @@ -1,596 +0,0 @@ -package delivery - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - "sync" -) - -// **One key per module and node** (research 072 row 37, the operator's decision of 2026-10-31; issue 774). The seat -// mesh-placement's state `placement` keeps a node's placement under the node's own keys, so a node watches `.>` -// alone and a change of one module writes that module's key: -// -// - `.node`, the node key: what a person decided of the node (NodeDecided), its pins and its holdings, and the -// modules placed on it in the order the composition reads them, each with the digest of its module key's value. -// The word `node` is the have state's own word for a node's key (HaveNodeWord), so no module is named it. -// - `.`, a module key: the module's ports, bindings and layers (PlacedModule). -// -// The node key is the commit of a write (ADR 0567 §6: a proposal's keys share one revision in history). A writer writes -// the module keys first, each carrying the value it replaces as Previous, then the node key, by compare-and-set at the -// revision the proposal was laid over, and deletes the module keys no longer named last. A reader takes, for each module -// the node key names, the value of its module key whose digest the node key names: the current one once the node key -// is written, the previous one while the write is under way. So a reader never composes a part of a write, and every -// write of a node is one revision of its node key, whose history is the node's placement history as `placement.`'s -// was (Placed, the warrant's record). A module key the node key does not name is not placed. -// -// The form before, one value `placement.` per node (LegacyPlacementKey), is still read during the delivery of -// this change, so readers and the holder may move in any order: a node with a node key is read from its new keys alone, -// one without from its legacy key. The holder converts each legacy value once at its start (PlacementKeys.Migrations), -// keeps the legacy key in step after every commit as a mirror, so a reader of the form before alone still follows every -// write while the members of the delivery move in any order, and deletes it first when a node's placement is deleted, -// so a placement handed back never returns from it. A later change stops the mirror and deletes `placement.*`. -const ( - // PlacementNodeWord is the last word of a node key, `.node`. - PlacementNodeWord = HaveNodeWord - placementDigestLen = 16 -) - -// PlacementKeyKind is the kind of a key of the state `placement`. -type PlacementKeyKind int - -// The kinds of key: a node key, a module key, and the form before (one value per node). -const ( - PlacementNodeKind PlacementKeyKind = iota + 1 - PlacementModuleKind - PlacementLegacyKind -) - -// PlacementNodeKey is a node's node key, `.node`. A node named `placement` is refused: its keys would read as the -// legacy form's. -func PlacementNodeKey(node string) (string, error) { - if err := checkPlacementNode(node); err != nil { - return "", err - } - return node + "." + PlacementNodeWord, nil -} - -// PlacementModuleKey is a module's key on a node, `.`; a module named `node` is refused. -func PlacementModuleKey(node, module string) (string, error) { - if err := checkPlacementNode(node); err != nil { - return "", err - } - if err := checkPlacementModule(module); err != nil { - return "", err - } - return node + "." + module, nil -} - -// PlacementNodeKeys is the pattern of every key of one node, `.>`: what a node watches of its own placement. -func PlacementNodeKeys(node string) (string, error) { - if err := checkPlacementNode(node); err != nil { - return "", err - } - return node + ".>", nil -} - -// LegacyPlacementKey is a node's key in the form before, `placement.`, read while this change is delivered. -func LegacyPlacementKey(node string) (string, error) { - if err := checkName("node", node); err != nil { - return "", err - } - return placementWord + "." + node, nil -} - -// ParsePlacementKey reads a key of the state `placement` in either form: its node, its module (empty for a node key and -// a legacy key) and its kind. Any other key is refused, naming it. -func ParsePlacementKey(key string) (node, module string, kind PlacementKeyKind, err error) { - first, rest, ok := strings.Cut(key, ".") - if !ok || strings.Contains(rest, ".") || checkName("node", first) != nil || checkName("module", rest) != nil { - return "", "", 0, fmt.Errorf("delivery: %q is not a placement key, .node or .", key) - } - switch { - case first == placementWord: - return rest, "", PlacementLegacyKind, nil - case rest == PlacementNodeWord: - return first, "", PlacementNodeKind, nil - default: - return first, rest, PlacementModuleKind, nil - } -} - -func checkPlacementNode(node string) error { - if err := checkName("node", node); err != nil { - return err - } - if node == placementWord { - return fmt.Errorf("delivery: no node is named %q: its placement keys would read as the form before", placementWord) - } - return nil -} - -func checkPlacementModule(module string) error { - if err := checkName("module", module); err != nil { - return err - } - if module == PlacementNodeWord { - return fmt.Errorf("delivery: no module is named %q: it is the word of a node's own key", PlacementNodeWord) - } - return nil -} - -// PlacementRef is one module the node key names, with the digest of the module key's value it stands for. -type PlacementRef struct { - Module string `json:"module"` - Digest string `json:"digest"` -} - -// PlacementNode is the value of a node key: the node's decisions, pins and holdings, and the modules placed on it. -type PlacementNode struct { - V int `json:"v"` - Node string `json:"node"` - Decided NodeDecided `json:"decided"` - Modules []PlacementRef `json:"modules"` - Pins map[string]Provider `json:"pins,omitempty"` - Holds []Holding `json:"holds,omitempty"` -} - -// PlacementModule is the value of a module key: the module as placed, and, while a write that replaces it is under way, -// the value it replaces (Previous), which a reader takes until the node key names the new one. -type PlacementModule struct { - V int `json:"v"` - Node string `json:"node"` - Placed PlacedModule `json:"placed"` - Previous *PlacedModule `json:"previous,omitempty"` -} - -// Digest is the digest of a placed module's value, as a node key names it. -func (m PlacedModule) Digest() string { - raw, _ := json.Marshal(m) - sum := sha256.Sum256(raw) - return hex.EncodeToString(sum[:])[:placementDigestLen] -} - -// EncodePlacementNode is n as JSON at Version. -func EncodePlacementNode(n PlacementNode) ([]byte, error) { - if err := checkPlacementNode(n.Node); err != nil { - return nil, err - } - n.V = Version - return json.Marshal(n) -} - -// DecodePlacementNode reads a node key's value; it refuses an unknown version, a node or module that is not a name, and -// a module named twice or without a digest. -func DecodePlacementNode(data []byte) (PlacementNode, error) { - var n PlacementNode - if err := decode(data, &n); err != nil { - return PlacementNode{}, err - } - if err := checkPlacementNode(n.Node); err != nil { - return PlacementNode{}, err - } - seen := map[string]bool{} - for _, r := range n.Modules { - if err := checkPlacementModule(r.Module); err != nil { - return PlacementNode{}, err - } - if seen[r.Module] || r.Digest == "" { - return PlacementNode{}, fmt.Errorf("delivery: the node key of %s names %s twice or without a digest", n.Node, r.Module) - } - seen[r.Module] = true - } - return n, nil -} - -// EncodePlacementModule is m as JSON at Version. -func EncodePlacementModule(m PlacementModule) ([]byte, error) { - if err := checkPlacementNode(m.Node); err != nil { - return nil, err - } - if err := checkPlacementModule(m.Placed.Module); err != nil { - return nil, err - } - if m.Previous != nil && m.Previous.Module != m.Placed.Module { - return nil, fmt.Errorf("delivery: the key of %s on %s carries %s as its previous value", m.Placed.Module, m.Node, m.Previous.Module) - } - m.V = Version - return json.Marshal(m) -} - -// DecodePlacementModule reads a module key's value. -func DecodePlacementModule(data []byte) (PlacementModule, error) { - var m PlacementModule - if err := decode(data, &m); err != nil { - return PlacementModule{}, err - } - if err := checkPlacementNode(m.Node); err != nil { - return PlacementModule{}, err - } - if err := checkPlacementModule(m.Placed.Module); err != nil { - return PlacementModule{}, err - } - return m, nil -} - -// PlacementKeys is the state `placement` as a reader holds it, key by key in either form, and every node's placement -// assembled from it: the one reader of the state's keys for every part (issue 774), so no part parses them itself. A -// write of the holder's under way is never assembled half: a module key's value is taken only as far as the node key -// names its digest (see the package's comment above PlacementNodeWord), and a node whose keys do not agree keeps the -// placement last assembled for it, Put saying why. -// -// Safe for use from a watch and from a reader at once. -type PlacementKeys struct { - mu sync.Mutex - nodes map[string]*placementKeysOf -} - -// placementKeysOf is what is held of one node's keys. -type placementKeysOf struct { - node *PlacementNode - nodeRev uint64 - modules map[string]PlacementModule - moduleRevs map[string]uint64 - legacy *Placement - legacyRev uint64 - last *Placement // the placement last assembled whole, nil when none -} - -// NewPlacementKeys holds no key yet. -func NewPlacementKeys() *PlacementKeys { return &PlacementKeys{nodes: map[string]*placementKeysOf{}} } - -// PlacementsOf is every node's placement assembled from the state's keys as a reader holds them (key to value; a -// deleted key absent), and an error naming every key that did not read and every node whose keys do not agree (that -// node is then assembled from its module keys' values as they are). -func PlacementsOf(keys map[string][]byte) (map[string]Placement, error) { - k := NewPlacementKeys() - var errs []error - names := make([]string, 0, len(keys)) - for key := range keys { - names = append(names, key) - } - sort.Strings(names) - for _, key := range names { - // A node key read before its module keys does not agree yet; whether the keys agree is asked once all are in. - if _, readErr, _ := k.put(key, keys[key], 0, false); readErr != nil { - errs = append(errs, readErr) - } - } - k.mu.Lock() - for _, node := range sortedKeys(k.nodes) { - if _, _, err := k.nodes[node].assemble(node); err != nil { - errs = append(errs, err) - } - } - k.mu.Unlock() - return k.All(), errors.Join(errs...) -} - -// Put takes one key's value in (revision: the bus's revision of it, 0 when the reader has none; deleted: a delete or a -// purge). It answers the node the key is of and an error for a key or a value that does not read, or for a node whose -// keys do not agree; the node then keeps what it had. -func (k *PlacementKeys) Put(key string, value []byte, revision uint64, deleted bool) (string, error) { - node, readErr, err := k.put(key, value, revision, deleted) - if readErr != nil { - return node, readErr - } - return node, err -} - -// put is Put telling an error of the key or value it took (readErr) from the node's keys not agreeing after it (err). -func (k *PlacementKeys) put(key string, value []byte, revision uint64, deleted bool) (node string, readErr, err error) { - node, module, kind, err := ParsePlacementKey(key) - if err != nil { - return "", err, nil - } - k.mu.Lock() - defer k.mu.Unlock() - of := k.nodes[node] - if of == nil { - of = &placementKeysOf{modules: map[string]PlacementModule{}, moduleRevs: map[string]uint64{}} - k.nodes[node] = of - } - switch kind { - case PlacementNodeKind: - of.node, of.nodeRev = nil, 0 - if !deleted { - n, derr := DecodePlacementNode(value) - switch { - case derr != nil: - readErr = fmt.Errorf("the node key %s does not read, so it places nothing there: %w", key, derr) - case n.Node != node: - readErr = fmt.Errorf("the node key %s names the node %q, so it places nothing there", key, n.Node) - default: - of.node, of.nodeRev = &n, revision - } - } - case PlacementModuleKind: - delete(of.modules, module) - delete(of.moduleRevs, module) - if !deleted { - m, derr := DecodePlacementModule(value) - switch { - case derr != nil: - readErr = fmt.Errorf("the module key %s does not read: %w", key, derr) - case m.Node != node || m.Placed.Module != module: - readErr = fmt.Errorf("the module key %s holds %s on %s", key, m.Placed.Module, m.Node) - default: - of.modules[module] = m - } - // Its revision is kept even when its value does not read, so the holder can write over it. - of.moduleRevs[module] = revision - } - case PlacementLegacyKind: - of.legacy, of.legacyRev = nil, 0 - if !deleted { - p, derr := DecodePlacement(value) - switch { - case derr != nil: - readErr = fmt.Errorf("the placement of %s does not read, so it places nothing there: %w", node, derr) - case p.Node != node: - readErr = fmt.Errorf("the placement under %s names the node %q, so it places nothing there", key, p.Node) - default: - of.legacy, of.legacyRev = &p, revision - } - } - } - p, whole, err := of.assemble(node) - switch { - case p == nil: - of.last = nil - case whole || of.last == nil: - of.last = p - } - return node, readErr, err -} - -// assemble is the node's placement from its keys: nil when none places it, whole false (and an error) when the node key -// names a module digest no module key's value has. -func (of *placementKeysOf) assemble(node string) (*Placement, bool, error) { - if of.node == nil { - if of.legacy == nil { - return nil, true, nil - } - p := clonePlacement(*of.legacy) - return &p, true, nil - } - n := of.node - p := Placement{V: Version, Node: node, Decided: n.Decided, Pins: n.Pins, Holds: n.Holds, Modules: make([]PlacedModule, 0, len(n.Modules))} - var astray []string - for _, r := range n.Modules { - m, ok := of.modules[r.Module] - switch { - case ok && m.Placed.Digest() == r.Digest: - p.Modules = append(p.Modules, m.Placed) - case ok && m.Previous != nil && m.Previous.Digest() == r.Digest: - p.Modules = append(p.Modules, *m.Previous) - case ok: - astray = append(astray, r.Module) - p.Modules = append(p.Modules, m.Placed) - default: - astray = append(astray, r.Module) - } - } - if len(astray) > 0 { - return &p, false, fmt.Errorf("the node key of %s names %s at a value no module key holds: %s keeps the placement it had", - node, strings.Join(astray, ", "), node) - } - return &p, true, nil -} - -// Of is node's placement and the revision of the key it was read from (its node key, or its legacy key), and whether -// any key places it. -func (k *PlacementKeys) Of(node string) (Placement, uint64, bool) { - k.mu.Lock() - defer k.mu.Unlock() - of := k.nodes[node] - if of == nil || of.last == nil { - return Placement{}, 0, false - } - rev := of.nodeRev - if of.node == nil { - rev = of.legacyRev - } - return clonePlacement(*of.last), rev, true -} - -// All is every node's placement, by node, and the revision of each one's key (Of). -func (k *PlacementKeys) All() map[string]Placement { - out, _ := k.AllRevisions() - return out -} - -// AllRevisions is All with the revision of each node's key. -func (k *PlacementKeys) AllRevisions() (map[string]Placement, map[string]uint64) { - k.mu.Lock() - nodes := make([]string, 0, len(k.nodes)) - for n := range k.nodes { - nodes = append(nodes, n) - } - k.mu.Unlock() - out, revs := map[string]Placement{}, map[string]uint64{} - for _, n := range nodes { - if p, rev, ok := k.Of(n); ok { - out[n], revs[n] = p, rev - } - } - return out, revs -} - -// PlacementKeyWrite is one write of a key of the state `placement`, by compare-and-set: Value nil deletes the key at -// Base, Base 0 creates it, any other Base updates it at that revision. -type PlacementKeyWrite struct { - Node string - Key string - Value []byte - Base uint64 - // Commit says this write is the node key's: once it is written, the node's placement is the new one. - Commit bool - // Mirror says this write is the legacy key's, `placement.`, kept in step after the commit for the readers not - // yet delivered: a reader of both forms never reads it once the node key is there, so a mirror write that fails - // harms no such reader, and is made again at the node's next write. - Mirror bool -} - -// Writes are the writes that take node from the placement its keys hold to after (nil: no placement), in the order -// they are made: the module keys that change, each carrying the value it replaces; then the node key, the commit; then -// the legacy key `placement.` with the whole placement, a mirror for the readers not yet delivered, unless it -// holds it already; then the deletes of the module keys after no longer names. For no placement the legacy key goes -// first, then the node key, then the module keys, so a crash between them never leaves the legacy value to be read -// again. A module key whose value stays is not written. -func (k *PlacementKeys) Writes(node string, after *Placement) ([]PlacementKeyWrite, error) { - nodeKey, err := PlacementNodeKey(node) - if err != nil { - return nil, err - } - k.mu.Lock() - defer k.mu.Unlock() - of := k.nodes[node] - if of == nil { - of = &placementKeysOf{modules: map[string]PlacementModule{}, moduleRevs: map[string]uint64{}} - } - var out []PlacementKeyWrite - deleteModules := func(keep map[string]bool) { - for _, module := range sortedKeys(of.moduleRevs) { - if keep[module] { - continue - } - key, _ := PlacementModuleKey(node, module) - out = append(out, PlacementKeyWrite{Node: node, Key: key, Base: of.moduleRevs[module]}) - } - } - if after == nil { - if of.legacy != nil { - key, _ := LegacyPlacementKey(node) - out = append(out, PlacementKeyWrite{Node: node, Key: key, Base: of.legacyRev}) - } - if of.node != nil { - out = append(out, PlacementKeyWrite{Node: node, Key: nodeKey, Base: of.nodeRev, Commit: true}) - } - deleteModules(nil) - return out, nil - } - if after.Node != node { - return nil, fmt.Errorf("delivery: the placement written under %s names the node %q", node, after.Node) - } - if err := after.check(); err != nil { - return nil, err - } - committed := map[string]PlacedModule{} - if of.last != nil { - for _, m := range of.last.Modules { - committed[m.Module] = m - } - } - n := PlacementNode{Node: node, Decided: after.Decided, Pins: after.Pins, Holds: after.Holds, Modules: make([]PlacementRef, 0, len(after.Modules))} - keep := map[string]bool{} - for _, m := range after.Modules { - if err := checkPlacementModule(m.Module); err != nil { - return nil, err - } - digest := m.Digest() - n.Modules = append(n.Modules, PlacementRef{Module: m.Module, Digest: digest}) - keep[m.Module] = true - was, wasPlaced := committed[m.Module] - if held, ok := of.modules[m.Module]; ok && held.Placed.Digest() == digest && - (held.Previous == nil || wasPlaced && was.Digest() == digest) { - continue // the key holds it, and no previous value a reader could take in its place - } - v := PlacementModule{Node: node, Placed: m} - if wasPlaced && was.Digest() != digest { - v.Previous = &was - } - raw, err := EncodePlacementModule(v) - if err != nil { - return nil, err - } - key, _ := PlacementModuleKey(node, m.Module) - out = append(out, PlacementKeyWrite{Node: node, Key: key, Value: raw, Base: of.moduleRevs[m.Module]}) - } - raw, err := EncodePlacementNode(n) - if err != nil { - return nil, err - } - out = append(out, PlacementKeyWrite{Node: node, Key: nodeKey, Value: raw, Base: of.nodeRev, Commit: true}) - // The mirror of the form before, while readers of it alone may run (the delivery of this change); a later change - // stops writing it and deletes it. - if of.legacy == nil || !SamePlacement(*of.legacy, *after) { - legacy, err := EncodePlacement(*after) - if err != nil { - return nil, err - } - key, _ := LegacyPlacementKey(node) - out = append(out, PlacementKeyWrite{Node: node, Key: key, Value: legacy, Base: of.legacyRev, Mirror: true}) - } - deleteModules(keep) - return out, nil -} - -// Migrations are, by node, the writes that convert every node held only in the form before (its legacy key, no node -// key) into its node and module keys, its legacy key kept as the mirror for the readers not yet delivered (Writes); empty -// once every node is converted, so the holder's conversion at each start is idempotent. -func (k *PlacementKeys) Migrations() (map[string][]PlacementKeyWrite, error) { - k.mu.Lock() - var legacy []string - for node, of := range k.nodes { - if of.node == nil && of.legacy != nil && checkPlacementNode(node) == nil { - legacy = append(legacy, node) - } - } - k.mu.Unlock() - sort.Strings(legacy) - out := map[string][]PlacementKeyWrite{} - var errs []error - for _, node := range legacy { - p, _, _ := k.Of(node) - w, err := k.Writes(node, &p) - if err != nil { - errs = append(errs, fmt.Errorf("%s: %w", node, err)) - continue - } - out[node] = w - } - return out, errors.Join(errs...) -} - -// Legacy says node is held only in the form before. -func (k *PlacementKeys) Legacy(node string) bool { - k.mu.Lock() - defer k.mu.Unlock() - of := k.nodes[node] - return of != nil && of.node == nil && of.legacy != nil -} - -// PlacementValues is every key and value of p written whole onto no keys before: its module keys, its node key and the -// legacy mirror, as a writer that holds no key of the node (a test's bus, a fresh mesh) writes them. -func PlacementValues(p Placement) (map[string][]byte, error) { - writes, err := NewPlacementKeys().Writes(p.Node, &p) - if err != nil { - return nil, err - } - out := make(map[string][]byte, len(writes)) - for _, w := range writes { - out[w.Key] = w.Value - } - return out, nil -} - -// InsideAddresses is every node's address on the private network as its placement decides it (NodeDecided.Address), -// sorted and each once: the machines inside the private network, which a membership's Mesh carried until the assignments -// stream went (ADR 0167; research 072 rows 33 and 34). A node with no address decided is left out. -func InsideAddresses(placements map[string]Placement) []string { - seen := map[string]bool{} - out := []string{} - for _, p := range placements { - if a := strings.TrimSpace(p.Decided.Address); a != "" && !seen[a] { - seen[a] = true - out = append(out, a) - } - } - sort.Strings(out) - return out -} diff --git a/go/delivery/placement_proposal.go b/go/delivery/placement_proposal.go deleted file mode 100644 index ab6995a..0000000 --- a/go/delivery/placement_proposal.go +++ /dev/null @@ -1,627 +0,0 @@ -package delivery - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "regexp" - "sort" - "strings" - "time" -) - -// A placement changes only by a proposal (novox/hq ADR 0567 §2): `mesh-placement.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 (PlacementCandidate). The proposal's key leaves the state when it is applied, withdrawn, -// declined or expired; its whole content stays in the event that ends it (PlacementEvent), so no proposal is kept in -// the state once it has ended. -const ( - // PlacementProposalsState is the seat's state of open proposals, PlacementProposalsBucket its bucket and - // PlacementProposalsRead the name a reader's manifest declares; a key is the proposal's id. - PlacementProposalsState = "proposals" - PlacementProposalsBucket = AssignerModule + "_" + PlacementProposalsState - PlacementProposalsRead = AssignerModule + "." + PlacementProposalsState - // PlacementProposalsHistory is the revisions the bucket keeps per key. - PlacementProposalsHistory = 5 - - // PlacementPulsesState is the holder's state of bounds (§4): a key per node a proposal waits on, written with a time - // to live of the answer bound, and a key per raised ask, with its ask's; the server's delete marker when one ends is - // the event that fails a silent node or ends an unanswered ask, so the holder keeps no timer. Its bucket allows a - // time to live per key (catalogue.StateDeclaration.KeyTTL). - PlacementPulsesState = "pulses" - PlacementPulsesBucket = AssignerModule + "_" + PlacementPulsesState -) - -// PulseAnswerKey is the key of the bound on one node's answer to a proposal: `answer..`. -func PulseAnswerKey(proposal, node string) (string, error) { - if err := CheckProposalID(proposal); err != nil { - return "", err - } - if err := checkName("node", node); err != nil { - return "", err - } - return "answer." + proposal + "." + node, nil -} - -// PulseAskKey is the key of the bound on a proposal's raised ask: `ask.`. -func PulseAskKey(proposal string) (string, error) { - if err := CheckProposalID(proposal); err != nil { - return "", err - } - return "ask." + proposal, nil -} - -// The operations a proposal's change names (ADR 0567 §2). -const ( - OpPlace = "place" // place a module on a node, with its host ports and bindings - OpTakeOff = "take-off" // take a module off a node - OpPin = "pin" // pin the provider of a provision on a node - OpUnpin = "unpin" // remove a pin - OpHold = "hold" // a module placed on the node holds a seat - OpUnhold = "unhold" // it holds it no more - 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" - // OpRemoveNode takes a joining node off placement again when its enrolment ends unconfirmed, revoked or expired - // (the operator's decision of 2026-11-02 01:05): mesh-enrolment's alone, on the same warrant, the proposal's one - // change, of a node with no module and no holding. The holder deletes its key (RemovedNodes). - OpRemoveNode = "remove-node" -) - -// The states of an open proposal. -const ( - // ProposalAnswering: its candidates are written and the nodes it must hear from have not all answered. - ProposalAnswering = "answering" - // ProposalFailed: its verdict failed; it stays open, for its proposer to read and withdraw, or for a node's later - // answer to judge it again. - ProposalFailed = "failed" - // ProposalAsked: its verdict passed and the operator is asked to approve it. - ProposalAsked = "asked" -) - -// VerdictWaits is a proposal's verdict while a node it must hear from has not answered within the bound; it passes -// (VerdictPass) or fails (VerdictFail) as a check's verdict does (ADR 0567 §4). -const VerdictWaits = "waits" - -// PlacementProposalChange is one change of a proposal. Node is always named; the rest is the operation's. -type PlacementProposalChange struct { - Op string `json:"op"` - Node string `json:"node"` - Module string `json:"module,omitempty"` - Ports map[string]int `json:"ports,omitempty"` - Bindings map[string]Provider `json:"bindings,omitempty"` - Provision string `json:"provision,omitempty"` - Provider *Provider `json:"provider,omitempty"` - // Claim, Scope, Kind and Site are a holding's (OpHold, OpUnhold). - Claim string `json:"claim,omitempty"` - Scope string `json:"scope,omitempty"` - Kind string `json:"kind,omitempty"` - Site string `json:"site,omitempty"` - // Field and Value are a decided field's (OpDecide): its JSON name in NodeDecided and its value. - Field string `json:"field,omitempty"` - Value json.RawMessage `json:"value,omitempty"` - // MeshWide, Layer, MeshValues and Values are a module's config placement and values (OpConfig), as PlacedModule - // carries them; each given replaces what the module has. - MeshWide map[string]any `json:"mesh-wide,omitempty"` - Layer map[string]any `json:"layer,omitempty"` - MeshValues map[string]any `json:"mesh-wide-values,omitempty"` - 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 its keys: at the operator's yes its tunnel key alone, given at issue (the operator's - // decision of 2026-11-02 01:05); the identity keys are bound at redeem, checked against the node account's digest. - Decided *NodeDecided `json:"decided,omitempty"` - Keys *JoiningKeys `json:"keys,omitempty"` -} - -// JoiningKeys are the public halves of a joining node's keys (ADR 0587 §6, §7): its private network (tunnel) key, given -// at issue, and its identity, sealing and serving keys, presented at redeem, as Redeem carries them. An add-node at the -// operator's yes carries the tunnel key alone. 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 { - if err := checkName("node", c.Node); err != nil { - return err - } - needs := func(what string, ok bool) error { - if !ok { - return fmt.Errorf("delivery: a %s change of %s names no %s", c.Op, c.Node, what) - } - return nil - } - switch c.Op { - case OpPlace, OpTakeOff, OpConfig: - return needs("module", checkName("module", c.Module) == nil) - case OpPin: - return needs("provision or provider", c.Provision != "" && c.Provider != nil && c.Provider.Node != "" && c.Provider.Module != "") - case OpUnpin: - return needs("provision", c.Provision != "") - case OpHold, OpUnhold: - return needs("module or claim", checkName("module", c.Module) == nil && c.Claim != "") - case OpDecide: - return needs("field or value", c.Field != "" && len(c.Value) > 0) - case OpSeed: - if c.Whole == nil || c.Whole.Node != c.Node { - 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.Overlay == "" { - return fmt.Errorf("delivery: 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("delivery: 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("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 - case OpRemoveNode: - if c.Module != "" || c.Decided != nil || c.Keys != nil || c.Whole != nil { - return fmt.Errorf("delivery: the remove-node of %s names more than its node", c.Node) - } - return nil - } - return fmt.Errorf("delivery: %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 DecodePlacement refuses. What CheckPlacements refuses -// across nodes is the caller's to ask of the result laid over current. -func LayChanges(current map[string]Placement, changes []PlacementProposalChange) (map[string]Placement, []string) { - laid := map[string]Placement{} - var findings []string - of := func(node string) Placement { - if p, ok := laid[node]; ok { - return p - } - p, ok := current[node] - if !ok { - return Placement{V: Version, Node: node, Modules: []PlacedModule{}} - } - return clonePlacement(p) - } - for i, c := range changes { - if err := c.check(); 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 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 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 OpPlace: - m := PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings} - if at >= 0 { - m.MeshWide, m.HasMesh, m.Layer, m.HasLayer = p.Modules[at].MeshWide, p.Modules[at].HasMesh, p.Modules[at].Layer, p.Modules[at].HasLayer - m.MeshValues, m.Values = p.Modules[at].MeshValues, p.Modules[at].Values - p.Modules[at] = m - } else { - p.Modules = append(p.Modules, m) - } - case OpTakeOff: - if at < 0 { - notPlaced() - continue - } - p.Modules = append(p.Modules[:at:at], p.Modules[at+1:]...) - var holds []Holding - for _, h := range p.Holds { - if h.Module != c.Module { - holds = append(holds, h) - } - } - p.Holds = holds - case OpConfig: - if at < 0 { - notPlaced() - continue - } - m := &p.Modules[at] - if c.MeshWide != nil { - m.MeshWide, m.HasMesh = c.MeshWide, true - } - if c.Layer != nil { - m.Layer, m.HasLayer = c.Layer, true - } - if c.MeshValues != nil { - m.MeshValues = c.MeshValues - } - if c.Values != nil { - m.Values = c.Values - } - case OpPin: - if p.Pins == nil { - p.Pins = map[string]Provider{} - } - p.Pins[c.Provision] = *c.Provider - case OpUnpin: - delete(p.Pins, c.Provision) - if len(p.Pins) == 0 { - p.Pins = nil - } - case OpHold: - if at < 0 { - notPlaced() - continue - } - p.Holds = append(p.Holds, Holding{Claim: c.Claim, Scope: c.Scope, Module: c.Module, Site: c.Site, Kind: c.Kind}) - case 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 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 := laid[node].check(); 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 NodeDecided, field string, value json.RawMessage) (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) - } - fields[field] = value - raw, _ = json.Marshal(fields) - var out 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, value, err) - } - return out, nil -} - -// decidedFields are NodeDecided's fields by their JSON names. -var decidedFields = func() map[string]bool { - raw, _ := json.Marshal(NodeDecided{Endpoint: "x", Site: "x", Hub: true, Address: "x", PublicDomain: "x", Account: "x", - AccountHome: "x", AgentAccount: "x", AgentHome: "x", Adopted: true, Taken: []string{"x"}, Tunnel: &RecordTunnel{}, - 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 Placement) Placement { - raw, _ := json.Marshal(p) - var out Placement - _ = json.Unmarshal(raw, &out) - if out.Modules == nil { - out.Modules = []PlacedModule{} - } - return out -} - -var proposalIDOK = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,62}$`) - -// CheckProposalID refuses an id that is no proposal's: lower-case letters, digits and dashes, 3 to 63 of them, so it is -// one key token. -func CheckProposalID(id string) error { - if !proposalIDOK.MatchString(id) { - return fmt.Errorf("delivery: %q is no proposal id (lower-case letters, digits and dashes, one key token)", id) - } - return nil -} - -// NewProposalID is a fresh proposal id: its time in UTC and six random hex digits, `p-20261028t183000-1a2b3c`. -func NewProposalID(at time.Time) string { - var b [3]byte - _, _ = rand.Read(b[:]) - return "p-" + strings.ToLower(at.UTC().Format("20060102T150405")) + "-" + hex.EncodeToString(b[:]) -} - -// PlacementVerdict is the holder's judgement of a proposal's answers (ADR 0567 §4). It fails only on a refusal by a -// node that must answer or on its silence past the bound (and, for the seed alone, on any difference); every difference -// is shown by node: Intended is what the holder computed the proposal changes, Unintended what a node answered beside -// it, marked as such in the brief. -type PlacementVerdict struct { - State string `json:"state"` - At time.Time `json:"at"` - Fails []string `json:"fails,omitempty"` - Silent []string `json:"silent,omitempty"` - Until time.Time `json:"until,omitempty"` - Intended map[string][]string `json:"intended,omitempty"` - Unintended map[string][]string `json:"unintended,omitempty"` - // Carried is, for the seed, by node each difference a node found that the seed carries, named for the operator who - // approves it knowingly (": at "): the seed fails on no difference, only on a refusal or - // silence (issue 1126). - Carried map[string][]string `json:"carried,omitempty"` - Answers map[string]CandidateAnswer `json:"answers,omitempty"` - Notes []string `json:"notes,omitempty"` -} - -// PlacementProposal is an open proposal, the value of its key in PlacementProposalsBucket. -type PlacementProposal struct { - V int `json:"v"` - ID string `json:"id"` - By string `json:"by"` - Why string `json:"why"` - // At is when it was proposed, Written when its newest candidate was written (the answers' bound runs from it). - At time.Time `json:"at"` - Written time.Time `json:"written,omitempty"` - // Changes are as proposed; Seed says they are the take-over (adopt, §7), ExportDigest the digest of the export it - // proposed (ExportDigest), checked again at the warrant. - Changes []PlacementProposalChange `json:"changes"` - Seed bool `json:"seed,omitempty"` - ExportDigest string `json:"export-digest,omitempty"` - // Base is, by node whose key it changes, the revision of the key it was laid over (0: the node had none). - Base map[string]uint64 `json:"base"` - // Must is every node it must hear from (§3): each whose key it changes and each whose composition it changes - // through another's, sorted; set by the holder once it composed, before then the nodes of Base. - Must []string `json:"must,omitempty"` - // HandBack says it is the operator's hand-back (§7): every key of Base deleted on its warrant, with no change and no - // node's answer. - HandBack bool `json:"hand-back,omitempty"` - // State is ProposalAnswering, ProposalFailed or ProposalAsked; Verdict is the latest judgement, Ask the authorising - // ask raised on a passing one. - State string `json:"state"` - Verdict *PlacementVerdict `json:"verdict,omitempty"` - Ask string `json:"ask,omitempty"` - // Words is what happened last, in words (laid again over moved keys, asked again). - Words string `json:"words,omitempty"` - // 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"` -} - -// Nodes is every node whose key the proposal changes, sorted. -func (p PlacementProposal) Nodes() []string { return sortedKeys(p.Base) } - -func (p PlacementProposal) check() error { - if err := CheckProposalID(p.ID); err != nil { - return err - } - if strings.TrimSpace(p.By) == "" || strings.TrimSpace(p.Why) == "" { - return fmt.Errorf("delivery: 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("delivery: the proposal %s changes nothing, or is a hand-back with changes", p.ID) - } - for i, c := range p.Changes { - if err := c.check(); err != nil { - return fmt.Errorf("delivery: the proposal %s, change %d: %w", p.ID, i+1, err) - } - if (c.Op == OpSeed) != p.Seed { - return fmt.Errorf("delivery: the proposal %s mixes the seed with other changes", p.ID) - } - if _, ok := p.Base[c.Node]; !ok { - return fmt.Errorf("delivery: the proposal %s changes %s and names no base revision of its key", p.ID, c.Node) - } - } - switch p.State { - case ProposalAnswering, ProposalFailed: - case ProposalAsked: - if p.Ask == "" || p.Verdict == nil || p.Verdict.State != VerdictPass { - return fmt.Errorf("delivery: the proposal %s is asked with no ask or no passing verdict", p.ID) - } - default: - return fmt.Errorf("delivery: the proposal %s is in no state %q", p.ID, p.State) - } - 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 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 (p PlacementProposal) checkAddNode() error { - adds := 0 - for _, c := range p.Changes { - if c.Op == OpAddNode || c.Op == OpRemoveNode { - 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 c.Op == OpRemoveNode { - if w.Node != c.Node { - return fmt.Errorf("delivery: 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("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 -} - -// RemovedNodes are the nodes whose key a proposal deletes (its remove-node), which LayChanges leaves out of what it lays. -func (p PlacementProposal) RemovedNodes() []string { - var out []string - for _, c := range p.Changes { - if c.Op == OpRemoveNode { - out = append(out, c.Node) - } - } - return out -} - -// AddNodeRefusal is why the holder of mesh-placement refuses an add-node or a remove-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 or a remove-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 { - return nil, err - } - p.V = Version - return json.Marshal(p) -} - -// DecodePlacementProposal reads a proposal; it refuses an unknown version, an id that is none, no proposer or why, no -// change, 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, and a state it does not know. -func DecodePlacementProposal(data []byte) (PlacementProposal, error) { - var p PlacementProposal - if err := decode(data, &p); err != nil { - return PlacementProposal{}, err - } - if err := p.check(); err != nil { - return PlacementProposal{}, err - } - return p, nil -} - -// ExportDigest is the digest of an export's answer, every node's placement, as the seed proposes it: the same -// placements give the same digest, whatever order they came in. -func ExportDigest(placements map[string]Placement) string { - h := sha256.New() - for _, node := range sortedKeys(placements) { - raw, _ := EncodePlacement(placements[node]) - fmt.Fprintf(h, "%s\n%s\n", node, raw) - } - return "sha256:" + hex.EncodeToString(h.Sum(nil)) -} - -// ChangesDigest is the digest of a proposal's changes, which its authorising ask is bound to (§5). -func ChangesDigest(p PlacementProposal) string { - raw, _ := json.Marshal(struct { - ID string `json:"id"` - Changes []PlacementProposalChange `json:"changes"` - Base map[string]uint64 `json:"base"` - }{p.ID, p.Changes, p.Base}) - sum := sha256.Sum256(raw) - return "sha256:" + hex.EncodeToString(sum[:]) -} - -// PlacementWarrant is who approved a proposal, through which channel and when (§5, §6): the warrant's id as the teller -// wrote it (the ask's id and when), and its words, which name the person and the channel that proved them -// (asks.Warrant.Says). -type PlacementWarrant struct { - Warrant string `json:"warrant"` - Words string `json:"words"` - At time.Time `json:"at"` -} - -// PlacementEvent is what each of the seat's events carries (§6): the proposal whole (its id, by, why, changes, base -// revisions and verdict with every node's answer); for placement-changed, by node the revisions before and after and -// the modules added, removed or changed, and the warrant; for placement-withdrawn, why it ended. -type PlacementEvent struct { - Proposal PlacementProposal `json:"proposal"` - Nodes []PlacementChange `json:"nodes,omitempty"` - Warrant *PlacementWarrant `json:"warrant,omitempty"` - Ended string `json:"ended,omitempty"` -} - -// SortChanges orders a placement-changed event's nodes by node. -func SortChanges(c []PlacementChange) { - sort.Slice(c, func(i, j int) bool { return c[i].Node < c[j].Node }) -} diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go deleted file mode 100644 index 7dd7855..0000000 --- a/go/delivery/placement_test.go +++ /dev/null @@ -1,797 +0,0 @@ -package delivery - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "reflect" - "slices" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// ADR 0549 §1, §2: the placement key and file, the value's refusals, its reading as the composition reads a node, what -// a change says, and the catalogue's check of every file. -func TestPlacement(t *testing.T) { - t.Run("key and file", func(t *testing.T) { - // Research 072 row 37: one node key and one key per module, a legacy key read beside them, any other refused. - for key, want := range map[string]struct { - node, module string - kind PlacementKeyKind - }{"g14.node": {"g14", "", PlacementNodeKind}, "g14.app": {"g14", "app", PlacementModuleKind}, - "placement.g14": {"g14", "", PlacementLegacyKind}} { - if n, m, k, err := ParsePlacementKey(key); err != nil || n != want.node || m != want.module || k != want.kind { - t.Errorf("ParsePlacementKey(%q) = %q, %q, %d, %v", key, n, m, k, err) - } - } - for _, bad := range []string{"want.g14.x", "placement.", "placement.a.b", "g14", "g14.a.b", ".node"} { - if _, _, _, err := ParsePlacementKey(bad); err == nil { - t.Errorf("ParsePlacementKey(%q) took it", bad) - } - } - if k, err := PlacementNodeKey("g14"); err != nil || k != "g14.node" { - t.Errorf("PlacementNodeKey = %q, %v", k, err) - } - if k, err := PlacementModuleKey("g14", "app"); err != nil || k != "g14.app" { - t.Errorf("PlacementModuleKey = %q, %v", k, err) - } - if k, err := PlacementNodeKeys("g14"); err != nil || k != "g14.>" { - t.Errorf("PlacementNodeKeys = %q, %v", k, err) - } - if k, err := LegacyPlacementKey("g14"); err != nil || k != "placement.g14" { - t.Errorf("LegacyPlacementKey = %q, %v", k, err) - } - for name, err := range map[string]error{"a dotted node": func() error { _, err := PlacementNodeKey("a.b"); return err }(), - "a node named placement": func() error { _, err := PlacementNodeKey("placement"); return err }(), - "a module named node": func() error { _, err := PlacementModuleKey("g14", "node"); return err }()} { - if err == nil { - t.Errorf("%s was taken", name) - } - } - // ADR 0567 §1: the seat's three states are named after the holding module, and every reader takes the names here. - for got, want := range map[string]string{PlacementBucket: "mesh-delivery-assigner_placement", - PlacementProposalsBucket: "mesh-delivery-assigner_proposals", PlacementCandidatesBucket: "mesh-delivery-assigner_candidates", - PlacementRead: "mesh-delivery-assigner.placement", PlacementCandidatesRead: "mesh-delivery-assigner.candidates", - PlacementProposalsRead: "mesh-delivery-assigner.proposals"} { - if got != want { - t.Errorf("a bucket is named %q, want %q", got, want) - } - } - if PlacementHistory != 64 || PlacementStreamDays != 30 { - t.Errorf("history %d, stream %d days", PlacementHistory, PlacementStreamDays) - } - }) - - g14 := Placement{Node: "g14", Revision: "abc", - Decided: NodeDecided{Endpoint: "g14.example:51820", Site: "home", Address: "10.77.0.2", Account: "op", Taken: []string{"zsh"}}, - Modules: []PlacedModule{ - {Module: "zsh"}, - {Module: "app", Ports: map[string]int{"8080": 18080}, Bindings: map[string]Provider{"postgres": {Node: "novox", Module: "postgres"}}}, - }, - Pins: map[string]Provider{"shell": {Node: "g14", Module: "zsh"}}, - Holds: []Holding{{Claim: "node-shell", Scope: "node", Module: "zsh"}}, - } - novox := Placement{Node: "novox", Decided: NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []PlacedModule{{Module: "postgres", Ports: map[string]int{"5432": 5432}}}, - Holds: []Holding{{Claim: "mesh-store", Scope: "mesh", Module: "postgres"}}} - - t.Run("value", func(t *testing.T) { - raw, err := EncodePlacement(g14) - if err != nil { - t.Fatal(err) - } - back, err := DecodePlacement(raw) - if err != nil || back.V != Version || !SamePlacement(back, g14) { - t.Fatalf("round trip: %+v, %v", back, err) - } - moved := g14 - moved.Revision = "def" - if !SamePlacement(g14, moved) { - t.Error("a new revision alone counts as a change") - } - if _, err := DecodePlacement([]byte(`{"v":9,"node":"g14","modules":[]}`)); !errors.Is(err, ErrUnknownVersion) { - t.Errorf("version 9: %v", err) - } - for name, bad := range map[string]string{ - "module twice": `{"v":1,"node":"g14","modules":[{"module":"a"},{"module":"a"}]}`, - "port not number": `{"v":1,"node":"g14","modules":[{"module":"a","ports":{"http":80}}]}`, - "host port zero": `{"v":1,"node":"g14","modules":[{"module":"a","ports":{"80":0}}]}`, - "binding no node": `{"v":1,"node":"g14","modules":[{"module":"a","bindings":{"pg":{"module":"pg"}}}]}`, - "pin no module": `{"v":1,"node":"g14","modules":[],"pins":{"pg":{"node":"x"}}}`, - "hold not placed": `{"v":1,"node":"g14","modules":[],"holds":[{"claim":"s","module":"a"}]}`, - "node not a name": `{"v":1,"node":"g.14","modules":[]}`, - "value not shaped": `{"v":1,"node":"g14","modules":{}}`, - } { - if _, err := DecodePlacement([]byte(bad)); err == nil { - t.Errorf("%s: taken", name) - } - } - }) - - t.Run("as the composition reads it", func(t *testing.T) { - if got := g14.Assigned(); !reflect.DeepEqual(got, []string{"zsh", "app"}) { - t.Errorf("Assigned = %v, want the file's order", got) - } - if got := g14.ChosenPins(); !reflect.DeepEqual(got, map[string]catalogue.Chosen{"shell": {Node: "g14", Module: "zsh"}}) { - t.Errorf("ChosenPins = %v", got) - } - if got := g14.Bound(); !reflect.DeepEqual(got, map[string]map[string]catalogue.Chosen{"app": {"postgres": {Node: "novox", Module: "postgres"}}}) { - t.Errorf("Bound = %v", got) - } - if got := g14.HostPorts(); !reflect.DeepEqual(got, map[string]map[int]int{"app": {8080: 18080}}) { - t.Errorf("HostPorts = %v", got) - } - if got := g14.Held(); !reflect.DeepEqual(got, []catalogue.Held{{Claim: "node-shell", Scope: "node", Node: "g14", Module: "zsh"}}) { - t.Errorf("Held = %v", got) - } - layered := PlacedModule{Module: "app", HasMesh: true, Layer: map[string]any{"expose": map[string]any{"80": "mesh"}}} - want := []catalogue.Layer{ - {From: catalogue.MeshWideLayer, Values: map[string]any{}}, - {From: "g14", Values: map[string]any{"mode": "x"}, Placement: map[string]any{"expose": map[string]any{"80": "mesh"}}}, - } - if got := layered.Layers("g14", map[string]any{"mode": "x"}); !reflect.DeepEqual(got, want) { - t.Errorf("Layers = %#v, want %#v", got, want) - } - if got := (PlacedModule{Module: "a"}).Layers("g14", map[string]any{"k": 1}); len(got) != 1 || got[0].From != "g14" { - t.Errorf("values alone make the node's layer: %#v", got) - } - if got := (PlacedModule{Module: "a", HasLayer: true}).Layers("g14", nil); len(got) != 1 || got[0].Values == nil { - t.Errorf("an empty layer of the node's own is kept, with empty values: %#v", got) - } - if got := (PlacedModule{Module: "a"}).Layers("g14", nil); got != nil { - t.Errorf("no layer and no value is no layer: %#v", got) - } - empty := Placement{Node: "x"} - if empty.ChosenPins() != nil || empty.Bound() != nil || empty.HostPorts() != nil || len(empty.Held()) != 0 { - t.Error("an empty placement reads as something") - } - }) - - t.Run("what a change says", func(t *testing.T) { - after := g14 - after.Modules = []PlacedModule{{Module: "app", Ports: map[string]int{"8080": 18081}}, {Module: "new"}} - got := ChangeOf(g14, after) - want := PlacementChange{Node: "g14", Added: []string{"new"}, Removed: []string{"zsh"}, Changed: []string{"app"}} - if !reflect.DeepEqual(got, want) { - t.Errorf("ChangeOf = %+v, want %+v", got, want) - } - pinned := g14 - pinned.Pins = nil - if got := ChangeOf(g14, pinned); !reflect.DeepEqual(got.Changed, []string{"app", "zsh"}) { - t.Errorf("a pin change changes every module on the node: %+v", got) - } - if got := ChangeOf(Placement{}, novox); !reflect.DeepEqual(got.Added, []string{"postgres"}) || got.Node != "novox" { - t.Errorf("a new node: %+v", got) - } - }) - - // Research 072 row 37: a node's placement as its node key and module keys, written so a reader never takes a part of - // a write, read beside the form before, and converted from it once. - t.Run("one key per module and node", func(t *testing.T) { - values, err := PlacementValues(g14) - if err != nil || len(values) != 4 || values["g14.node"] == nil || values["g14.zsh"] == nil || values["g14.app"] == nil || - values["placement.g14"] == nil { - t.Fatalf("PlacementValues = %v, %v", values, err) - } - got, err := PlacementsOf(values) - if err != nil || !SamePlacement(got["g14"], g14) || len(got) != 1 { - t.Fatalf("PlacementsOf = %+v, %v", got, err) - } - if got := InsideAddresses(map[string]Placement{"g14": g14, "novox": novox, "x": {Node: "x"}, "y": {Node: "y", Decided: NodeDecided{Address: " 10.77.0.1"}}}); strings.Join(got, ",") != "10.77.0.1,10.77.0.2" { - t.Errorf("InsideAddresses = %v", got) - } - legacy, _ := EncodePlacement(novox) - stale, _ := EncodePlacement(Placement{Node: "g14"}) - both := map[string][]byte{"placement.novox": legacy, "placement.g14": stale} - for k, v := range values { - both[k] = v - } - if got, err := PlacementsOf(both); err != nil || !SamePlacement(got["g14"], g14) || !SamePlacement(got["novox"], novox) { - t.Errorf("a node key wins over a legacy key, and a legacy key alone places its node: %+v, %v", got, err) - } - - keys := NewPlacementKeys() - rev := uint64(0) - apply := func(writes []PlacementKeyWrite, upTo int) { - t.Helper() - for i, w := range writes { - if i == upTo { - return - } - rev++ - if _, err := keys.Put(w.Key, w.Value, rev, w.Value == nil); err != nil { - t.Fatalf("write %d (%s): %v", i, w.Key, err) - } - } - } - // The form before converts once: its node and module keys, the legacy key left for the readers not yet delivered. - rev++ - legacyG14, _ := EncodePlacement(g14) - if _, err := keys.Put("placement.g14", legacyG14, rev, false); err != nil || !keys.Legacy("g14") { - t.Fatalf("the legacy key: %v", err) - } - migrations, err := keys.Migrations() - if err != nil || len(migrations["g14"]) != 3 || !migrations["g14"][2].Commit { - // The legacy key holds the placement already: no mirror write. - t.Fatalf("Migrations = %+v, %v", migrations, err) - } - apply(migrations["g14"], -1) - if m, _ := keys.Migrations(); len(m) != 0 || keys.Legacy("g14") { - t.Fatalf("a converted node converts again: %+v", m) - } - if p, r, ok := keys.Of("g14"); !ok || !SamePlacement(p, g14) || r != rev { - t.Fatalf("Of after the conversion = %+v, %d, %v", p, r, ok) - } - - // A write under way is never read half: app's new port is not read before the node key names it. - after := clonePlacement(g14) - after.Modules = []PlacedModule{{Module: "app", Ports: map[string]int{"8080": 18081}}, {Module: "new"}} - after.Holds, after.Pins = nil, nil - writes, err := keys.Writes("g14", &after) - if err != nil { - t.Fatal(err) - } - var kinds []string - for _, w := range writes { - kinds = append(kinds, fmt.Sprintf("%s:%v:%d", w.Key, w.Value != nil, w.Base)) - } - if strings.Join(kinds, " ") != "g14.app:true:3 g14.new:true:0 g14.node:true:4 placement.g14:true:1 g14.zsh:false:2" || - !writes[3].Mirror { - t.Fatalf("Writes = %v", kinds) - } - apply(writes, 2) - if p, _, _ := keys.Of("g14"); !SamePlacement(p, g14) { - t.Errorf("the module keys written before the node key are read: %+v", p) - } - apply(writes[2:], -1) - if p, _, _ := keys.Of("g14"); !SamePlacement(p, after) { - t.Errorf("the write done is not read whole: %+v", p) - } - if legacy, _ := PlacementsOf(map[string][]byte{"placement.g14": writes[3].Value}); !SamePlacement(legacy["g14"], after) { - t.Errorf("the mirror is not the placement written: %+v", legacy) - } - // A module key that stays is not written again, nor a mirror that holds it. - if again, _ := keys.Writes("g14", &after); len(again) != 1 || !again[0].Commit { - t.Errorf("an unchanged placement writes %+v", again) - } - // Keys that do not agree keep what the node had, and say so. - stray, _ := EncodePlacementModule(PlacementModule{Node: "g14", Placed: PlacedModule{Module: "app"}}) - if _, err := keys.Put("g14.app", stray, 99, false); err == nil { - t.Error("a module key the node key does not name the value of was taken silently") - } - if p, _, _ := keys.Of("g14"); !SamePlacement(p, after) { - t.Errorf("a stray module key moved the placement: %+v", p) - } - // The holder's next write puts the module key right again. - repair, _ := keys.Writes("g14", &after) - if len(repair) != 2 || repair[0].Key != "g14.app" || repair[0].Base != 99 || !repair[1].Commit { - t.Fatalf("the repair writes %+v", repair) - } - apply(repair, -1) - // No placement: the legacy key first, then the node key, then the module keys; none is read after. - gone, _ := keys.Writes("g14", nil) - kinds = nil - for _, w := range gone { - kinds = append(kinds, w.Key) - } - if strings.Join(kinds, " ") != "placement.g14 g14.node g14.app g14.new" { - t.Errorf("Writes(nil) = %v", kinds) - } - apply(gone, 2) - if _, _, ok := keys.Of("g14"); ok { - t.Error("a node key deleted still places its node") - } - if _, err := DecodePlacementNode([]byte(`{"v":1,"node":"g14","modules":[{"module":"a","digest":"x"},{"module":"a","digest":"x"}]}`)); err == nil { - t.Error("a node key naming a module twice reads") - } - }) - - t.Run("the catalogue's check", func(t *testing.T) { - held := func(m string) bool { return m != "ghost" } - enrolled := func(n string) bool { return n != "stranger" } - if r := CheckPlacements(map[string]Placement{"g14": g14, "novox": novox}, held, enrolled); len(r) != 0 { - t.Fatalf("a sound placement is refused: %v", r) - } - // Issue 1126: the private network is the mesh's own module, held by no repository the indexer reads, and placed on - // every node by the seed; the check takes it whatever the indexer holds. - wired := Placement{Node: "novox", Modules: []PlacedModule{{Module: "mesh-wireguard"}}} - if r := CheckPlacements(map[string]Placement{"novox": wired}, func(string) bool { return false }, enrolled); len(r) != 0 || - !ProvidedByMesh("mesh-wireguard") || ProvidedByMesh("postgres") { - t.Errorf("the private network placed with no repository holding it is refused: %v", r) - } - if m, _, err := ProvidedManifest("mesh-wireguard"); err != nil || m.Module != "mesh-wireguard" { - t.Errorf("the provided manifest = %+v, %v", m, err) - } - // A replicated mesh seat (ADR 0223) is held on two nodes; a seat that is not replicated is refused there. - for seat, refused := range map[string]bool{"mesh-dns-resolver": false, "mesh-broker": true} { - a := Placement{Node: "ace", Modules: []PlacedModule{{Module: "dns"}}, Holds: []Holding{{Claim: seat, Scope: "mesh", Module: "dns"}}} - b := a - b.Node = "novox" - r := CheckPlacements(map[string]Placement{"ace": a, "novox": b}, held, enrolled) - if (len(r) != 0) != refused { - t.Errorf("%s on two nodes: refusals %v, want refused %v", seat, r, refused) - } - } - clash := g14 - clash.Modules = append([]PlacedModule{{Module: "ghost", Ports: map[string]int{"80": 18080}}}, g14.Modules...) - clash.Modules[2] = PlacedModule{Module: "app", Ports: map[string]int{"8080": 18080}, Bindings: map[string]Provider{"postgres": {Node: "ace", Module: "postgres"}}} - clash.Pins = map[string]Provider{"shell": {Node: "g14", Module: "bash"}} - // ADR 0564 clause 6, Limits 5 (issue 1952 S8): mesh-watcher and mesh-self-check are never placed on one node. - clash.Modules = append(clash.Modules, PlacedModule{Module: "mesh-watcher"}, PlacedModule{Module: "mesh-self-check"}) - twice := novox - twice.Node = "stranger" - twice.Decided.Address = "10.77.0.2" - r := CheckPlacements(map[string]Placement{"g14": clash, "novox": novox, "stranger": twice}, held, enrolled) - for _, want := range []string{"holds no module ghost", "ghost and app are both published on host port 18080", - "bound to ace/postgres", "pin of shell names g14/bash", "stranger is a node with no record", - "mesh-store, held once for the mesh, is held on novox and stranger", - "g14 and stranger both have the address 10.77.0.2", "novox, stranger are each placed as it", - "g14: mesh-watcher and mesh-self-check are never placed on one node (ADR 0227 §6, ADR 0564 clause 6)"} { - found := false - for _, line := range r { - found = found || strings.Contains(line, want) - } - if !found { - t.Errorf("no refusal says %q; got %v", want, r) - } - } - }) - - // ADR 0567 §3: a proposal's candidates, one key `.` per node it must hear from, and each node's answer - // in its have. - t.Run("candidates", func(t *testing.T) { - const id = "p-20261028t183000-1a2b3c" - key, err := PlacementCandidateKey(id, "g14") - if err != nil || key != id+".g14" { - t.Fatalf("PlacementCandidateKey = %q, %v", key, err) - } - if p, n, err := ParsePlacementCandidateKey(key); err != nil || p != id || n != "g14" { - t.Fatalf("ParsePlacementCandidateKey = %q, %q, %v", p, n, err) - } - for _, bad := range []string{"g14", ".g14", "p-x.g.14", "p-x.", "P-X.g14", "feat/1.g14"} { - if _, _, err := ParsePlacementCandidateKey(bad); err == nil { - t.Errorf("ParsePlacementCandidateKey(%q) took it", bad) - } - } - for name, c := range map[string]PlacementCandidate{ - "placed": {Proposal: id, Placement: &g14}, - "removed": {Proposal: id}, - "reached": {Proposal: id, Reached: true, Placement: &g14}, - } { - raw, err := EncodePlacementCandidate(c) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - back, err := DecodePlacementCandidate(raw) - if err != nil || back.Proposal != id || back.Reached != c.Reached || (back.Placement == nil) != (c.Placement == nil) || - (c.Placement != nil && !SamePlacement(*back.Placement, g14)) { - t.Errorf("%s: round trip %+v, %v", name, back, err) - } - } - for name, c := range map[string]PlacementCandidate{ - "no proposal": {}, - "a group": {Proposal: "feat/x"}, - "bad place": {Proposal: id, Placement: &Placement{Node: "a.b"}}, - } { - if _, err := EncodePlacementCandidate(c); err == nil { - t.Errorf("%s: taken", name) - } - } - answers := map[string]CandidateAnswer{ - "same": {Proposal: id, Revision: 3, Verdict: CandidateSame, Same: []string{"app", "node"}}, - "differs": {Proposal: id, Revision: 3, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}}, - "refused": {Proposal: id, Revision: 3, Verdict: CandidateRefused, Refused: "unbound postgres"}, - "added": {Proposal: id, Revision: 1, Verdict: CandidateDiffers, Same: []string{"node"}, Added: []ModuleAdded{{Module: "app", Unissued: []string{"app/token"}}}, Removed: []string{"old"}}, - "a value": {Proposal: id, Revision: 1, Verdict: CandidateRefused, Same: []string{"node"}, Refusals: []ModuleRefused{{Module: "db", Why: "stands in for db/password"}}}, - // A head that deletes the node's placement file: the node returns to the controller's compositions (issue 1126, - // the seed's revert, mesh-catalog #526). - "returns": {Proposal: id, Revision: 1, Verdict: CandidateSame, Returns: ReturnsToNothing, Same: []string{"app", "node"}}, - "returns, differs": {Proposal: id, Revision: 1, Verdict: CandidateDiffers, Returns: ReturnsToNothing, Same: []string{"node"}, - Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}, Removed: []string{"old"}}, - } - n := HaveNode{Composed: "sha256:" + strings.Repeat("a", 64), Sealed: []string{"box"}, Candidates: answers, - Composes: ComposesOwn, ComposesWhy: "the catalogue places it (placement.n1)"} - raw, err := EncodeHaveNode(n) - if err != nil { - t.Fatal(err) - } - back, err := DecodeHaveNode(raw) - if err != nil || !SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Candidates: answers, - Composes: ComposesOwn, ComposesWhy: n.ComposesWhy}) { - t.Fatalf("a node's answers changed on the way: %+v, %v", back, err) - } - if SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Composes: ComposesOwn, ComposesWhy: n.ComposesWhy}) { - t.Error("a node's have without its answers counts as the same") - } - if SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Candidates: answers, - ComposesWhy: n.ComposesWhy}) { - t.Error("a node's have composing otherwise counts as the same") - } - if _, err := EncodeHaveNode(HaveNode{Composes: "both"}); err == nil { - t.Error("a node composing neither its own nor none was taken") - } - // Issue 1126 B4a: a have an engine from before the controller ended wrote still reads. - if _, err := DecodeHaveNode([]byte(`{"v":1,"composes":"controller"}`)); err != nil { - t.Errorf("an older engine's have is refused: %v", err) - } - // CompareComposed: the bus's user list is left out on both sides; anything else that differs is named. - file := func(id, content string) json.RawMessage { - return json.RawMessage(`{"id":"` + id + `","type":"file","module":"nats","path":"/etc/` + id + `","mode":"0600","content":"` + content + `"}`) - } - fp := "src2:" + strings.Repeat("a", 64) - comp := func(rs ...json.RawMessage) Composed { - return Composed{Node: "novox", Module: "nats", FP: fp, Resources: rs} - } - for name, tc := range map[string]struct { - composed, running Composed - resource, why string - }{ - "user list alone": {comp(file("conf", "a"), file("nats.bus-users", "stand-in")), comp(file("conf", "a"), file("nats.bus-users", "real")), "", ""}, - "a resource": {comp(file("conf", "a")), comp(file("conf", "b")), "conf", "the resource differs"}, - "one side": {comp(file("conf", "a"), file("more", "x")), comp(file("conf", "a")), "more", "composed, and not in what runs"}, - "order": {comp(file("conf", "a"), file("more", "x")), comp(file("more", "x"), file("conf", "a")), "", "the same resources in another order"}, - } { - c := CompareComposed(map[string]Composed{"novox.nats." + fp: tc.composed}, []Composed{tc.running}) - if tc.why == "" { - if len(c.Differs) != 0 || len(c.Same) != 1 { - t.Errorf("%s: %+v", name, c) - } - } else if len(c.Differs) != 1 || c.Differs[0].Resource != tc.resource || c.Differs[0].Why != tc.why || - c.Differs[0].Composed == "" || c.Differs[0].Running == "" || c.Differs[0].Composed == c.Differs[0].Running { - t.Errorf("%s: differs %+v, want %s: %s with both digests", name, c.Differs, tc.resource, tc.why) - } - } - // Issue 1126, the shadow shows its work: every differing resource with both bodies, each value a secret could be - // in masked (never a secret in an answer), saying whether the two sides hold the same value. - sealedFile := func(id, sealed, mode string) json.RawMessage { - return json.RawMessage(`{"id":"` + id + `","type":"file","module":"nats","path":"/etc/` + id + `","mode":"` + mode + `","sealed":"` + sealed + `"}`) - } - // Issue 1126, C1: a sealed value is judged by the value it opens to at its own slot: b sealed again (box-2 and - // box-3 open to one value) is no difference; d holds at its slot a box of another value, though that value is - // the one b holds, and differs at its path `sealed`. - sameBox := func(a, b string) (same, known bool) { - return (a == "box-2" && b == "box-3") || a == b, true - } - shown := CompareComposedWith(map[string]Composed{"k": comp(sealedFile("a", "box-1", "0600"), sealedFile("b", "box-2", "0600"), - file("c", "x"), sealedFile("d", "box-2", "0600"))}, - []Composed{comp(sealedFile("a", "box-1", "0644"), sealedFile("b", "box-3", "0600"), file("c", "y"), sealedFile("d", "box-4", "0600"))}, - sameBox) - if len(shown.Differs) != 1 || len(shown.Differs[0].Bodies) != 3 { - t.Fatalf("every differing resource is not shown, or b sealed again is shown: %+v", shown.Differs) - } - if c := CompareComposedWith(map[string]Composed{"k": comp(sealedFile("b", "box-2", "0600"))}, - []Composed{comp(sealedFile("b", "box-3", "0600"))}, sameBox); len(c.Same) != 1 { - t.Errorf("a value sealed again at its own slot is not the same: %+v", c) - } - for _, row := range []struct { - id, has, hasNot string - paths []string - }{ - {"a", `"mode":"0600"`, "box-1", []string{"mode"}}, - {"c", `"content":"x"`, "", []string{"content"}}, - {"d", "", "box-2", []string{"sealed"}}, - } { - var body ResourceBodies - for _, b := range shown.Differs[0].Bodies { - if b.ID == row.id { - body = b - } - } - both := body.Composed + body.Running - if !strings.Contains(both, row.has) || (row.hasNot != "" && strings.Contains(both, row.hasNot)) || - !slices.Equal(body.Paths, row.paths) { - t.Errorf("%s shown as %+v, want paths %v", row.id, body, row.paths) - } - } - // Issue 1126, C1, the seed-7 probe: a file naming secrets carries each sealed value in its `secrets`, and each entry - // is a slot of its own. The shape is intoFile's output, captured from catalogue for a file naming two secrets - // (2026-10-28), with the module a composition names each resource of; only the boxes are replaced. - withSecrets := func(db, key string) json.RawMessage { - return json.RawMessage(`{"content":"[database]\npassword = ${secret:postgres-database}\n[security]\nsecret_key = ${secret:secret-key}\n",` + - `"id":"grafana.ini","mode":"0600","module":"nats","path":"/etc/grafana/grafana.ini","secrets":{"postgres-database":"` + db + - `","secret-key":"` + key + `"},"type":"file"}`) - } - if c := CompareComposedWith(map[string]Composed{"k": comp(withSecrets("box-2", "box-1"))}, - []Composed{comp(withSecrets("box-3", "box-1"))}, sameBox); len(c.Same) != 1 { - t.Errorf("a secret sealed again at its own entry is not the same: %+v", c) - } - if kept := KeepSeals(comp(withSecrets("box-2", "box-1")), SealsIn(comp(withSecrets("box-3", "box-1"))), sameBox); !bytes.Equal(kept.Resources[0], withSecrets("box-3", "box-1")) { - t.Errorf("the box that runs is not kept byte for byte: %s", kept.Resources[0]) - } - if s := SealsIn(comp(withSecrets("box-2", "box-1"))); s[SealSlot("grafana.ini", SecretSlotField("postgres-database"))] != "box-2" || len(s) != 2 { - t.Errorf("the secrets' slots are %v", s) - } - // Another value at one entry differs there alone, each entry masked by what it opens to. - // box-2 and box-4 open to one value, so the box that runs is kept there; box-5 and box-6 are two values. - mixed := CompareComposedWith(map[string]Composed{"k": comp(withSecrets("box-2", "box-5"))}, - []Composed{comp(withSecrets("box-4", "box-6"))}, func(a, b string) (bool, bool) { - return (a == "box-2" && b == "box-4") || a == b, true - }) - if len(mixed.Differs) != 1 || len(mixed.Differs[0].Bodies) != 1 { - t.Fatalf("a secret of another value is not shown: %+v", mixed) - } - if body := mixed.Differs[0].Bodies[0]; !slices.Equal(body.Paths, []string{"secrets.secret-key"}) || - !strings.Contains(body.Composed, `"secret-key":""`) || strings.Contains(body.Composed+body.Running, "box-") || - !strings.Contains(body.Running, `"postgres-database":""`) { - t.Errorf("a file's secrets shown as %+v", body) - } - if c, _ := MaskedPair(withSecrets("box-2", "box-5"), withSecrets("box-4", "box-6"), func(a, b string) (bool, bool) { - return a == "box-2", true - }); !strings.Contains(c, `"postgres-database":""`) || !strings.Contains(c, `"secret-key":""`) { - t.Errorf("each secret is not masked by what it opens to: %s", c) - } - // Only a file's secrets are split by entry: a vault box (an object under `vault-sealed`) is one masked word and one - // path, judged whole by what it opens to (rev-seed-same-3, round 1). - box := func(ct string) json.RawMessage { - return json.RawMessage(`{"id":"core-box","type":"file","module":"nats","path":"/etc/core","mode":"0600","vault-sealed":{"v":1,"eph":"e-` + - ct + `","ct":"` + ct + `"}}`) - } - vault := CompareComposedWith(map[string]Composed{"k": comp(box("c1"))}, []Composed{comp(box("c2"))}, - func(a, b string) (bool, bool) { - return false, strings.Contains(a, `"ct":"c1"`) && strings.Contains(b, `"ct":"c2"`) - }) - if len(vault.Differs) != 1 || len(vault.Differs[0].Bodies) != 1 { - t.Fatalf("a box of another value is not shown: %+v", vault) - } - if body := vault.Differs[0].Bodies[0]; !slices.Equal(body.Paths, []string{"vault-sealed"}) || - !strings.Contains(body.Composed, `"vault-sealed":""`) || strings.Contains(body.Composed+body.Running, "c1") { - t.Errorf("a vault box shown as %+v", body) - } - // Issue 1126, T1: the paths are found on the whole bodies, before they are cut; JSON in a string is walked into, a - // key a secret could be under is named and never shown. - long := strings.Repeat("x", MaxBody) - conf := func(link, password string) json.RawMessage { - inner, _ := json.Marshal(map[string]any{"pad": long, "links": []string{"a", link}, "password": password}) - outer, _ := json.Marshal(map[string]any{"id": "conf", "type": "file", "module": "nats", "path": "/etc/conf", - "mode": "0600", "content": string(inner)}) - return outer - } - far := CompareComposedWith(map[string]Composed{"k": comp(conf("b", "hunter2"))}, []Composed{comp(conf("c", "hunter3"))}, nil) - if len(far.Differs) != 1 || len(far.Differs[0].Bodies) != 1 || - !slices.Equal(far.Differs[0].Bodies[0].Paths, []string{"content.links.1", "content.password"}) || - !strings.Contains(far.Differs[0].Bodies[0].Composed, "cut at") || strings.Contains(fmt.Sprint(far), "hunter") { - t.Errorf("a difference past the cut: %+v", far.Differs) - } - if c, r := MaskedPair(json.RawMessage(`{"env":{"DB_PASSWORD":"hunter2","PORT":"80"},"list":[{"token":"t1"}]}`), - json.RawMessage(`{"env":{"DB_PASSWORD":"hunter3","PORT":"81"},"list":[{"token":"t1"}]}`), nil); strings.Contains(c+r, "hunter") || - strings.Contains(c+r, `"t1"`) || !strings.Contains(r, `"PORT":"81"`) || !strings.Contains(c, "") || - !strings.Contains(c, "") { - t.Errorf("masked as %s and %s", c, r) - } - // Issue 1126, cause C4: the own secrets whose two sides open to two values are read from the shown bodies, and - // every body so masked is counted, own or not. - // The ids are as a node runs them, its module's name first (captured from novox's shadow answer, 2026-10-28: - // `only-office.needs-jwt`); another module's need, named here, is not nats's. - drift := CompareComposedWith(map[string]Composed{"k": comp(sealedFile("nats.needs-jwt", "box-1", "0600"), - sealedFile("nats.needs-salt", "box-4", "0600"), sealedFile("nats.conf", "box-6", "0600"), - sealedFile("other.needs-key", "box-8", "0600"))}, - []Composed{comp(sealedFile("nats.needs-jwt", "box-2", "0600"), sealedFile("nats.needs-salt", "box-5", "0600"), - sealedFile("nats.conf", "box-7", "0600"), sealedFile("other.needs-key", "box-9", "0600"))}, - func(a, b string) (same, known bool) { return a == "box-4", true }) - got := CandidateAnswer{Proposal: "p-x1", Revision: 1, Differs: drift.Differs} - if own := OwnValuesDiffer(got); len(own) != 1 || own[0] != (OwnValueDiffers{Module: "nats", Name: "jwt"}) { - t.Errorf("the own secrets with another value are %+v, want nats jwt alone", own) - } - if n := ValuesDiffer(got); n != 3 { - t.Errorf("%d bodies hold another value, want 3 (jwt, conf and other's key)", n) - } - for id, want := range map[string]string{"nats.needs-jwt": "jwt", "needs-jwt": "jwt", "other.needs-jwt": "", - "nats.conf": "", "nats.needs-": ""} { - if name, own := OwnNeedName("nats", id); name != want && (own || want != "") { - t.Errorf("%s names nats's own secret %q (%v), want %q", id, name, own, want) - } - } - if w := WithValuesDiffer(map[string]CandidateAnswer{"g": got}); w["g"].ValuesDiffer != 3 { - t.Errorf("the answer carries values_differ %d", w["g"].ValuesDiffer) - } - if c := CompareComposed(nil, []Composed{{Node: "novox", Resources: []json.RawMessage{}}}); len(c.Removed) != 1 || c.Removed[0] != "node" { - t.Errorf("the node's own composition run and not composed is removed: %+v", c) - } - if c := CompareComposed(map[string]Composed{"k": comp(file("conf", "a"))}, nil); len(c.Added) != 1 || c.Added[0].Module != "nats" || c.Added[0].Composed == "" { - t.Errorf("a composition not run is added: %+v", c) - } - for name, a := range map[string]CandidateAnswer{ - "no head": {Verdict: CandidateSame}, - "same with a diff": {Proposal: "p-x1", Revision: 1, Verdict: CandidateSame, Differs: answers["differs"].Differs}, - "differs with none": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers}, - "refused no reason": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused}, - "unknown verdict": {Proposal: "p-x1", Revision: 1, Verdict: "maybe"}, - "diff with no module": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Why: "x"}}}, - "same with an added": {Proposal: "p-x1", Revision: 1, Verdict: CandidateSame, Added: []ModuleAdded{{Module: "a"}}}, - "differs refusing": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Removed: []string{"a"}, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}}, - "refused and modules": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Refused: "x", Same: []string{"a"}}, - "returns refusing": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToNothing, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}}, - "returns refused": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToNothing, Refused: "x"}, - } { - if _, err := EncodeHaveNode(HaveNode{Candidates: map[string]CandidateAnswer{"g": a}}); err == nil { - t.Errorf("%s: taken", name) - } - } - }) - - // ADR 0567 §2, §8: a proposal's changes laid over the current keys, what waits for the lease write, and the proposal's - // encoding and refusals. - t.Run("proposal", func(t *testing.T) { - current := map[string]Placement{"g14": g14, "novox": novox} - laid, findings := LayChanges(current, []PlacementProposalChange{ - {Op: OpPlace, Node: "g14", Module: "new"}, - {Op: OpTakeOff, Node: "g14", Module: "zsh"}, - {Op: OpUnpin, Node: "g14", Provision: "shell"}, - {Op: OpDecide, Node: "novox", Field: "site", Value: json.RawMessage(`"attic"`)}, - }) - if len(findings) != 0 || len(laid) != 2 || !reflect.DeepEqual(laid["g14"].Assigned(), []string{"app", "new"}) || - len(laid["g14"].Holds) != 0 || laid["g14"].Pins != nil || laid["novox"].Decided.Site != "attic" || !laid["novox"].Decided.Hub { - t.Fatalf("laid %+v, %v", laid, findings) - } - if !reflect.DeepEqual(current["g14"].Assigned(), []string{"zsh", "app"}) { - t.Error("laying changed the current placement") - } - if fresh, f := LayChanges(current, []PlacementProposalChange{{Op: OpPlace, Node: "pi", Module: "zsh"}}); len(f) != 0 || fresh["pi"].Node != "pi" { - t.Errorf("a node with no key: %+v, %v", fresh, f) - } - for name, c := range map[string]struct { - change PlacementProposalChange - want string - }{ - "not placed": {PlacementProposalChange{Op: OpTakeOff, Node: "g14", Module: "nope"}, "not placed"}, - "no such field": {PlacementProposalChange{Op: OpDecide, Node: "g14", Field: "colour", Value: json.RawMessage(`"x"`)}, "no decided field"}, - "wrong type": {PlacementProposalChange{Op: OpDecide, Node: "g14", Field: "hub", Value: json.RawMessage(`"x"`)}, "does not take"}, - "unknown op": {PlacementProposalChange{Op: "move", Node: "g14"}, "no change of placement"}, - "pin of nobody": {PlacementProposalChange{Op: OpPin, Node: "g14", Provision: "x"}, "provider"}, - "not held": {PlacementProposalChange{Op: OpUnhold, Node: "g14", Module: "app", Claim: "x"}, "holds no"}, - "bad port": {PlacementProposalChange{Op: OpPlace, Node: "g14", Module: "x", Ports: map[string]int{"a": 1}}, "port"}, - "seed of another": {PlacementProposalChange{Op: OpSeed, Node: "g14", Whole: &novox}, "seed"}, - } { - if _, f := LayChanges(current, []PlacementProposalChange{c.change}); len(f) != 1 || !strings.Contains(f[0], c.want) { - t.Errorf("%s: %v, want a finding naming %q", name, f, c.want) - } - } - - const id = "p-20261028t183000-1a2b3c" - if got := NewProposalID(time.Date(2026, 10, 28, 18, 30, 0, 0, time.UTC)); !strings.HasPrefix(got, "p-20261028t183000-") || CheckProposalID(got) != nil { - t.Errorf("NewProposalID = %q", got) - } - p := PlacementProposal{ID: id, By: "operator@anchor", Why: "move the app", At: time.Unix(1, 0).UTC(), - Changes: []PlacementProposalChange{{Op: OpTakeOff, Node: "g14", Module: "zsh"}}, Base: map[string]uint64{"g14": 4}, - State: ProposalAnswering} - raw, err := EncodePlacementProposal(p) - if err != nil { - t.Fatal(err) - } - if back, err := DecodePlacementProposal(raw); err != nil || back.ID != id || back.Base["g14"] != 4 || !reflect.DeepEqual(back.Nodes(), []string{"g14"}) { - t.Fatalf("round trip %+v, %v", back, err) - } - seed := p - seed.Seed, seed.ExportDigest = true, ExportDigest(current) - seed.Changes = []PlacementProposalChange{{Op: OpSeed, Node: "g14", Whole: &g14}} - if _, err := EncodePlacementProposal(seed); err != nil { - t.Errorf("the seed: %v", err) - } - if ExportDigest(current) != ExportDigest(map[string]Placement{"novox": novox, "g14": g14}) || ExportDigest(current) == ExportDigest(nil) { - t.Error("the export digest depends on order or not on the placements") - } - back := p - back.HandBack, back.Changes = true, nil - if _, err := EncodePlacementProposal(back); err != nil { - t.Errorf("a hand-back: %v", err) - } - if k, err := PulseAnswerKey(id, "g14"); err != nil || k != "answer."+id+".g14" { - t.Errorf("PulseAnswerKey = %q, %v", k, err) - } - if k, err := PulseAskKey(id); err != nil || k != "ask."+id || PlacementPulsesBucket != "mesh-delivery-assigner_pulses" { - t.Errorf("PulseAskKey = %q, %v", k, err) - } - 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. - // At the operator's yes it carries the tunnel key alone (the operator, 2026-11-02 01:05). - joining := PlacementProposalChange{Op: OpAddNode, Node: "lab", Decided: &NodeDecided{Address: "10.0.0.7"}, - Keys: &JoiningKeys{Overlay: "wg"}} - 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.Overlay != "wg" { - 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{Overlay: "wg"}}}); 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 }, - "no tunnel key": func(p *PlacementProposal) { - c := joining - c.Keys = &JoiningKeys{Identity: "id", Sealing: "seal"} - p.Changes = []PlacementProposalChange{c} - }, - "half its identity keys": func(p *PlacementProposal) { - c := joining - c.Keys = &JoiningKeys{Overlay: "wg", Identity: "id"} - p.Changes = []PlacementProposalChange{c} - }, - "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) - } - } - // With its identity keys whole, as bound at redeem, it is taken too. - whole := add - whole.Changes = []PlacementProposalChange{joining} - whole.Changes[0].Keys = &JoiningKeys{Overlay: "wg", Identity: "id", Sealing: "seal"} - if _, err := EncodePlacementProposal(whole); err != nil { - t.Errorf("an add-node with its identity keys: %v", err) - } - // An enrolment's end takes its never-confirmed node off again: mesh-enrolment's alone, of a node running nothing. - remove := add - remove.Changes = []PlacementProposalChange{{Op: OpRemoveNode, Node: "lab"}} - if _, err := EncodePlacementProposal(remove); err != nil || AddNodeRefusal(remove, AssignerModule) == "" || - AddNodeRefusal(remove, EnrolmentModule) != "" || !slices.Equal(remove.RemovedNodes(), []string{"lab"}) { - t.Errorf("a remove-node: %v, %q", err, AddNodeRefusal(remove, AssignerModule)) - } - joined := map[string]Placement{"lab": {V: Version, Node: "lab", Decided: NodeDecided{Address: "10.0.0.7"}, Modules: []PlacedModule{}}} - if laid, f := LayChanges(joined, remove.Changes); len(f) != 0 || len(laid) != 0 { - t.Errorf("a joining node removed: %v, %v", laid, f) - } - if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpRemoveNode, Node: "g14"}}); len(f) != 1 || - !strings.Contains(f[0], "runs modules") { - t.Errorf("a running node removed: %v", f) - } - remove.Warrant = &AddNodeWarrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"} - if _, err := EncodePlacementProposal(remove); err == nil { - t.Error("a remove-node on another node's warrant: taken") - } - for name, mut := range map[string]func(*PlacementProposal){ - "no id": func(p *PlacementProposal) { p.ID = "" }, - "no proposer": func(p *PlacementProposal) { p.By = "" }, - "no why": func(p *PlacementProposal) { p.Why = " " }, - "no change": func(p *PlacementProposal) { p.Changes = nil }, - "no base": func(p *PlacementProposal) { p.Base = map[string]uint64{"novox": 1} }, - "no state": func(p *PlacementProposal) { p.State = "" }, - "asked, no ask": func(p *PlacementProposal) { p.State = ProposalAsked }, - "seed mixed": func(p *PlacementProposal) { p.Seed, p.ExportDigest = true, "d" }, - "bad change": func(p *PlacementProposal) { p.Changes[0].Op = "swap" }, - "seed, no digest": func(p *PlacementProposal) { *p = seed; p.ExportDigest = "" }, - "hand-back with changes": func(p *PlacementProposal) { p.HandBack = true }, - } { - q := p - q.Changes = append([]PlacementProposalChange(nil), p.Changes...) - mut(&q) - if _, err := EncodePlacementProposal(q); err == nil { - t.Errorf("%s: taken", name) - } - } - }) -} diff --git a/go/delivery/preview.go b/go/delivery/preview.go deleted file mode 100644 index c444323..0000000 --- a/go/delivery/preview.go +++ /dev/null @@ -1,189 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "reflect" - "strings" -) - -// The checker's other buckets (ADR 0484 clauses 2 and 3): owed, the acts it owes the forge, which the forge bridge -// performs once per revision of a key, and previews, what the fetch step answered for a head merged onto a trunk commit. -const ( - // CheckerOwedBucket is the checker's bucket of owed acts, in the gate's owed shape: a status with the context - // StatusContextCheck for each head it judged. One part writes each key: the checker. - CheckerOwedBucket = CheckerModule + "_owed" - // CheckerOwedHistory is the revisions the checker's owed bucket keeps per key. - CheckerOwedHistory = 5 - // PreviewsBucket is the checker's bucket of merge previews; the indexer reads it for the candidates' fingerprints. - PreviewsBucket = CheckerModule + "_previews" - // PreviewsHistory is the revisions the previews bucket keeps per key. - PreviewsHistory = 5 -) - -// CheckerOwedKey is the key, in CheckerOwedBucket, of the status the checker owes on a pull request at a head: the -// gate's OwedKey with the act `status=mesh/check`, the only act the checker owes. -func CheckerOwedKey(repository string, number int, head string) (string, error) { - return OwedKey(repository, number, head, OwedStatus+"="+StatusContextCheck) -} - -// ParseCheckerOwedKey is the inverse of CheckerOwedKey; it refuses an owed key of any other act. -func ParseCheckerOwedKey(key string) (repository string, number int, head string, err error) { - repository, number, head, act, err := ParseOwedKey(key) - if err != nil { - return "", 0, "", err - } - if act != OwedStatus+"="+StatusContextCheck { - return "", 0, "", fmt.Errorf("delivery: %q is not a checker owed key: the checker owes only status=%s, not %q", key, StatusContextCheck, act) - } - return repository, number, head, nil -} - -// EncodeCheckerOwed is o as JSON at Version; it refuses what EncodeOwed refuses and any act but a status with the -// context StatusContextCheck. -func EncodeCheckerOwed(o Owed) ([]byte, error) { - if err := checkCheckerOwed(o); err != nil { - return nil, err - } - return EncodeOwed(o) -} - -func checkCheckerOwed(o Owed) error { - if o.Act != OwedStatus || o.Context != StatusContextCheck { - return fmt.Errorf("delivery: the checker owes only a status with the context %s, not the act %q with the context %q", StatusContextCheck, o.Act, o.Context) - } - return nil -} - -// DecodeCheckerOwed reads an owed act of the checker's bucket. -func DecodeCheckerOwed(data []byte) (Owed, error) { - o, err := DecodeOwed(data) - if err != nil { - return Owed{}, err - } - if err := checkCheckerOwed(o); err != nil { - return Owed{}, err - } - return o, nil -} - -// PreviewKey is the key, in PreviewsBucket, of a head merged onto a trunk commit: `..`. -func PreviewKey(repository, head, trunk string) (string, error) { - repo, err := escapeRepository(repository) - if err != nil { - return "", err - } - if err := checkName("head", head); err != nil { - return "", err - } - if err := checkName("trunk commit", trunk); err != nil { - return "", err - } - return repo + "." + head + "." + trunk, nil -} - -// ParsePreviewKey is the inverse of PreviewKey. -func ParsePreviewKey(key string) (repository, head, trunk string, err error) { - parts := strings.Split(key, ".") - if len(parts) != 3 { - return "", "", "", fmt.Errorf("delivery: %q is not a preview key, ..", key) - } - repository, err = unescapeRepository(parts[0]) - if err != nil { - return "", "", "", fmt.Errorf("delivery: %q is not a preview key: %w", key, err) - } - if _, err := PreviewKey(repository, parts[1], parts[2]); err != nil { - return "", "", "", fmt.Errorf("delivery: %q is not a preview key: %w", key, err) - } - return repository, parts[1], parts[2], nil -} - -// PreviewModule is what the preview holds of one module directory: the tree sha of the directory (or its named tree), the digest of its -// manifest (the key in the manifests bucket) and the hex sha256 of its lock files by path relative to it. -type PreviewModule struct { - Tree string `json:"tree"` - Manifest string `json:"manifest"` - Locks map[string]string `json:"locks,omitempty"` - // Paths are, of a module whose every artifact names its paths, each named path's git object id at the merge; Tree - // is then their NamedTree (novox/hq ADR 0499 clause 2). Absent otherwise. - Paths map[string]string `json:"paths,omitempty"` - // Packages are the mesh packages the module's lock file names at the merge (MeshPackagesOf, novox/hq ADR 0573 §2). - Packages []string `json:"packages,omitempty"` -} - -// Preview is the value of a preview key: the repository, the head and the trunk commit it was merged onto, the merge -// commit and, for each module directory of the merge, a PreviewModule. A head that does not merge is a preview too: it -// names the files in Conflicts and has no merge commit and no modules (issue 1445). -type Preview struct { - V int `json:"v"` - Repository string `json:"repository"` - Head string `json:"head"` - Trunk string `json:"trunk"` - Merge string `json:"merge,omitempty"` - Conflicts []string `json:"conflicts,omitempty"` - Modules map[string]PreviewModule `json:"modules,omitempty"` -} - -func (p Preview) check() error { - if _, err := PreviewKey(p.Repository, p.Head, p.Trunk); err != nil { - return err - } - for _, f := range p.Conflicts { - if f == "" { - return fmt.Errorf("delivery: a preview names a conflicting file with an empty path") - } - } - if len(p.Conflicts) > 0 { - if p.Merge != "" || len(p.Modules) > 0 { - return fmt.Errorf("delivery: a preview of a head in conflict has no merge commit and no modules") - } - return nil - } - if p.Merge == "" { - return fmt.Errorf("delivery: a preview names no merge commit") - } - for dir, m := range p.Modules { - if dir == "" { - return fmt.Errorf("delivery: a preview names a module with an empty directory") - } - if m.Tree == "" { - return fmt.Errorf("delivery: the preview's module %q has no tree", dir) - } - if _, err := ManifestsKey(m.Manifest); err != nil { - return fmt.Errorf("delivery: the preview's module %q: %w", dir, err) - } - for path, hash := range m.Locks { - if path == "" || hash == "" { - return fmt.Errorf("delivery: the lock %q of the preview's module %q has an empty path or hash", path, dir) - } - } - if err := checkPathObjects(m.Paths); err != nil { - return fmt.Errorf("delivery: the preview's module %q: %w", dir, err) - } - } - return nil -} - -// EncodePreview is p as JSON at Version. -func EncodePreview(p Preview) ([]byte, error) { - if err := p.check(); err != nil { - return nil, err - } - p.V = Version - return json.Marshal(p) -} - -// DecodePreview reads a preview. -func DecodePreview(data []byte) (Preview, error) { - var p Preview - if err := decode(data, &p); err != nil { - return Preview{}, err - } - if err := p.check(); err != nil { - return Preview{}, err - } - return p, nil -} - -// SamePreview says whether two previews are the same value. -func SamePreview(a, b Preview) bool { return reflect.DeepEqual(a, b) } diff --git a/go/delivery/provided.go b/go/delivery/provided.go deleted file mode 100644 index f3bcc05..0000000 --- a/go/delivery/provided.go +++ /dev/null @@ -1,58 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// The private network's names as its manifest says them, written here as well as in overlay (Name, Requirement, -// Addressing, TheNetwork), which delivery does not import so its many readers do not reach the network's generator; -// a test keeps them agreeing. -const ( - overlayName = "mesh-wireguard" - overlayRequirement = "private-network" - overlayAddressing = "mesh-addressing" - overlayTheNetwork = "the-private-network" -) - -// overlayManifest is overlay.Manifest(): the module the mesh provides for itself. -func overlayManifest() map[string]any { - return map[string]any{ - "module": overlayName, - "version": "1", - "computed": overlayName, - "provides": []string{overlayRequirement, overlayAddressing}, - "claims": []map[string]any{{"name": overlayTheNetwork, "scope": "node"}}, - } -} - -// The modules the mesh provides itself (issue 1126): no repository holds them, so the indexer reads none; their -// manifests come with mesh-sdk, built in code, and always win over any other. Today the private network alone -// (overlay's mesh-wireguard), built by nobody and run inside each node's own composition. One predicate for every part that judges -// or composes a placement: CheckPlacements, the seat's holder, the composer's input (frombus) and the grants part. - -// ProvidedModules is every module the mesh provides itself, sorted. -func ProvidedModules() []string { return []string{overlayName} } - -// ProvidedByMesh says whether the mesh provides module itself, so a placement may name it with no repository holding it. -func ProvidedByMesh(module string) bool { return module == overlayName } - -// ProvidedManifest is a provided module's manifest, as its readers parse it, and its raw JSON; an error for a module the -// mesh does not provide or a manifest its own reader refuses. -func ProvidedManifest(module string) (catalogue.Manifest, []byte, error) { - if !ProvidedByMesh(module) { - return catalogue.Manifest{}, nil, fmt.Errorf("delivery: the mesh provides no module %s", module) - } - raw, err := json.Marshal(overlayManifest()) - if err != nil { - return catalogue.Manifest{}, nil, err - } - // Decoded as every reader decodes a manifest (novox/hq ADR 0578 §3); its judgement is the indexer's. - m, err := catalogue.Decode(raw) - if err != nil { - return catalogue.Manifest{}, raw, fmt.Errorf("delivery: the manifest mesh-sdk provides for %s does not decode: %w", module, err) - } - return m, raw, nil -} diff --git a/go/delivery/provided_overlay_test.go b/go/delivery/provided_overlay_test.go deleted file mode 100644 index 05523d3..0000000 --- a/go/delivery/provided_overlay_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package delivery_test - -import ( - "encoding/json" - "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// The private network's manifest delivery provides is overlay's, though delivery does not import overlay. -func TestTheProvidedNetworkIsOverlays(t *testing.T) { - _, raw, err := delivery.ProvidedManifest(overlay.Name) - if err != nil { - t.Fatal(err) - } - want, _ := json.Marshal(overlay.Manifest()) - if string(raw) != string(want) { - t.Fatalf("delivery provides\n%s\noverlay says\n%s", raw, want) - } -} diff --git a/go/delivery/published_packages.go b/go/delivery/published_packages.go deleted file mode 100644 index a631821..0000000 --- a/go/delivery/published_packages.go +++ /dev/null @@ -1,126 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "slices" - "strings" - "time" -) - -// The published mesh packages (novox/hq ADR 0593 §2): the indexer alone reads each mesh Go module's package digests -// from the forge's listing at its merge and writes, in its own state, the latest published version of the module and -// the content digest of each package in it. The builder, the checker and the build modules read this state; no other -// part computes a package's version or digest. This is the contract only: the key, the value and its checks. How a -// digest is computed is the indexer's own. - -const ( - // PackagesState is the name of the indexer's state of published packages, as its manifest declares it, and - // PackagesBucket its bucket: one key per mesh Go module (PackagesKey). - PackagesState = "packages" - PackagesBucket = IndexerModule + "_" + PackagesState - // PackagesHistory is the revisions the packages state keeps per key: the module's newest publications. - PackagesHistory = 5 -) - -// PackagesKey is the key, in PackagesBucket, of a mesh Go module (its module path, such as -// git.novox.be/novox/mesh-sdk/go): the path as a key part (EscapeName). It refuses a path that is no mesh Go module's -// (PackageRepository). -func PackagesKey(module string) (string, error) { - if _, _, ok := PackageRepository(module); !ok { - return "", fmt.Errorf("delivery: %q is no mesh Go module's path (it starts with %s)", module, ForgeModulePrefix) - } - return EscapeName(module), nil -} - -// PublishedPackages is the value of a packages key: a mesh Go module as its latest trunk merge published it. Module is -// its module path, Repository (`/`) and Dir its repository and its directory there (RootPath for the -// root), Commit the merge commit (the version a build pins, StepAsk.Packages), Requires the digest of its go.mod and -// go.sum together, and Packages the content digest of each package of the module (its files, tests and sub-packages -// excluded), by import path, sorted and each once. Packages is a list, never a map keyed by import path: a state value -// refuses a field name ending in a credential word, and a package such as .../internal/token is one (issue 1930). -type PublishedPackages struct { - V int `json:"v"` - Module string `json:"module"` - Repository string `json:"repository"` - Dir string `json:"dir"` - Commit string `json:"commit"` - Requires string `json:"requires"` - Packages []PackageDigest `json:"packages"` - At time.Time `json:"at"` -} - -// DigestOf is the content digest of the package at import path pkg, and whether p holds it. -func (p PublishedPackages) DigestOf(pkg string) (string, bool) { - i, found := slices.BinarySearchFunc(p.Packages, pkg, func(d PackageDigest, s string) int { return strings.Compare(d.Package, s) }) - if !found { - return "", false - } - return p.Packages[i].Digest, true -} - -func (p PublishedPackages) check() error { - repository, _, ok := PackageRepository(p.Module) - switch { - case !ok: - return fmt.Errorf("delivery: %q is no mesh Go module's path (it starts with %s)", p.Module, ForgeModulePrefix) - case !SameRepository(p.Repository, repository): - return fmt.Errorf("delivery: the module %s is published from %s, and the value names the repository %q", p.Module, repository, p.Repository) - case p.Dir == "": - return fmt.Errorf("delivery: the published module %s names no directory (%q for the repository's root)", p.Module, RootPath) - case !askCommitOK.MatchString(p.Commit): - return fmt.Errorf("delivery: the published module %s names the commit %q, which is no full commit", p.Module, p.Commit) - case p.Requires == "": - return fmt.Errorf("delivery: the published module %s names no digest of its requirements", p.Module) - case p.At.IsZero(): - return fmt.Errorf("delivery: the published module %s names no time", p.Module) - } - for i, d := range p.Packages { - if d.Package != p.Module && !strings.HasPrefix(d.Package, p.Module+"/") { - return fmt.Errorf("delivery: the package %q is not in the module %s", d.Package, p.Module) - } - if d.Digest == "" { - return fmt.Errorf("delivery: the package %s names no digest", d.Package) - } - if i > 0 && p.Packages[i-1].Package >= d.Package { - return fmt.Errorf("delivery: the packages of %s are not sorted and each once (%s after %s)", p.Module, d.Package, p.Packages[i-1].Package) - } - } - return nil -} - -// EncodePublishedPackages is p as JSON at Version; it refuses what a reader could not take (check). -func EncodePublishedPackages(p PublishedPackages) ([]byte, error) { - if err := p.check(); err != nil { - return nil, err - } - p.V = Version - return json.Marshal(p) -} - -// DecodePublishedPackages reads a packages value; it refuses an unknown version (ErrUnknownVersion) and what -// EncodePublishedPackages refuses. -func DecodePublishedPackages(data []byte) (PublishedPackages, error) { - var p PublishedPackages - if err := decode(data, &p); err != nil { - return PublishedPackages{}, err - } - if err := p.check(); err != nil { - return PublishedPackages{}, err - } - return p, nil -} - -// CheckImports refuses an import set (KeptInputs2.Imports, BuildAnswer.Imports, BuildLogEntry.Imports) that holds a -// path that is no mesh package's, or is not sorted and each once. -func CheckImports(imports []string) error { - for i, pkg := range imports { - if _, _, ok := PackageRepository(pkg); !ok || strings.ContainsAny(pkg, " \t\n@") { - return fmt.Errorf("delivery: the import %q is no mesh package's path (it starts with %s)", pkg, ForgeModulePrefix) - } - if i > 0 && imports[i-1] >= pkg { - return fmt.Errorf("delivery: the imports are not sorted and each once (%s after %s)", pkg, imports[i-1]) - } - } - return nil -} diff --git a/go/delivery/published_packages_test.go b/go/delivery/published_packages_test.go deleted file mode 100644 index 6ae85b6..0000000 --- a/go/delivery/published_packages_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package delivery - -import ( - "encoding/json" - "reflect" - "strings" - "testing" - "time" -) - -// The packages state, the build end's imports and the kept imports (ADR 0593 §2) round-trip, and a reader refuses what it -// could not take, naming it. -func TestPublishedPackagesAndImportsRoundTripAndRefuse(t *testing.T) { - commit := strings.Repeat("a", 40) - sdk := "git.novox.be/novox/mesh-sdk/go" - good := PublishedPackages{Module: sdk, Repository: "novox/mesh-sdk", Dir: "go", Commit: commit, Requires: "sha256:01", - Packages: []PackageDigest{{Package: sdk, Digest: "d0"}, {Package: sdk + "/announce", Digest: "d1"}, {Package: sdk + "/internal/token", Digest: "d2"}}, - At: time.Date(2026, 11, 2, 18, 0, 0, 0, time.UTC)} - raw, err := EncodePublishedPackages(good) - if err != nil { - t.Fatal(err) - } - back, err := DecodePublishedPackages(raw) - good.V = Version - if err != nil || !reflect.DeepEqual(back, good) { - t.Fatalf("round trip: %v, %+v", err, back) - } - if d, ok := back.DigestOf(sdk + "/announce"); !ok || d != "d1" { - t.Fatalf("DigestOf announce = %q, %v", d, ok) - } - if _, ok := back.DigestOf(sdk + "/buildstep"); ok { - t.Fatal("DigestOf finds a package the value does not hold") - } - if key, err := PackagesKey(sdk); err != nil || key != "git=2enovox=2ebe/novox/mesh-sdk/go" { - t.Fatalf("PackagesKey = %q, %v", key, err) - } - if _, err := PackagesKey("github.com/x/y"); err == nil { - t.Fatal("PackagesKey takes a path that is no mesh Go module's") - } - for name, mutate := range map[string]func(p *PublishedPackages){ - "no mesh module": func(p *PublishedPackages) { p.Module = "github.com/x/y" }, - "other repository": func(p *PublishedPackages) { p.Repository = "novox/mesh-host" }, - "short commit": func(p *PublishedPackages) { p.Commit = "abc" }, - "no requires": func(p *PublishedPackages) { p.Requires = "" }, - "package elsewhere": func(p *PublishedPackages) { - p.Packages = []PackageDigest{{Package: "git.novox.be/novox/mesh-host/x", Digest: "d"}} - }, - "unsorted": func(p *PublishedPackages) { p.Packages = []PackageDigest{p.Packages[1], p.Packages[0]} }, - "no digest": func(p *PublishedPackages) { p.Packages = []PackageDigest{{Package: sdk}} }, - } { - p := good - p.Packages = slicesClone(good.Packages) - mutate(&p) - if _, err := EncodePublishedPackages(p); err == nil { - t.Errorf("%s: encoded", name) - } - } - - // The indexer's series value (design 51 *The versions*), as the indexer writes it: read back whole, and refused - // where a reader could not take it. - written := `{"v":1,"name":"` + sdk + `","package":"mesh-sdk/go","series":"0.1","seat":"go-package-registry","go-module":"` + sdk + - `","newest":{"version":"0.1.15","commit":"` + commit + `","digest":"sha256:0f","fingerprint":"fp1","contents":` + string(raw) + - `},"numbered":{"version":"0.1.16","commit":"` + commit + `"},"at":"2026-11-04T14:00:00Z"}` - series, err := DecodeSeriesPackage([]byte(written)) - if err != nil || series.Name != sdk || series.Package != "mesh-sdk/go" || series.Newest.Contents == nil || series.Numbered.Version != "0.1.16" { - t.Fatalf("a series value as the indexer writes it reads %+v, %v", series, err) - } - if again, err := EncodeSeriesPackage(series); err != nil || string(again) != written { - t.Fatalf("a series value writes back as\n%s, %v, not\n%s", again, err, written) - } - if key := SeriesPackageKey(sdk, "0.1"); key != "git=2enovox=2ebe/novox/mesh-sdk/go.0.1" || !IsSeriesPackageKey(key) || IsSeriesPackageKey(EscapeName(sdk)) { - t.Fatalf("the series key %q is not told apart from a per-module key", key) - } - for name, mutate := range map[string]func(s *SeriesPackage){ - "a series with no name": func(s *SeriesPackage) { s.Name = "" }, - "a series that is no series": func(s *SeriesPackage) { s.Series = "0.1.15" }, - "built from no artifact": func(s *SeriesPackage) { s.Package = "mesh-sdk" }, - "a numbered version of nothing": func(s *SeriesPackage) { s.Numbered = &SeriesVersion{} }, - } { - s := series - mutate(&s) - if _, err := EncodeSeriesPackage(s); err == nil { - t.Errorf("%s: encoded", name) - } - } - - imports := []string{sdk, sdk + "/announce"} - outOfStep := []string{"go/seats/meshbroker/contract.go", "ts/seats/meshbroker/src/index.ts"} - end := BuildLogEntry{Event: BuildEndedEvent, Module: "netcheck", FP: "src2:x", Repository: "novox/mesh-catalog", Commit: commit, - Node: "ace", At: good.At, Outcome: "done", Imports: imports, OutOfStep: outOfStep} - raw, err = EncodeBuildLogEntry(end) - if err != nil { - t.Fatal(err) - } - if e, err := DecodeBuildLogEntry(raw); err != nil || !reflect.DeepEqual(e.Imports, imports) || !reflect.DeepEqual(e.OutOfStep, outOfStep) || - !strings.Contains(string(raw), `"out-of-step":["go/seats/meshbroker/contract.go",`) { - t.Fatalf("build end round trip: %v, %v, %v in %s", err, e.Imports, e.OutOfStep, raw) - } - start := end - start.Event, start.Outcome, start.Imports = BuildStartedEvent, "", nil - if _, err := EncodeBuildLogEntry(start); err == nil { - t.Error("a build's start with files out of step encoded") - } - start.Imports, start.OutOfStep = imports, nil - if _, err := EncodeBuildLogEntry(start); err == nil { - t.Error("a build's start with imports encoded") - } - end.Imports = []string{sdk + "/announce", sdk} - if _, err := EncodeBuildLogEntry(end); err == nil { - t.Error("unsorted imports encoded") - } - // A build narrates between its start and its end (build.said): its module, fingerprint, line and why, nothing of an - // end; a start or end carries no line. - said := BuildLogEntry{Event: BuildSaidEvent, Module: "netcheck", FP: "src2:x", At: good.At, - Line: "waiting on container-image-registry", Why: "waiting"} - if raw, err := EncodeBuildLogEntry(said); err != nil { - t.Errorf("a narration refused: %v", err) - } else if e, err := DecodeBuildLogEntry(raw); err != nil || e.Line != said.Line || e.Why != "waiting" { - t.Errorf("a narration's round trip: %+v, %v", e, err) - } - for name, mutate := range map[string]func(e *BuildLogEntry){ - "a narration with no line": func(e *BuildLogEntry) { e.Line = "" }, - "a narration with an outcome": func(e *BuildLogEntry) { e.Outcome = "done" }, - "a narration out of step": func(e *BuildLogEntry) { e.OutOfStep = outOfStep }, - "an end with a line": func(e *BuildLogEntry) { - e.Event, e.Outcome, e.Node, e.Repository, e.Commit = BuildEndedEvent, "done", "ace", "novox/mesh-catalog", commit - }, - } { - e := said - mutate(&e) - if _, err := EncodeBuildLogEntry(e); err == nil { - t.Errorf("%s encoded", name) - } - } - var answer BuildAnswer - if err := json.Unmarshal([]byte(`{"artifacts":[],"imports":["`+sdk+`"],"out-of-step":["go/seats/x/contract.go"]}`), &answer); err != nil || - len(answer.Imports) != 1 || !reflect.DeepEqual(answer.OutOfStep, []string{"go/seats/x/contract.go"}) { - t.Fatalf("build answer imports and files out of step: %v, %v, %v", err, answer.Imports, answer.OutOfStep) - } - // The builder's version.numbered, as a build module consumes it: the builder's own body whole, and its subject. - numbered := VersionNumbered{Name: "mesh-sdk/go", Kind: "package", Version: "0.1.7", Module: "mesh-sdk", Fingerprint: "src2:x", - Commit: commit, Digest: "sha256:d", Seat: "go-package-registry", Head: commit, Again: "2026-11-04T15:00:00Z"} - if raw, err := EncodeVersionNumbered(numbered); err != nil { - t.Errorf("numbered refused: %v", err) - } else if back, err := DecodeVersionNumbered(raw); err != nil || back != numbered || - !strings.Contains(string(raw), `"fingerprint":"src2:x"`) || VersionNumberedSubject != "mesh.mod.mesh-delivery-builder.event.version.numbered" { - t.Errorf("numbered round trip: %+v, %v, %s", back, err, raw) - } - if _, err := DecodeVersionNumbered([]byte(`{"name":"mesh-sdk/go","version":"0.1.7"}`)); err == nil { - t.Error("a numbered version naming no build is read") - } - if err := CheckImports([]string{"fmt"}); err == nil { - t.Error("CheckImports takes a standard package") - } -} - -func slicesClone(d []PackageDigest) []PackageDigest { return append([]PackageDigest(nil), d...) } diff --git a/go/delivery/put.go b/go/delivery/put.go deleted file mode 100644 index 609d298..0000000 --- a/go/delivery/put.go +++ /dev/null @@ -1,148 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "time" -) - -// A put (novox/hq ADR 0546): one version of a module put on one node by the assigner's tool `assigner_put`. Each put is the -// key `.` of the assigner's state puts, which holds the version asked, the hold, why, and each step the -// assigner took or waits on, so a reader watches the key to follow it. The want the put writes carries Put (and Hold), -// so the assigner's rules pass over that key while the put holds it. - -// AssignerPutsBucket is the assigner's bucket of puts, `_puts`. -const AssignerPutsBucket = AssignerModule + "_puts" - -// AssignerPutsHistory is the revisions the puts bucket keeps per key: each step of a put is one. -const AssignerPutsHistory = 20 - -// A put's steps, in the order they are taken (ADR 0546 clauses 3 to 9). A put stands at the last step written. -const ( - // PutAsked is a put written by the tool, not taken up by a pass yet. - PutAsked = "asked" - // PutBuild waits for the builder's build at the fingerprint. - PutBuild = "build" - // PutCompose waits for the controller's composition (and grant) at the fingerprint. - PutCompose = "compose" - // PutPerson waits for the operator's answer to the ask about the move. - PutPerson = "person" - // PutSnapshot waits for the bus's streams to be snapshotted after the operator's yes. - PutSnapshot = "snapshot" - // PutWanted is the want written: the node's have has not reached it yet. - PutWanted = "wanted" - // PutHolding is a held put whose have runs it healthy: the want stays until released. - PutHolding = "holding" - // PutDone is a put without hold whose have ran it healthy: the rules write the want again. - PutDone = "done" - // PutReleased is a put released by the tool `assigner_release`. - PutReleased = "released" - // PutRefused is a put that cannot go on: a build that failed, an answer no, a snapshot not taken. Its words say why. - PutRefused = "refused" -) - -// PutEnded says a put at this step is over: nothing more is done for it and the rules own the want. -func PutEnded(step string) bool { - return step == PutDone || step == PutReleased || step == PutRefused -} - -// PutStep is one step a put took: when, which, and what was done or waits, in a sentence. -type PutStep struct { - At time.Time `json:"at"` - Step string `json:"step"` - Words string `json:"words"` -} - -// PutStepsKept is how many steps a put keeps, newest last. -const PutStepsKept = 16 - -// Put is the value of a key of AssignerPutsBucket. -type Put struct { - V int `json:"v"` - // FP is the fingerprint put, of the new form. - FP string `json:"fp"` - // Hold keeps the want at FP until released. - Hold bool `json:"hold,omitempty"` - Why string `json:"why"` - // By is who asked, as the bus named the caller; "unproven" when it named none. - By string `json:"by,omitempty"` - At time.Time `json:"at"` - // Step is where the put stands now, one of the Put* steps; Words say it in a sentence. - Step string `json:"step"` - Words string `json:"words,omitempty"` - // Composed is the digest of the composition the want names, once known. - Composed string `json:"composed,omitempty"` - // Ask is the key, in AssignerAsksBucket, of the ask about the move, once asked. - Ask string `json:"ask,omitempty"` - // Snapshot says where the bus's streams were snapshotted, once they were. - Snapshot string `json:"snapshot,omitempty"` - // Steps are the steps taken, oldest first, at most PutStepsKept. - Steps []PutStep `json:"steps,omitempty"` -} - -// Said is p at step with words, the step added to its steps when it differs from the last one. -func (p Put) Said(at time.Time, step, words string) Put { - if p.Step == step && p.Words == words { - return p - } - p.Step, p.Words = step, words - p.Steps = append(append([]PutStep(nil), p.Steps...), PutStep{At: at.UTC(), Step: step, Words: words}) - if len(p.Steps) > PutStepsKept { - p.Steps = p.Steps[len(p.Steps)-PutStepsKept:] - } - return p -} - -func (p Put) check() error { - if err := checkFingerprint(p.FP, "a put's fingerprint"); err != nil { - return err - } - if p.Why == "" { - return fmt.Errorf("delivery: a put says why") - } - switch p.Step { - case PutAsked, PutBuild, PutCompose, PutPerson, PutSnapshot, PutWanted, PutHolding, PutDone, PutReleased, PutRefused: - default: - return fmt.Errorf("delivery: %q is not a step of a put", p.Step) - } - if p.Composed != "" { - return checkDigest(p.Composed, "a put's composition digest") - } - return nil -} - -// PutKey is the key, in AssignerPutsBucket, of the put of module on node: `.`. -func PutKey(node, module string) (string, error) { - return HaveKey(node, module) -} - -// ParsePutKey reads a PutKey. -func ParsePutKey(key string) (node, module string, err error) { - node, module, isNode, err := ParseHaveKey(key) - if err == nil && isNode { - err = fmt.Errorf("delivery: %q is a node's own key, and no put", key) - } - return node, module, err -} - -// EncodePut is p as JSON at Version; it refuses a fingerprint not of the new form, a put that says no why and an -// unknown step. -func EncodePut(p Put) ([]byte, error) { - if err := p.check(); err != nil { - return nil, err - } - p.V = Version - return json.Marshal(p) -} - -// DecodePut reads a put; it refuses an unknown version (ErrUnknownVersion) and what EncodePut refuses. -func DecodePut(data []byte) (Put, error) { - var p Put - if err := decode(data, &p); err != nil { - return Put{}, err - } - if err := p.check(); err != nil { - return Put{}, err - } - return p, nil -} diff --git a/go/delivery/reach.go b/go/delivery/reach.go deleted file mode 100644 index ad84483..0000000 --- a/go/delivery/reach.go +++ /dev/null @@ -1,167 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "maps" - "slices" - "strings" - "time" -) - -// How far a repository reached (novox/hq ADR 0556, issue 1280). The indexer says, per repository, the last merge it took in -// (TakenBucket); the delivery view keeps, per repository, how far each of its modules reached on the nodes that want it, and -// derives from it the repository's reached head (ReachBucket): the newest head of the repository at which every wanted -// module runs, on every node, a build at or after its last change. A merge at or before the reached head is on the -// machines. Nothing records a merge as delivered (ADR 0429 §1): the gate and the tracker derive it from this state. - -// TakenBucket is the indexer's state `taken`: one key per repository (RepositoryKey), the last merge it took in. -const TakenBucket = IndexerModule + "_taken" - -// ReachBucket is the delivery view's state `reach`: one key per repository (RepositoryKey), how far its modules reached. -const ReachBucket = ViewModule + "_reach" - -// RepositoryKey is a repository's key in TakenBucket and ReachBucket: its forge name `owner/name` (RepositoryName), in -// lower case as the forge compares it, escaped as a key part (EscapeName). An empty spelling, or one with no owner, is -// refused. -func RepositoryKey(repository string) (string, error) { - name := strings.ToLower(RepositoryName(repository)) - if owner, repo, ok := strings.Cut(name, "/"); !ok || owner == "" || repo == "" { - return "", fmt.Errorf("delivery: %q names no repository as owner/name", repository) - } - return EscapeName(name), nil -} - -// Taken is the value of a taken key: the repository, the merge commit the indexer last took in (applied its message -// whole), and when. -type Taken struct { - V int `json:"v"` - Repository string `json:"repository"` - Merge string `json:"merge"` - At time.Time `json:"at"` -} - -// EncodeTaken is t as JSON at Version; a value with no repository or merge is refused. -func EncodeTaken(t Taken) ([]byte, error) { - if t.Repository == "" || t.Merge == "" { - return nil, fmt.Errorf("delivery: a taken value names its repository and merge") - } - t.V = Version - return json.Marshal(t) -} - -// DecodeTaken reads a taken value; it refuses an unknown version (ErrUnknownVersion) and one with no merge. -func DecodeTaken(data []byte) (Taken, error) { - var t Taken - if err := decode(data, &t); err != nil { - return t, err - } - if t.Repository == "" || t.Merge == "" { - return t, fmt.Errorf("delivery: a taken value names no repository or merge") - } - return t, nil -} - -// Reach is the value of a reach key: how far the modules of one repository reached on the nodes. Indexed is the last -// merge the indexer took in for it (Taken.Merge), empty while it took in none. A repository the indexer holds no module -// of (a library) has no Modules. Reached is the reached head: the newest head of the repository in the source's history, -// not past Indexed, at which every module with a node that wants it has its floor at or after its last change at or before -// that head; ReachedSrc is the source revision that named it. Holds says, in words, what keeps the next head of the -// repository from being reached (a module on a node, the indexer's lag); empty when the repository's newest head is reached. -type Reach struct { - V int `json:"v"` - Repository string `json:"repository"` - Indexed string `json:"indexed,omitempty"` - Modules map[string]ModuleReach `json:"modules,omitempty"` - Reached string `json:"reached,omitempty"` - ReachedSrc uint64 `json:"reached_src,omitempty"` - Holds []string `json:"holds,omitempty"` -} - -// ModuleReach is how far one module reached. FP is its fingerprint at the indexer now; Nodes how many nodes want it -// (a want not removed); Changes its fingerprints the view knows of, each with the merge commit it first appeared at, -// newest first. Floor is the oldest first-appearance commit among the fingerprints its nodes' haves run, a running have -// counting whether its health check passes or not; it is empty when a node that wants the module has no running have, or -// runs a fingerprint not among Changes. Waiting names, in words, every node where the module is not settled (its have -// different from its want, not running, or not healthy), with or without a floor: health is said here and never moves the -// floor (ADR 0556 clause 2). A module whose floor is empty while a node wants it holds no head of the repository back: a -// reader takes a merge among its Changes as not yet on the machines, with these words. A module no node wants has neither. -type ModuleReach struct { - FP string `json:"fp,omitempty"` - Nodes int `json:"nodes"` - Changes []Change `json:"changes,omitempty"` - Floor string `json:"floor,omitempty"` - Waiting string `json:"waiting,omitempty"` -} - -// Change is one fingerprint of a module, the merge commit it first appeared at (Module.Commit), and the source revision -// that named that commit as the repository's head (Module.Src, or the source's history), 0 when not known. -type Change struct { - Commit string `json:"commit"` - FP string `json:"fp"` - Src uint64 `json:"src,omitempty"` -} - -// MostChanges is how many changes of a module a reach value keeps: as many revisions as the indexer's modules bucket keeps. -const MostChanges = ModulesHistory - -func (r Reach) check() error { - if r.Repository == "" { - return fmt.Errorf("delivery: a reach value names no repository") - } - if (r.Reached == "") != (r.ReachedSrc == 0) { - return fmt.Errorf("delivery: the reach of %s names its reached head and its source revision together", r.Repository) - } - for name, m := range r.Modules { - if name == "" { - return fmt.Errorf("delivery: a reach value names a module with no name") - } - if m.Nodes < 0 { - return fmt.Errorf("delivery: the reach of %s counts %d nodes", name, m.Nodes) - } - if len(m.Changes) > MostChanges { - return fmt.Errorf("delivery: the reach of %s keeps %d changes, more than %d", name, len(m.Changes), MostChanges) - } - for _, c := range m.Changes { - if c.Commit == "" || c.FP == "" { - return fmt.Errorf("delivery: a change of %s names no commit or fingerprint", name) - } - } - } - return nil -} - -// EncodeReach is r as JSON at Version; a value that does not check is refused. -func EncodeReach(r Reach) ([]byte, error) { - if err := r.check(); err != nil { - return nil, err - } - r.V = Version - return json.Marshal(r) -} - -// DecodeReach reads a reach value; it refuses an unknown version (ErrUnknownVersion) and one that does not check. -func DecodeReach(data []byte) (Reach, error) { - var r Reach - if err := decode(data, &r); err != nil { - return r, err - } - return r, r.check() -} - -// SameReach says whether two reach values are the same, so the view writes only when they differ. -func SameReach(a, b Reach) bool { - if a.Repository != b.Repository || a.Indexed != b.Indexed || len(a.Modules) != len(b.Modules) || - a.Reached != b.Reached || a.ReachedSrc != b.ReachedSrc || !slices.Equal(a.Holds, b.Holds) { - return false - } - for _, name := range slices.Sorted(maps.Keys(a.Modules)) { - x, ok := b.Modules[name] - y := a.Modules[name] - if !ok || x.FP != y.FP || x.Nodes != y.Nodes || x.Floor != y.Floor || x.Waiting != y.Waiting || - !slices.Equal(x.Changes, y.Changes) { - return false - } - } - return true -} diff --git a/go/delivery/seats_for_tests_test.go b/go/delivery/seats_for_tests_test.go deleted file mode 100644 index 51763c3..0000000 --- a/go/delivery/seats_for_tests_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package delivery - -import ( - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" -) - -// The seats the tests judge against are given to catalogue as a host gives the ones it read from the bus (ADR 0635 §1). -func init() { catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) } diff --git a/go/delivery/series_package.go b/go/delivery/series_package.go deleted file mode 100644 index 98ad84a..0000000 --- a/go/delivery/series_package.go +++ /dev/null @@ -1,105 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "regexp" - "strings" - "time" -) - -// The packages state per series (design 51 *The versions*, design 32; ADR 0606 §1, ADR 0609 §1): beside the per-module -// values (PublishedPackages), the indexer keeps one key per package and series, `.` (SeriesPackageKey), -// holding the newest trunk version of that series the builder numbered and the newest a registry seat published. The -// indexer alone writes it; its readers (the builder, the checker, the build modules) take this value's form from here. -// A series key always holds a dot, which EscapeName never leaves in a per-module key (IsSeriesPackageKey). - -// SeriesPackage is the value of a series key. Name is the package's name in its own ecosystem (a Go package's module -// path in its go.mod at the commit, an npm package's package.json name): the key is named by it, and a user's -// `packages` names it. Package is the module and artifact it is built from, `/`, by which the builder -// finds the name to number under; Series its `.`. Seat is the registry seat it is published through; -// GoModule the Go module path a Go package is fetched under, empty for any other. Newest is the newest version of the -// series a seat published (what a user accepting the series builds against), Numbered the newest the builder numbered, -// published or not yet. -type SeriesPackage struct { - V int `json:"v"` - Name string `json:"name"` - Package string `json:"package,omitempty"` - Series string `json:"series"` - Seat string `json:"seat,omitempty"` - GoModule string `json:"go-module,omitempty"` - Newest *SeriesVersion `json:"newest,omitempty"` - Numbered *SeriesVersion `json:"numbered,omitempty"` - At time.Time `json:"at"` -} - -// SeriesVersion is one version of a series: its number, the source commit it was built from, the digest and -// fingerprint. Contents are, for a series' newest of a Go package, its Go module's requirements' and packages' digests -// at Commit: what a user accepting the series is fingerprinted against; nil when the forge could not list it, and on a -// numbered version. -type SeriesVersion struct { - Version string `json:"version"` - Commit string `json:"commit,omitempty"` - Digest string `json:"digest,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` - Contents *PublishedPackages `json:"contents,omitempty"` -} - -// SeriesPackageKey is the key of a package's series in the packages state: its ecosystem name as a key part and the -// series. -func SeriesPackageKey(name, series string) string { return EscapeName(name) + "." + series } - -// IsSeriesPackageKey says whether a key of the packages state is a series key, not a per-module one. -func IsSeriesPackageKey(key string) bool { return strings.Contains(key, ".") } - -// builtFrom says whether pkg is `/`: a module name (no slash) and an artifact named after it. -func builtFrom(pkg string) bool { - module, artifact, ok := strings.Cut(pkg, "/") - return ok && module != "" && artifact != "" -} - -var seriesForm = regexp.MustCompile(`^[0-9]+\.[0-9]+$`) - -func (s SeriesPackage) check() error { - switch { - case s.Name == "": - return fmt.Errorf("delivery: a series of %q names no package in its ecosystem", s.Package) - case !seriesForm.MatchString(s.Series): - return fmt.Errorf("delivery: the series %q of %s is no .", s.Series, s.Name) - case s.Package != "" && !builtFrom(s.Package): - return fmt.Errorf("delivery: the series %s of %s is built from %q, which is no /", s.Series, s.Name, s.Package) - } - for which, v := range map[string]*SeriesVersion{"newest": s.Newest, "numbered": s.Numbered} { - if v != nil && v.Version == "" { - return fmt.Errorf("delivery: the %s version of %s %s names no version", which, s.Name, s.Series) - } - if v != nil && v.Contents != nil { - if err := v.Contents.check(); err != nil { - return err - } - } - } - return nil -} - -// EncodeSeriesPackage is s as JSON at Version; it refuses what a reader could not take. -func EncodeSeriesPackage(s SeriesPackage) ([]byte, error) { - if err := s.check(); err != nil { - return nil, err - } - s.V = Version - return json.Marshal(s) -} - -// DecodeSeriesPackage reads a series key's value; it refuses an unknown version (ErrUnknownVersion) and what -// EncodeSeriesPackage refuses. -func DecodeSeriesPackage(data []byte) (SeriesPackage, error) { - var s SeriesPackage - if err := decode(data, &s); err != nil { - return SeriesPackage{}, err - } - if err := s.check(); err != nil { - return SeriesPackage{}, err - } - return s, nil -} diff --git a/go/delivery/signers.go b/go/delivery/signers.go deleted file mode 100644 index 5a6b435..0000000 --- a/go/delivery/signers.go +++ /dev/null @@ -1,323 +0,0 @@ -package delivery - -import ( - "crypto/ed25519" - "crypto/sha256" - "crypto/x509" - "crypto/x509/pkix" - "encoding/base64" - "encoding/hex" - "encoding/json" - "encoding/pem" - "errors" - "fmt" - "math/big" - "slices" - "strings" - "time" -) - -// **The vault signs** (novox/hq ADR 0550 §2; design 47's L3 item d). The vault makes an ed25519 signing key and an x509 -// authority once, keeps both as its own secrets with their operator copies, and publishes their public halves in its -// state `signers`, which only the vault writes and every node principal and composing part reads. Its verb `sign` signs a -// node's certificate for that node's own node-engine, from bus state alone (the name from the node's placement, the -// serving key from its record), and says `signed` naming the caller, the kind, the node and the digest, never the key. -// -// A node trusts the vault's key through the bus grants alone (the lead's design limit, 2026-10-28): only the vault may -// write `signers`, the same trust the wants and the placement rest on. A node trusts the vault's key alone since the -// controller ended (ADR 0590). What a node trusts now it states in its record, NodeRecord.Trusts. - -const ( - // VaultModule is the module holding the seat mesh-vault, whose state `signers` is. - VaultModule = "mesh-vault" - // SignersState is the vault's state of its public signing halves, and SignersBucket its bucket. - SignersState = "signers" - SignersBucket = VaultModule + "_" + SignersState - // SignersRead is the state as a manifest's `reads` names it. - SignersRead = VaultModule + "." + SignersState - // SignersKeyKey holds the signing key's public half (SignerKey); SignersAuthorityKey the authority (SignerAuthority). - SignersKeyKey = "key" - SignersAuthorityKey = "authority" - - // The verb sign, its answer and the event signed are the vault's contract's (mesh-contracts meshvault: VerbSign, - // SignAnswer, HolderEventSigned, HolderSubjectSigned, SignedHolderEvent; novox/hq ADR 0627 §1). - - // KindNodeCertificate is a node's certificate for its internal name, asked by that node's node-engine alone. - KindNodeCertificate = "node-certificate" - // KindEnrolment is a redeem answer (RedeemAnswer), signed under its own context for mesh-enrolment alone (ADR 0587 - // §3, §6). - KindEnrolment = "enrolment" - // KindAuthority is the vault's own authority certificate, which the vault signs for itself. - KindAuthority = "authority" - - // SignerVault names the signer a node trusts. - SignerVault = "vault" - - // CertificateLife is how long a certificate the vault signs for a node lasts: ten years, renewed by asking again, - // never by a clock running out unseen. - CertificateLife = 10 * 365 * 24 * time.Hour -) - -// SignKinds are the kinds `sign` knows. -var SignKinds = []string{KindNodeCertificate, KindEnrolment, KindAuthority} - -// SignContext is the line a message of kind is signed after, so a signature made for one kind never verifies as -// another's (as the composition's and the asks' contexts are kept apart). -func SignContext(kind string) (string, error) { - if !slices.Contains(SignKinds, kind) { - return "", fmt.Errorf("delivery: %q is not a kind the vault signs (%s)", kind, strings.Join(SignKinds, ", ")) - } - return "novox " + kind + " v1\n", nil -} - -// SignerKey is the key `key` of `signers`: the vault's signing key's public half in standard base64, its fingerprint, -// and when it was made. -type SignerKey struct { - Key string `json:"key"` - Fingerprint string `json:"fingerprint"` - Made time.Time `json:"made"` -} - -// SignerAuthority is the key `authority` of `signers`: the vault's authority certificate as PEM, its fingerprint, and -// when it was made. -type SignerAuthority struct { - Certificate string `json:"certificate"` - Fingerprint string `json:"fingerprint"` - Made time.Time `json:"made"` -} - -// SignerFingerprint is an ed25519 public key's fingerprint, `sha256:` of its 32 bytes. -func SignerFingerprint(key ed25519.PublicKey) string { - sum := sha256.Sum256(key) - return "sha256:" + hex.EncodeToString(sum[:]) -} - -// AuthorityFingerprint is an authority certificate's fingerprint, `sha256:` of its DER; refused when certificate is -// not one PEM certificate. -func AuthorityFingerprint(certificate string) (string, error) { - c, err := ParseCertificate(certificate) - if err != nil { - return "", err - } - sum := sha256.Sum256(c.Raw) - return "sha256:" + hex.EncodeToString(sum[:]), nil -} - -// ParseCertificate is the first certificate of a PEM text. -func ParseCertificate(text string) (*x509.Certificate, error) { - block, _ := pem.Decode([]byte(strings.TrimSpace(text))) - if block == nil || block.Type != "CERTIFICATE" { - return nil, errors.New("delivery: not a PEM certificate") - } - return x509.ParseCertificate(block.Bytes) -} - -// NewSignerKey is the value of `key` for pub. -func NewSignerKey(pub ed25519.PublicKey, made time.Time) SignerKey { - return SignerKey{Key: base64.StdEncoding.EncodeToString(pub), Fingerprint: SignerFingerprint(pub), Made: made.UTC()} -} - -// PublicKey is k's key, checked: an ed25519 public key whose fingerprint is the one k names. -func (k SignerKey) PublicKey() (ed25519.PublicKey, error) { - raw, err := base64.StdEncoding.DecodeString(k.Key) - if err != nil || len(raw) != ed25519.PublicKeySize { - return nil, errors.New("delivery: the signers' key is not an ed25519 public key in base64") - } - if fp := SignerFingerprint(raw); fp != k.Fingerprint { - return nil, fmt.Errorf("delivery: the signers' key has the fingerprint %s, and names %s", fp, k.Fingerprint) - } - return ed25519.PublicKey(raw), nil -} - -// DecodeSignerKey reads the key `key` of `signers`, refused when it does not check. -func DecodeSignerKey(raw []byte) (SignerKey, error) { - var k SignerKey - if err := json.Unmarshal(raw, &k); err != nil { - return SignerKey{}, fmt.Errorf("delivery: %s %s: %w", SignersRead, SignersKeyKey, err) - } - if _, err := k.PublicKey(); err != nil { - return SignerKey{}, err - } - return k, nil -} - -// DecodeSignerAuthority reads the key `authority` of `signers`: one authority certificate (a CA that signs leaves -// alone) whose fingerprint is the one named. -func DecodeSignerAuthority(raw []byte) (SignerAuthority, error) { - var a SignerAuthority - if err := json.Unmarshal(raw, &a); err != nil { - return SignerAuthority{}, fmt.Errorf("delivery: %s %s: %w", SignersRead, SignersAuthorityKey, err) - } - c, err := ParseCertificate(a.Certificate) - if err != nil { - return SignerAuthority{}, err - } - if !c.IsCA { - return SignerAuthority{}, errors.New("delivery: the signers' authority is not an authority certificate") - } - if fp, _ := AuthorityFingerprint(a.Certificate); fp != a.Fingerprint { - return SignerAuthority{}, fmt.Errorf("delivery: the signers' authority has the fingerprint %s, and names %s", fp, a.Fingerprint) - } - return a, nil -} - -// Signer is one key a node trusts, by who holds it (SignerVault, or the operator's). -type Signer struct { - Name string - Key ed25519.PublicKey -} - -// Fingerprint is the signer's key's fingerprint. -func (s Signer) Fingerprint() string { return SignerFingerprint(s.Key) } - -// VerifyBy is the trusted signer whose key verifies sig over context and msg: refused when no trusted key does, saying -// so, and when a key is not an ed25519 key. A signature made under another context never verifies. -func VerifyBy(trusted []Signer, context string, msg, sig []byte) (Signer, error) { - if len(sig) != ed25519.SignatureSize { - return Signer{}, errors.New("delivery: not an ed25519 signature") - } - message := append([]byte(context), msg...) - for _, s := range trusted { - if len(s.Key) != ed25519.PublicKeySize { - continue - } - if ed25519.Verify(s.Key, message, sig) { - return s, nil - } - } - names := make([]string, 0, len(trusted)) - for _, s := range trusted { - names = append(names, s.Name) - } - return Signer{}, fmt.Errorf("delivery: the signature verifies against no trusted key (%s)", strings.Join(names, ", ")) -} - -// SignMessage is sig over context and msg by key: what the vault's `sign` does for a kind whose bytes are given. -func SignMessage(key ed25519.PrivateKey, context string, msg []byte) []byte { - return ed25519.Sign(key, append([]byte(context), msg...)) -} - -// DigestOf is the digest a `signed` names: `sha256:` of the bytes signed. -func DigestOf(b []byte) string { - sum := sha256.Sum256(b) - return "sha256:" + hex.EncodeToString(sum[:]) -} - -// NodeCertificateTemplate is the certificate the vault signs for node's internal name, as the controller's authority -// shaped it (identity.Certify): the name in the subject and the subject alternative names, server and client use, the -// serial derived from the name and the key so the same ask gives the same certificate, and its life from notBefore. -func NodeCertificateTemplate(name string, servingKey ed25519.PublicKey, notBefore time.Time) *x509.Certificate { - sum := sha256.Sum256(append([]byte(name+"\n"), servingKey...)) - return &x509.Certificate{ - SerialNumber: new(big.Int).SetBytes(sum[:16]), - Subject: pkix.Name{CommonName: name}, - DNSNames: []string{name}, - NotBefore: notBefore.UTC().Truncate(time.Second), - NotAfter: notBefore.UTC().Truncate(time.Second).Add(CertificateLife), - KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, - } -} - -// AuthorityTemplate is the vault's authority certificate for its key, made once: an authority that signs leaves and no -// other authority, as the controller's. -func AuthorityTemplate(key ed25519.PublicKey, made time.Time) *x509.Certificate { - sum := sha256.Sum256(append([]byte("authority\n"), key...)) - return &x509.Certificate{ - SerialNumber: new(big.Int).SetBytes(sum[:16]), - Subject: pkix.Name{CommonName: "the mesh's vault"}, - NotBefore: made.UTC().Truncate(time.Second), - NotAfter: made.UTC().Truncate(time.Second).Add(2 * CertificateLife), - IsCA: true, - KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, - BasicConstraintsValid: true, - MaxPathLen: 0, - MaxPathLenZero: true, - } -} - -// CheckNodeCertificate says why certificate is not a certificate of name for servingKey by one of the authorities, or -// nil: a node uses it to tell whether its record holds a certificate from an authority it trusts, and another node to -// check one the vault signed (ADR 0550 §2's live check). -func CheckNodeCertificate(certificate string, authorities []string, name string, servingKey ed25519.PublicKey, at time.Time) error { - c, err := ParseCertificate(certificate) - if err != nil { - return err - } - pool := x509.NewCertPool() - for _, a := range authorities { - pool.AppendCertsFromPEM([]byte(a)) // a bundle is every certificate it holds - } - if _, err := c.Verify(x509.VerifyOptions{Roots: pool, DNSName: name, CurrentTime: at, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}); err != nil { - return fmt.Errorf("delivery: the certificate of %s is not one a trusted authority signed for it: %w", name, err) - } - held, ok := c.PublicKey.(ed25519.PublicKey) - if !ok || !held.Equal(servingKey) { - return fmt.Errorf("delivery: the certificate of %s binds another key than the node serves with", name) - } - return nil -} - -// AuthorityBundle is the authorities given as one PEM text, each certificate once, in the order given, blanks left out: -// what a module asking for the mesh's authority is given once the vault signs beside the controller (ADR 0550 §2). An -// authority given is split into its certificates, so a bundle given again (the one a node kept from its last -// composition) adds nothing twice; a text that holds no PEM block is taken whole. -func AuthorityBundle(authorities ...string) string { - var out []string - for _, a := range authorities { - for _, piece := range pemPieces(a) { - if !slices.Contains(out, piece) { - out = append(out, piece) - } - } - } - if len(out) == 0 { - return "" - } - return strings.Join(out, "\n") + "\n" -} - -// pemPieces is each PEM block of text re-encoded, or the trimmed text whole when it holds none. -func pemPieces(text string) []string { - text = strings.TrimSpace(text) - if text == "" { - return nil - } - var out []string - rest := []byte(text) - for { - block, more := pem.Decode(rest) - if block == nil { - break - } - out = append(out, strings.TrimSpace(string(pem.EncodeToMemory(block)))) - rest = more - } - if len(out) == 0 { - return []string{text} - } - return out -} - -// Trusts is what a node states it trusts now (NodeRecord.Trusts): ` key ` for each signer and -// ` authority ` for each authority certificate, by who holds it, each once, sorted. The gate before the -// lease write reads `vault key ` in every node's record. -func Trusts(signers []Signer, authorities map[string]string) []string { - var out []string - for _, s := range signers { - if len(s.Key) == ed25519.PublicKeySize { - out = append(out, s.Name+" key "+s.Fingerprint()) - } - } - for who, a := range authorities { - if fp, err := AuthorityFingerprint(a); err == nil { - out = append(out, who+" authority "+fp) - } - } - slices.Sort(out) - return slices.Compact(out) -} - -// TrustsKey says whether trusts names the key with fingerprint fp held by who. -func TrustsKey(trusts []string, who, fp string) bool { return slices.Contains(trusts, who+" key "+fp) } diff --git a/go/delivery/source.go b/go/delivery/source.go deleted file mode 100644 index 500dfd8..0000000 --- a/go/delivery/source.go +++ /dev/null @@ -1,887 +0,0 @@ -package delivery - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "path" - "reflect" - "sort" - "strings" -) - -// The modules of the parts release 3 adds (novox/hq ADR 0437): their buckets are their own state, `_`. -const ( - GateModule = "mesh-delivery-gate" - IndexerModule = "mesh-delivery-indexer" - ForgeBridgeModule = "mesh-delivery-forge-bridge" - TellerModule = "mesh-delivery-teller" -) - -// The gate's source bucket and the indexer's candidates and modules buckets (ADR 0437 §5). The source bucket has -// one key, SourceMainKey: every repository's merged head and the group that moved it, written by the gate alone by -// compare-and-set; its revision is the mesh's source revision. The indexer writes the other two. -const ( - SourceBucket = GateModule + "_source" - SourceMainKey = "main" - SourceHistory = 64 - CandidatesBucket = IndexerModule + "_candidates" - // CandidatesHistory is the revisions the candidates bucket keeps per key. - CandidatesHistory = 10 - ModulesBucket = IndexerModule + "_modules" - // ModulesHistory is the revisions the modules bucket keeps per key. - ModulesHistory = 20 - // ManifestsBucket is the indexer's bucket of manifests by digest (novox/hq ADR 0429 clause 16); see manifests.go. - ManifestsBucket = IndexerModule + "_manifests" - // ManifestsHistory is the revisions the manifests bucket keeps per key: a key is written once. - ManifestsHistory = 1 -) - -// ForgeStream is the forge bridge's stream as its manifest declares it; the broker derives the real names. -const ForgeStream = "forge" - -// The kinds of forge message, the last part of a forge event's name. -const ( - ForgePull = "pull" - ForgeMerged = "merged" - ForgeRefused = "refused" - ForgeOpen = "open" - // ForgeBranchDeleted says a branch of a repository was deleted on the forge, from the forge's own delete event - // (design 51 *Tagged versions*): the builder removes the tagged versions it asked for that branch. It names the - // repository and the branch and carries nothing else. A reader that does not know the kind ignores it. - ForgeBranchDeleted = "branch-deleted" -) - -// OpenPull names an open pull request into the trunk: the repository as `/` and the number. -type OpenPull struct { - Repository string `json:"repository"` - Number int `json:"number"` -} - -// OpenEvent is the name of an open message: `forge.open`. It names no repository: it says every open pull request of -// every repository the forge bridge reads (novox/hq ADR 0443 §5). -func OpenEvent() string { return "forge." + ForgeOpen } - -// The actions of a pull message. -const ( - PullOpened = "opened" - PullUpdated = "updated" - PullClosed = "closed" -) - -// SourcePrefix opens every source fingerprint. -const SourcePrefix = "src1:" - -// CandidatesKey is the key, in CandidatesBucket, of a group: ``, the branch name the group's pull requests -// share (a slash is allowed); a lone pull request is a group of one under its branch name. -func CandidatesKey(group string) (string, error) { - return groupKey(group) -} - -// ParseCandidatesKey is the inverse of CandidatesKey. -func ParseCandidatesKey(key string) (group string, err error) { - if group, err = parseGroupKey(key); err != nil { - return "", fmt.Errorf("delivery: %q is not a candidates key: %w", key, err) - } - return group, nil -} - -// groupKey is a group name as a key part: escaped (EscapeName), so a branch such as `fix/1.2` keys. -func groupKey(group string) (string, error) { - if group == "" { - return "", fmt.Errorf("delivery: the group name is empty") - } - return EscapeName(group), nil -} - -// parseGroupKey is the inverse of groupKey. -func parseGroupKey(part string) (string, error) { - if part == "" { - return "", fmt.Errorf("delivery: the group name is empty") - } - group, err := UnescapeName(part) - if err != nil { - return "", err - } - return group, nil -} - -// escapeRepository is `/` as a key part, each part escaped; the repository must have exactly one slash -// and neither part empty. -func escapeRepository(repository string) (string, error) { - owner, name, ok := strings.Cut(repository, "/") - if !ok || owner == "" || name == "" || strings.Contains(name, "/") { - return "", fmt.Errorf("delivery: the repository %q is not /, with exactly one slash", repository) - } - return EscapeName(owner) + "/" + EscapeName(name), nil -} - -// unescapeRepository is the inverse of escapeRepository. -func unescapeRepository(part string) (string, error) { - owner, name, ok := strings.Cut(part, "/") - if !ok || owner == "" || name == "" || strings.Contains(name, "/") { - return "", fmt.Errorf("delivery: the repository %q is not /, with exactly one slash", part) - } - o, err := UnescapeName(owner) - if err != nil { - return "", err - } - n, err := UnescapeName(name) - if err != nil { - return "", err - } - return o + "/" + n, nil -} - -// ModulesKey is the key, in ModulesBucket, of a module: ``. -func ModulesKey(module string) (string, error) { - if err := checkName("module", module); err != nil { - return "", err - } - return module, nil -} - -// ParseModulesKey is the inverse of ModulesKey. -func ParseModulesKey(key string) (module string, err error) { - if module, err = ModulesKey(key); err != nil { - return "", fmt.Errorf("delivery: %q is not a modules key: %w", key, err) - } - return module, nil -} - -// checkRepository refuses a repository that is not `/`: exactly one slash, each part a valid name. -func checkRepository(repository string) error { - owner, name, ok := strings.Cut(repository, "/") - if !ok || strings.Contains(name, "/") { - return fmt.Errorf("delivery: the repository %q is not /, with exactly one slash", repository) - } - if err := checkName("repository owner", owner); err != nil { - return fmt.Errorf("delivery: the repository %q: %w", repository, err) - } - if err := checkName("repository name", name); err != nil { - return fmt.Errorf("delivery: the repository %q: %w", repository, err) - } - if strings.Contains(owner, "/") { - return fmt.Errorf("delivery: the repository %q is not /, with exactly one slash", repository) - } - return nil -} - -func checkForgeKind(kind string) error { - switch kind { - case ForgePull, ForgeMerged, ForgeRefused, ForgeOpen, ForgeBranchDeleted: - return nil - } - return fmt.Errorf("delivery: the forge kind %q is not pull, merged, refused, open or branch-deleted", kind) -} - -// ForgeEvent is the name of a forge message of the given kind: `forge...`. -func ForgeEvent(repository, kind string) (string, error) { - if err := checkForgeKind(kind); err != nil { - return "", err - } - if kind == ForgeOpen { - return "", fmt.Errorf("delivery: an open message names no repository, its event is %s", OpenEvent()) - } - if err := checkRepository(repository); err != nil { - return "", err - } - owner, name, _ := strings.Cut(repository, "/") - return "forge." + owner + "." + name + "." + kind, nil -} - -// ParseForgeEvent is the inverse of ForgeEvent. -func ParseForgeEvent(event string) (repository, kind string, err error) { - if event == OpenEvent() { - return "", ForgeOpen, nil - } - parts := strings.Split(event, ".") - if len(parts) != 4 || parts[0] != "forge" { - return "", "", fmt.Errorf("delivery: %q is not a forge event, forge...", event) - } - repository = parts[1] + "/" + parts[2] - if _, err := ForgeEvent(repository, parts[3]); err != nil { - return "", "", fmt.Errorf("delivery: %q is not a forge event: %w", event, err) - } - return repository, parts[3], nil -} - -// Head is one pull request at one commit: the repository as `/` and the full sha. -type Head struct { - Repository string `json:"repository"` - Number int `json:"number"` - Head string `json:"head"` -} - -// HeadsHash is the first 12 hex digits of the sha256 over the lines `#=`, sorted and -// joined by a newline: the same for every order of heads, different when any head changes. An empty list is refused. -func HeadsHash(heads []Head) (string, error) { - if len(heads) == 0 { - return "", fmt.Errorf("delivery: a heads hash needs at least one pull request") - } - lines := make([]string, len(heads)) - for i, h := range heads { - lines[i] = fmt.Sprintf("%s#%d=%s", h.Repository, h.Number, h.Head) - } - sort.Strings(lines) - sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) - return hex.EncodeToString(sum[:])[:12], nil -} - -// Source is the value of SourceMainKey: every repository's merged head (repository to full sha, not only the -// group's) and the group that moved it. -type Source struct { - V int `json:"v"` - Group string `json:"group"` - Heads map[string]string `json:"heads"` -} - -func (s Source) check() error { - if len(s.Heads) == 0 { - return fmt.Errorf("delivery: a source names no repository") - } - for repository, head := range s.Heads { - if repository == "" { - return fmt.Errorf("delivery: a source names a repository with no name") - } - if head == "" { - return fmt.Errorf("delivery: the source's head of %s is empty", repository) - } - } - return nil -} - -// Candidate is the value of a candidates key: the group's pull requests and heads, the heads hash, the base (the -// source revision it was previewed onto) and the modules each pull request touches. It carries no fingerprint -// until release 4 (ADR 0437 §5); from R4c the indexer writes the new form's fingerprint of each touched module in -// the pull request's Fps and, over all of them, FPSet (novox/hq ADR 0453 clauses 6 and 16). Both are absent in a -// value written before, and a reader of today's shape ignores them. -type Candidate struct { - V int `json:"v"` - Group string `json:"group"` - Base uint64 `json:"base"` - HeadsHash string `json:"heads"` - Pulls []CandidatePull `json:"pulls"` - FPSet string `json:"fpset,omitempty"` -} - -// CandidatePull is one pull request of a candidate and the modules it touches. Fps is, by module name, the new -// form's fingerprint of the module at the merge preview, or why it has none, and the digest of its manifest (the key -// of the indexer's manifests bucket); a module of Modules without an entry has none written yet. -type CandidatePull struct { - Head - Modules []string `json:"modules"` - Fps map[string]CandidateModule `json:"fps,omitempty"` -} - -// CandidateNoneNoModule is the None2 of a candidate's module whose directory the merge preview names no manifest for: -// the change removes the module. The indexer writes it and the assigner reads it as a removal (issue 1297). -const CandidateNoneNoModule = "the merge preview names no manifest for the module's directory" - -// CandidateModule is one touched module's new-form fingerprint FP2 (`src2:` and 64 hex digits), or None2, the reason -// it has none; exactly one of them. Manifest is the digest of the module's manifest as read at the preview. -type CandidateModule struct { - FP2 string `json:"fp2,omitempty"` - None2 string `json:"none2,omitempty"` - Manifest string `json:"manifest,omitempty"` - // Paths are, of a module whose every artifact names its paths, each named path's git object id at the preview - // (novox/hq ADR 0499 clause 2), so the builder's fetch request for the candidate names them. Absent otherwise. - Paths map[string]string `json:"paths,omitempty"` - // Packages are the versions of the mesh packages FP2 was computed with (novox/hq ADR 0573 §2, §4): the latest - // published version of each, or the head of the candidate's pull request that publishes it, so a group's candidate - // builds against the group's own package. Absent for none. - Packages PackageVersions `json:"packages,omitempty"` -} - -func (m CandidateModule) check() error { - switch { - case m.FP2 != "" && m.None2 != "": - return fmt.Errorf("delivery: a module has both fp2 and none2") - case m.FP2 == "" && m.None2 == "": - return fmt.Errorf("delivery: a module has neither fp2 nor none2") - } - if err := checkPathObjects(m.Paths); err != nil { - return err - } - if err := m.Packages.Check(); err != nil { - return err - } - return checkNewForm(m.FP2, m.Manifest) -} - -// checkNewForm refuses an fp2 that is not of the new form and a manifest digest that is not a digest, naming it. -func checkNewForm(fp2, manifest string) error { - if fp2 != "" { - if _, err := FingerprintKeyPart(fp2); err != nil { - return err - } - } - if manifest != "" { - if _, err := ManifestsKey(manifest); err != nil { - return err - } - } - return nil -} - -// FingerprintSet is the fpset of a candidate's pull requests: the first 12 hex digits of the sha256 over the lines -// `=` or `=none:` of every module with an entry in Fps, sorted and joined by a newline, -// so it is the same for every order and changes with any fingerprint or reason. A manifest digest does not enter: the -// fingerprint already covers the manifest. It is the empty string when no pull request carries a fingerprint. The -// checker keys its verdict by it (research 063 section 01). -func FingerprintSet(pulls []CandidatePull) string { - lines := map[string]bool{} - for _, p := range pulls { - for module, m := range p.Fps { - if m.FP2 != "" { - lines[module+"="+m.FP2] = true - } else { - lines[module+"=none:"+m.None2] = true - } - } - } - if len(lines) == 0 { - return "" - } - all := make([]string, 0, len(lines)) - for l := range lines { - all = append(all, l) - } - sort.Strings(all) - sum := sha256.Sum256([]byte(strings.Join(all, "\n"))) - return hex.EncodeToString(sum[:])[:12] -} - -func (c Candidate) check() error { - if len(c.Pulls) == 0 { - return fmt.Errorf("delivery: a candidate names no pull request") - } - heads := make([]Head, len(c.Pulls)) - for i, p := range c.Pulls { - heads[i] = p.Head - } - want, _ := HeadsHash(heads) - if c.HeadsHash != want { - return fmt.Errorf("delivery: the candidate's heads hash %q is not the hash %q of its pull requests' heads", c.HeadsHash, want) - } - seen := map[string]CandidateModule{} - for _, p := range c.Pulls { - touched := map[string]bool{} - for _, m := range p.Modules { - touched[m] = true - } - for module, m := range p.Fps { - if !touched[module] { - return fmt.Errorf("delivery: the candidate gives a fingerprint of the module %q, which pull request %s#%d does not touch", module, p.Repository, p.Number) - } - if err := m.check(); err != nil { - return fmt.Errorf("delivery: the candidate's module %q: %w", module, err) - } - if before, ok := seen[module]; ok && !reflect.DeepEqual(before, m) { - return fmt.Errorf("delivery: two pull requests of the candidate give the module %q different fingerprints", module) - } - seen[module] = m - } - } - if wantSet := FingerprintSet(c.Pulls); c.FPSet != wantSet { - return fmt.Errorf("delivery: the candidate's fpset %q is not the hash %q of its fingerprints", c.FPSet, wantSet) - } - return nil -} - -// Module is the value of a modules key: the module's source fingerprint, or none and why (a build that resolves -// packages when built gets none, said here), and where it was computed: Repository and Commit are the trunk merge, -// Src the source revision then (0 before the gate writes source.main). At most one of FP and None is set. -// -// From R4c the indexer writes the new form beside it (novox/hq ADR 0453 clause 6): FP2, the fingerprint of the new -// form (`src2:` and 64 hex digits), or None2, why it has none; one of them whenever either is, never both; and -// Manifest, the digest of the module's manifest, the key of the manifests bucket. A value written before has none of -// the three, and a reader of today's shape ignores them. From R4e a value may carry the new form alone (ADR 0453 clause -// 6; issue 1851): one of FP, None, FP2 and None2 is set, and the old form stays readable. -type Module struct { - V int `json:"v"` - FP string `json:"fp,omitempty"` - None string `json:"none,omitempty"` - FP2 string `json:"fp2,omitempty"` - None2 string `json:"none2,omitempty"` - Manifest string `json:"manifest,omitempty"` - // Inputs2 are the new-form inputs the merge gave the module, kept so the indexer computes FP2 again when a build - // module's toolchain versions appear, with no merge (novox/hq ADR 0465 clause 2). It needs Manifest. - Inputs2 *KeptInputs2 `json:"inputs2,omitempty"` - Src uint64 `json:"src,omitempty"` - // FPSrc is the source revision at which FP2 last changed (novox/hq ADR 0601 §1): Src when a merge of the module's own - // repository moved it, and 0 when a recompute with no merge moved it (a toolchain, a package, a base), since no release - // carries that build. The release that moved the source to it is the one that carries the build; 0 is not known, and no - // release is taken to carry it. Src and Commit stay where the module was last merged and say nothing of that. - FPSrc uint64 `json:"fp-src,omitempty"` - Repository string `json:"repository"` - Commit string `json:"commit"` - // Path is the module's directory in its repository, trimmed of slashes, and RootPath (".") when its manifest is the - // repository's root (novox/hq ADR 0478 clause 9, issue 1615): the build tools need it. Empty means not known: a value - // written before the indexer learned paths has none. - Path string `json:"path,omitempty"` - // Toolchains are the toolchains FP2 was computed with, by language (go, typescript): the version installed and the - // build module's recipe version the build nodes agreed on then (novox/hq ADR 0477 clause 2, ADR 0478 clause 3). A builder - // builds the fingerprint only with these. Empty when FP2 is empty or the module has no toolchain; a value written - // before has none (issue 1615). - Toolchains map[string]ToolchainUsed `json:"toolchains,omitempty"` - // ArtifactPaths are, of each artifact that names the paths it is built from, those paths as its manifest names - // them, by artifact (novox/hq ADR 0499 clause 1): the indexer fills them from each catalogue.Artifact.Paths it reads, - // so the checker tells whether a change touches an artifact's build source without reading a manifest (the lead's - // decision L8 of R4e). Absent for an artifact that names none, and in a value written before. - ArtifactPaths map[string][]string `json:"paths,omitempty"` - // Packages are the versions of the mesh packages FP2 was computed with (novox/hq ADR 0573 §2): the trunk merge the - // indexer held for each package of Inputs2.Packages. A builder builds the fingerprint against exactly these - // (StepAsk.Packages). Empty when FP2 is empty or the module uses no mesh package. - Packages PackageVersions `json:"packages,omitempty"` - // Verdict is the indexer's verdict on Manifest, VerdictAccepted or VerdictRefused, and Problems each problem of a - // refusal, as the verdicts state holds them (novox/hq ADR 0578 §1): a reader of modules takes it without a read of - // that state. Empty in a value written before the indexer judged, which a reader takes as accepted (TakeManifestVerdict). - Verdict string `json:"verdict,omitempty"` - Problems []string `json:"problems,omitempty"` - // Unknown are the fields of Manifest the judge does not know. A manifest that waits for them (VerdictManifestWaits) is - // written here as a refusal whose Problems say what it waits on and whose Unknown names the fields: a reader pinned - // before the verdict waits holds it as a refused one, and VerdictOf gives a reader that knows it the verdict waits. - Unknown []string `json:"unknown,omitempty"` - // Warnings are the problems of an accepted manifest a stricter judge outgrew (novox/hq ADR 0579 §1): Verdict is then - // empty (accepted), and the watchman raises delivery.ManifestOutgrown until the manifest is mended. - Warnings []string `json:"warnings,omitempty"` - // Needs are the reader capabilities the manifest's own words, forms and seat names need (catalogue.ManifestNeeds, - // novox/hq ADR 0614 §4), written by the judge with its verdict: the assigner holds a node's want while that node's - // engine does not state one of them. Empty in a value written before, which needs nothing. - Needs []string `json:"needs,omitempty"` -} - -// VerdictOf is the verdict a modules value carries, or nil when it carries none (taken as accepted, TakeManifestVerdict). -func (m Module) VerdictOf(module string) *ManifestVerdict { - if m.Verdict == "" && len(m.Warnings) == 0 { - return nil - } - if m.Verdict == "" { - return &ManifestVerdict{Module: module, State: VerdictAccepted, Warnings: m.Warnings} - } - if m.Verdict == VerdictRefused && len(m.Unknown) > 0 { - return &ManifestVerdict{Module: module, State: VerdictManifestWaits, Unknown: m.Unknown} - } - return &ManifestVerdict{Module: module, State: m.Verdict, Problems: m.Problems} -} - -// RootPath is Module.Path of a module whose manifest is its repository's root. -const RootPath = "." - -// KeptInputs2 are SourceInputs2 without the module's name (the key has it) and without toolchains (those come from -// the build modules' toolchain keys when the fingerprint is computed): what a Module keeps of a merge. -type KeptInputs2 struct { - Tree string `json:"tree"` - Bases []string `json:"bases,omitempty"` - Builder string `json:"builder,omitempty"` - Contexts map[string]string `json:"contexts,omitempty"` - Locks map[string]string `json:"locks,omitempty"` - // Paths are, of a module whose every artifact names its paths, each named path's git object id at the merge, by - // path relative to the module's directory; Tree is then their NamedTree with the module's manifest digest, so a - // computation from kept inputs gives the merge's value (novox/hq ADR 0499 clause 2). Absent for every other module, - // whose Tree is its directory's tree, and in a value written before. - Paths map[string]string `json:"paths,omitempty"` - // Packages are the mesh packages the module's lock file names (MeshPackagesOf, novox/hq ADR 0573 §2): the - // fingerprint takes the latest published version of each (Module.Packages). Absent for none, and in a value written - // before. - Packages []string `json:"packages,omitempty"` - // Imports are the mesh packages (import paths) the module's programs import, their tests left out, as the module's - // last trunk build reported them at its end (BuildLogEntry.Imports; novox/hq ADR 0593 §2), sorted and each once: - // the fingerprint takes each one's content digest (PublishedPackages) instead of Packages' versions. Absent for a - // module with no reported import set, which keeps the version line, and in a value written before. - Imports []string `json:"imports,omitempty"` - // ImportForm is the import-form fingerprint (src2: and 64 hex digits) of the inputs the build that reported Imports - // was built from (novox/hq ADR 0593 §4): set by the indexer when it keeps a build's Imports, while FP2 is still the - // form that build was asked at. A recompute whose import-form fingerprint equals it keeps FP2 as it is (the build - // stands); one that differs writes the import form. Absent with no Imports, and in a value written before. - ImportForm string `json:"import-form,omitempty"` -} - -// checkPathObjects refuses a named path that is not clean and relative to the module's directory, or names no object. -func checkPathObjects(paths map[string]string) error { - for p, object := range paths { - if p == "" || p != path.Clean(p) || strings.HasPrefix(p, "/") || p == ".." || strings.HasPrefix(p, "../") { - return fmt.Errorf("delivery: the named path %q is not a clean path inside the module's directory", p) - } - if object == "" { - return fmt.Errorf("delivery: the named path %q names no git object", p) - } - } - return nil -} - -func (m Module) check() error { - switch { - case m.FP != "" && m.None != "": - return fmt.Errorf("delivery: a module has both a fingerprint and a reason for none") - case m.FP == "" && m.None == "" && m.FP2 == "" && m.None2 == "": - return fmt.Errorf("delivery: a module has neither a fingerprint nor a reason for none, of either form") - case m.FP != "" && !strings.HasPrefix(m.FP, SourcePrefix): - return fmt.Errorf("delivery: the module's fingerprint %q does not start with %s", m.FP, SourcePrefix) - case m.FP2 != "" && m.None2 != "": - return fmt.Errorf("delivery: a module has both fp2 and none2") - } - for language, t := range m.Toolchains { - if t.Version == "" || t.Recipe == "" { - return fmt.Errorf("delivery: the module's toolchain of %q needs its version and its recipe version", language) - } - } - if err := m.Packages.Check(); err != nil { - return err - } - if m.Inputs2 != nil && m.Manifest == "" { - return fmt.Errorf("delivery: a module keeps new-form inputs but has no manifest digest") - } - for artifact, paths := range m.ArtifactPaths { - if artifact == "" || len(paths) == 0 { - return fmt.Errorf("delivery: the module's named paths of the artifact %q are empty or name no artifact", artifact) - } - for _, p := range paths { - if p == "" || p != path.Clean(p) || strings.HasPrefix(p, "/") || p == ".." || strings.HasPrefix(p, "../") { - return fmt.Errorf("delivery: the artifact %q names the path %q, not a clean path inside the module's directory", artifact, p) - } - } - } - if m.Inputs2 != nil { - if err := CheckImports(m.Inputs2.Imports); err != nil { - return err - } - if f := m.Inputs2.ImportForm; f != "" && (len(m.Inputs2.Imports) == 0 || !strings.HasPrefix(f, SourcePrefix2) || !isHex64(f[len(SourcePrefix2):])) { - return fmt.Errorf("delivery: the kept import form %q is set only with imports, and is %s and 64 hex digits", f, SourcePrefix2) - } - if err := checkPathObjects(m.Inputs2.Paths); err != nil { - return err - } - } - switch { - case m.Verdict != "" && m.Verdict != VerdictAccepted && m.Verdict != VerdictRefused: - return fmt.Errorf("delivery: the module's verdict %q is neither %s nor %s", m.Verdict, VerdictAccepted, VerdictRefused) - case len(m.Unknown) > 0 && m.Verdict != VerdictRefused: - return fmt.Errorf("delivery: a module's unknown fields are named only with the refusal a wait is written as") - case m.Verdict != "" && m.Manifest == "": - return fmt.Errorf("delivery: a module carries a verdict and no manifest digest") - case (m.Verdict == VerdictRefused) != (len(m.Problems) > 0): - return fmt.Errorf("delivery: a module's problems are named only with a refusal, and a refusal names them") - case m.Verdict == VerdictRefused && len(m.Warnings) > 0: - return fmt.Errorf("delivery: a refused module warns: a refusal names problems") - } - return checkNewForm(m.FP2, m.Manifest) -} - -// Forge is a message of the forge bridge's stream, trimmed to repository, branch, pull request and head. A pull -// message says Action; a merged message carries the merge commit, the tree of every directory holding a -// module.json and the repository's root tree; a refused message names the owed act it answers and the forge's own -// words. A pull message may also carry the module directories its head changes (Dirs, in the order the forge said) and -// DirsKnown, true when the forge could say them: known and empty is DirsKnown with no Dirs, not known is neither -// (the forge's module_dirs and module_dirs_said, mesh-catalog modules/gitea/trunk.ts). Other messages carry neither. -type Forge struct { - V int `json:"v"` - Kind string `json:"kind"` - Repository string `json:"repository"` - Branch string `json:"branch,omitempty"` - Number int `json:"number,omitempty"` - Head string `json:"head,omitempty"` - Action string `json:"action,omitempty"` - Dirs []string `json:"dirs,omitempty"` - DirsKnown bool `json:"dirs-known,omitempty"` - Merge string `json:"merge,omitempty"` - Trees map[string]string `json:"trees,omitempty"` - Root string `json:"root,omitempty"` - // Manifests are the module.json bytes, exact, of each module directory: of the merge's trees on a merged message, - // of Dirs at the head on a pull message. Locks are, of each such directory, its lock files' hex sha256 by path - // relative to it (novox/hq ADR 0465 clause 1). Older writers send neither. - Manifests map[string]string `json:"manifests,omitempty"` - Locks map[string]map[string]string `json:"locks,omitempty"` - // Needs are, of each such directory whose manifest names templates, the reader capabilities of the module whole: - // its manifest's and its templates' text's at the same commit (catalogue.ModuleNeeds; novox/hq ADR 0632 §2). The - // indexer takes them over the manifest's own; a directory absent, or an older writer, has the manifest's alone. - Needs map[string][]string `json:"needs,omitempty"` - // Paths are, of each module directory whose manifest names paths on every artifact, each named path's git object - // id at the same commit as its manifest, by path relative to the directory (novox/hq ADR 0499 clause 3). The - // indexer names the paths; a message without them gives such a module none. Older writers send none. - Paths map[string]map[string]string `json:"paths,omitempty"` - // Packages are, of each module directory, the mesh packages its lock file names (MeshPackagesOf; novox/hq ADR 0573 - // §2): the indexer keeps them in the module's inputs and takes each one's latest published version into its - // fingerprint. Absent for a directory that uses none, and from an older writer. - Packages map[string][]string `json:"packages,omitempty"` - Act string `json:"act,omitempty"` - Words string `json:"words,omitempty"` - Open []OpenPull `json:"open,omitempty"` - // RetiredPlacements is the placement files a bridge before ADR 0567 carried (ADR 0549 §2, ADR 0559 §2): placement is - // the seat mesh-placement's state now and no repository holds it (ADR 0567 §11), so a message carrying them is - // refused, loudly, rather than read with them dropped. - RetiredPlacements json.RawMessage `json:"placements,omitempty"` - RetiredRead bool `json:"placements-read,omitempty"` - // Predefined is the catalogue's predefined.json, exact, at the merge commit of a merged message (novox/hq ADR 0565 - // §2): what the indexer keeps as its state `predefined`. PredefinedRead says the bridge read the merge and it holds - // no predefined.json. Carried on a merged message only; an older bridge sends neither, and the indexer keeps what it - // held. - Predefined json.RawMessage `json:"predefined,omitempty"` - PredefinedRead bool `json:"predefined-read,omitempty"` -} - -// checkPredefined refuses the predefined list on anything but a merged message, a list that is not one JSON object, and -// a list said both present and absent. -func (f Forge) checkPredefined() error { - if len(f.Predefined) == 0 && !f.PredefinedRead { - return nil - } - if f.Kind != ForgeMerged { - return fmt.Errorf("delivery: only a merged message carries the predefined list, not a %s message", f.Kind) - } - if len(f.Predefined) > 0 && f.PredefinedRead { - return fmt.Errorf("delivery: a merged message carries the predefined list and says the merge holds none (predefined-read)") - } - var list map[string]json.RawMessage - if len(f.Predefined) > 0 && json.Unmarshal(f.Predefined, &list) != nil { - return fmt.Errorf("delivery: the predefined list a merged message carries is not one JSON object") - } - return nil -} - -func (f Forge) check() error { - if err := checkForgeKind(f.Kind); err != nil { - return err - } - if f.Kind == ForgeOpen { - return f.checkOpen() - } - if len(f.Open) > 0 { - return fmt.Errorf("delivery: only an open message names open pull requests") - } - if len(f.RetiredPlacements) > 0 || f.RetiredRead { - return fmt.Errorf("delivery: a forge message carries placement files, which no repository holds since ADR 0567: " + - "placement is the seat mesh-placement's state") - } - if err := f.checkPredefined(); err != nil { - return err - } - if err := checkRepository(f.Repository); err != nil { - return err - } - switch f.Kind { - case ForgePull: - if f.Number <= 0 { - return fmt.Errorf("delivery: a pull message needs its pull request number") - } - if f.Head == "" { - return fmt.Errorf("delivery: a pull message needs its head") - } - switch f.Action { - case PullOpened, PullUpdated, PullClosed: - default: - return fmt.Errorf("delivery: the pull action %q is not opened, updated or closed", f.Action) - } - if len(f.Dirs) > 0 && !f.DirsKnown { - return fmt.Errorf("delivery: a pull message with module directories must say it knows them (dirs-known)") - } - dirs := map[string]bool{} - for _, d := range f.Dirs { - dirs[d] = true - } - if err := f.checkInputs(func(d string) bool { return dirs[d] }, "its module directories"); err != nil { - return err - } - case ForgeMerged: - if len(f.Dirs) > 0 || f.DirsKnown { - return fmt.Errorf("delivery: a merged message carries no module directories") - } - if f.Number <= 0 { - return fmt.Errorf("delivery: a merged message needs its pull request number") - } - if f.Head == "" { - return fmt.Errorf("delivery: a merged message needs its head") - } - if f.Merge == "" { - return fmt.Errorf("delivery: a merged message needs its merge commit") - } - if f.Root == "" { - return fmt.Errorf("delivery: a merged message needs the repository's root tree") - } - // No trees is a merge that leaves the repository with no module.json anywhere (a documents repository, or one - // whose last module was removed): it is written all the same, so the indexer takes the merge in and drops the - // repository's modules (novox/hq issue 1126). An older writer always sent at least one tree, so none is unambiguous; - // such a message carries no manifests, locks or paths (checkInputs below: each names a directory of its trees). - for dir, tree := range f.Trees { - if tree == "" { - return fmt.Errorf("delivery: a merged message's tree of the directory %q is empty", dir) - } - } - if err := f.checkInputs(func(d string) bool { _, ok := f.Trees[d]; return ok }, "its trees"); err != nil { - return err - } - case ForgeBranchDeleted: - if f.Branch == "" { - return fmt.Errorf("delivery: a branch-deleted message needs the branch") - } - if f.Number != 0 || f.Head != "" || f.Merge != "" || len(f.Dirs) > 0 || len(f.Trees) > 0 || len(f.Manifests) > 0 || - len(f.Locks) > 0 || len(f.Paths) > 0 || len(f.Packages) > 0 || f.Act != "" { - return fmt.Errorf("delivery: a branch-deleted message names its repository and branch and carries nothing else") - } - case ForgeRefused: - if len(f.Dirs) > 0 || f.DirsKnown { - return fmt.Errorf("delivery: a refused message carries no module directories") - } - if len(f.Manifests) > 0 || len(f.Locks) > 0 || len(f.Paths) > 0 { - return fmt.Errorf("delivery: a refused message carries no manifests, lock hashes or named paths") - } - if f.Act == "" { - return fmt.Errorf("delivery: a refused message needs the act it answers") - } - if f.Words == "" { - return fmt.Errorf("delivery: a refused message needs the forge's words") - } - } - return nil -} - -// checkInputs refuses a manifest or a lock hash of a directory that known does not know (of is what it knows them -// by), an empty manifest and an empty lock path or hash, naming the directory. -func (f Forge) checkInputs(known func(dir string) bool, of string) error { - for dir, m := range f.Manifests { - if !known(dir) { - return fmt.Errorf("delivery: the manifest of the directory %q is not of %s", dir, of) - } - if m == "" { - return fmt.Errorf("delivery: the manifest of the directory %q is empty", dir) - } - } - for dir, locks := range f.Locks { - if !known(dir) { - return fmt.Errorf("delivery: the lock hashes of the directory %q are not of %s", dir, of) - } - for path, hash := range locks { - if path == "" || hash == "" { - return fmt.Errorf("delivery: the lock %q of the directory %q has an empty path or hash", path, dir) - } - } - } - for dir, paths := range f.Paths { - if !known(dir) { - return fmt.Errorf("delivery: the named paths of the directory %q are not of %s", dir, of) - } - for path, object := range paths { - if path == "" || object == "" { - return fmt.Errorf("delivery: the named path %q of the directory %q has an empty path or object", path, dir) - } - } - } - return nil -} - -// checkOpen refuses an open message that names a repository, a branch, a head or anything of the other kinds, or -// an open pull request that is not `/` and a number. -func (f Forge) checkOpen() error { - if f.Repository != "" || f.Branch != "" || f.Number != 0 || f.Head != "" || f.Action != "" || len(f.Dirs) > 0 || f.DirsKnown || - f.Merge != "" || len(f.Trees) > 0 || len(f.Manifests) > 0 || len(f.Locks) > 0 || len(f.Paths) > 0 || f.Root != "" || f.Act != "" || f.Words != "" { - return fmt.Errorf("delivery: an open message carries only the open pull requests it names") - } - for _, p := range f.Open { - if err := checkRepository(p.Repository); err != nil { - return err - } - if p.Number <= 0 { - return fmt.Errorf("delivery: the open pull request of %s has no number", p.Repository) - } - } - return nil -} - -// EncodeSource is s as JSON at Version; it refuses a source with no repository or an empty head. -func EncodeSource(s Source) ([]byte, error) { - if err := s.check(); err != nil { - return nil, err - } - s.V = Version - return json.Marshal(s) -} - -// DecodeSource reads a source; it refuses an unknown version (ErrUnknownVersion) and what EncodeSource refuses. -func DecodeSource(data []byte) (Source, error) { - var s Source - if err := decode(data, &s); err != nil { - return Source{}, err - } - if err := s.check(); err != nil { - return Source{}, err - } - return s, nil -} - -// EncodeCandidate is c as JSON at Version; it refuses a heads hash that is not the hash of its pull requests' heads. -func EncodeCandidate(c Candidate) ([]byte, error) { - if err := c.check(); err != nil { - return nil, err - } - c.V = Version - return json.Marshal(c) -} - -// DecodeCandidate reads a candidate; it refuses an unknown version (ErrUnknownVersion) and what EncodeCandidate refuses. -func DecodeCandidate(data []byte) (Candidate, error) { - var c Candidate - if err := decode(data, &c); err != nil { - return Candidate{}, err - } - if err := c.check(); err != nil { - return Candidate{}, err - } - return c, nil -} - -// EncodeModule is m as JSON at Version; it refuses both or neither of a fingerprint and a reason, and a -// fingerprint not of the form src1:. -func EncodeModule(m Module) ([]byte, error) { - if err := m.check(); err != nil { - return nil, err - } - m.V = Version - return json.Marshal(m) -} - -// DecodeModule reads a module; it refuses an unknown version (ErrUnknownVersion) and what EncodeModule refuses. -func DecodeModule(data []byte) (Module, error) { - var m Module - if err := decode(data, &m); err != nil { - return Module{}, err - } - if err := m.check(); err != nil { - return Module{}, err - } - return m, nil -} - -// EncodeForge is f as JSON at Version; it refuses a message missing what its kind needs. -func EncodeForge(f Forge) ([]byte, error) { - if err := f.check(); err != nil { - return nil, err - } - f.V = Version - return json.Marshal(f) -} - -// DecodeForge reads a forge message; it refuses an unknown version (ErrUnknownVersion) and what EncodeForge refuses. -func DecodeForge(data []byte) (Forge, error) { - var f Forge - if err := decode(data, &f); err != nil { - return Forge{}, err - } - if err := f.check(); err != nil { - return Forge{}, err - } - return f, nil -} - -// SameSource says whether two sources are the same value, so a writer writes only when they differ. -func SameSource(a, b Source) bool { return reflect.DeepEqual(a, b) } - -// SameCandidate says whether two candidates are the same value. -func SameCandidate(a, b Candidate) bool { return reflect.DeepEqual(a, b) } - -// SameModule says whether two modules are the same value. -func SameModule(a, b Module) bool { return reflect.DeepEqual(a, b) } - -// SameForge says whether two forge messages are the same value. -func SameForge(a, b Forge) bool { return reflect.DeepEqual(a, b) } diff --git a/go/delivery/source_test.go b/go/delivery/source_test.go deleted file mode 100644 index 48b096b..0000000 --- a/go/delivery/source_test.go +++ /dev/null @@ -1,512 +0,0 @@ -package delivery - -import ( - "encoding/json" - "errors" - "reflect" - "strings" - "testing" - "time" -) - -func TestSourceKeys(t *testing.T) { // 1200/AC1 - if k, err := CandidatesKey("feat/delivery-source-keys"); err != nil || k != "feat/delivery-source-keys" { - t.Fatalf("candidates key = %q, %v", k, err) - } - if g, err := ParseCandidatesKey("feat/x"); err != nil || g != "feat/x" { - t.Fatalf("parse candidates = %q, %v", g, err) - } - if k, err := ModulesKey("mesh-mail"); err != nil || k != "mesh-mail" { - t.Fatalf("modules key = %q, %v", k, err) - } - if m, err := ParseModulesKey("mesh-mail"); err != nil || m != "mesh-mail" { - t.Fatalf("parse modules = %q, %v", m, err) - } - for _, bad := range []string{"", "a.b", "a*b", "a>b", "a b", "a\tb"} { - if _, err := ModulesKey(bad); err == nil { - t.Errorf("modules key %q accepted", bad) - } - if _, err := ParseCandidatesKey(bad); err == nil { - t.Errorf("parse candidates %q accepted", bad) - } - if _, err := ParseModulesKey(bad); err == nil { - t.Errorf("parse modules %q accepted", bad) - } - } - if _, err := CandidatesKey(""); err == nil { - t.Error("an empty candidates key accepted") - } - ev, err := ForgeEvent("novox/mesh-sdk", ForgeMerged) - if err != nil || ev != "forge.novox.mesh-sdk.merged" { - t.Fatalf("forge event = %q, %v", ev, err) - } - repo, kind, err := ParseForgeEvent(ev) - if err != nil || repo != "novox/mesh-sdk" || kind != ForgeMerged { - t.Fatalf("parse forge event = %q %q %v", repo, kind, err) - } - for _, k := range []string{ForgePull, ForgeMerged, ForgeRefused, ForgeBranchDeleted} { - if _, err := ForgeEvent("a/b", k); err != nil { - t.Errorf("kind %s refused: %v", k, err) - } - } - for _, bad := range []struct{ repo, kind string }{ - {"", "pull"}, {"novox", "pull"}, {"a/b/c", "pull"}, {"/b", "pull"}, {"a/", "pull"}, - {"a.b/c", "pull"}, {"a/b c", "pull"}, {"a/b", "closed"}, {"a/b", ""}, - } { - if _, err := ForgeEvent(bad.repo, bad.kind); err == nil { - t.Errorf("forge event %q %q accepted", bad.repo, bad.kind) - } else if bad.kind == "closed" && !strings.Contains(err.Error(), "closed") { - t.Errorf("error does not name the kind: %v", err) - } - } - for _, bad := range []string{"", "forge.a.b", "forge.a.b.closed", "x.a.b.pull", "forge.a.b.pull.x", "forge..b.pull"} { - if _, _, err := ParseForgeEvent(bad); err == nil { - t.Errorf("parse forge event %q accepted", bad) - } - } -} - -func TestForgeOpenKind(t *testing.T) { // 1279/AC5 - ev := OpenEvent() - if ev != "forge.open" { - t.Fatalf("open event = %q", ev) - } - repo, kind, err := ParseForgeEvent(ev) - if err != nil || repo != "" || kind != ForgeOpen { - t.Fatalf("parse open event = %q %q %v", repo, kind, err) - } - // An empty list is a message too: nothing is open. - data, err := EncodeForge(Forge{Kind: ForgeOpen, Open: []OpenPull{}}) - if err != nil { - t.Fatalf("empty open: %v", err) - } - if f, err := DecodeForge(data); err != nil || f.Kind != ForgeOpen || len(f.Open) != 0 { - t.Errorf("empty open round trip: %+v %v", f, err) - } - for name, f := range map[string]Forge{ - "a repository": {Kind: ForgeOpen, Repository: "a/b", Open: []OpenPull{{Repository: "a/b", Number: 1}}}, - "a bad entry": {Kind: ForgeOpen, Open: []OpenPull{{Repository: "a", Number: 1}}}, - "a zero number": {Kind: ForgeOpen, Open: []OpenPull{{Repository: "a/b"}}}, - "a head": {Kind: ForgeOpen, Head: "x", Open: []OpenPull{{Repository: "a/b", Number: 1}}}, - } { - if _, err := EncodeForge(f); err == nil { - t.Errorf("open with %s accepted", name) - } - } - if _, err := EncodeForge(Forge{Kind: ForgePull, Repository: "a/b", Number: 1, Head: "h", Action: "updated", Open: []OpenPull{{Repository: "a/b", Number: 1}}}); err == nil { - t.Error("a pull message naming open pull requests accepted") - } - if _, err := ForgeEvent("a/b", ForgeOpen); err == nil { - t.Error("an open event for a repository accepted") - } -} - -func TestHeadsHash(t *testing.T) { // 1200/AC2 - a := Head{Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"} - b := Head{Repository: "novox/mesh-control", Number: 7, Head: "bbbb"} - h1, err := HeadsHash([]Head{a, b}) - if err != nil { - t.Fatal(err) - } - h2, _ := HeadsHash([]Head{b, a}) - if h1 != h2 { - t.Errorf("order changes the hash: %s %s", h1, h2) - } - if len(h1) != 12 || strings.Trim(h1, "0123456789abcdef") != "" { - t.Errorf("hash %q is not 12 hex digits", h1) - } - for _, changed := range []Head{ - {Repository: "novox/mesh-sdk", Number: 36, Head: "aaab"}, - {Repository: "novox/mesh-sdk", Number: 37, Head: "aaaa"}, - {Repository: "novox/other", Number: 36, Head: "aaaa"}, - } { - h3, _ := HeadsHash([]Head{changed, b}) - if h3 == h1 { - t.Errorf("changing %v keeps the hash", changed) - } - } - if _, err := HeadsHash(nil); err == nil { - t.Error("an empty list is accepted") - } -} - -func sampleSource() Source { - return Source{Group: "feat/x", Heads: map[string]string{"novox/mesh-sdk": "abc123", "novox/mesh-control": "def456"}} -} - -func sampleCandidate() Candidate { - pulls := []CandidatePull{ - {Head: Head{Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"}, Modules: []string{"mesh-mail"}}, - {Head: Head{Repository: "novox/mesh-control", Number: 7, Head: "bbbb"}, Modules: []string{"mesh-controller"}}, - } - hh, _ := HeadsHash([]Head{pulls[0].Head, pulls[1].Head}) - return Candidate{Group: "feat/x", Base: 12, HeadsHash: hh, Pulls: pulls} -} - -func sampleForges() []Forge { - return []Forge{ - {Kind: ForgePull, Repository: "novox/mesh-sdk", Branch: "feat/x", Number: 36, Head: "aaaa", Action: "opened"}, - {Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa", Merge: "cccc", - Trees: map[string]string{"modules/a": "t1"}, Root: "t0"}, - {Kind: ForgeRefused, Repository: "novox/mesh-sdk", Act: "novox/mesh-sdk.36.aaaa.view", Words: "403 forbidden"}, - {Kind: ForgeOpen, Open: []OpenPull{{Repository: "novox/mesh-sdk", Number: 36}, {Repository: "novox/mesh-control", Number: 7}}}, - // A deleted branch, from the forge's delete event (design 51 *Tagged versions*). - {Kind: ForgeBranchDeleted, Repository: "novox/mesh-sdk", Branch: "feat/x"}, - } -} - -func TestSourceValuesRoundTrip(t *testing.T) { // 1200/AC3 - check := func(name string, data []byte, err error) { - t.Helper() - if err != nil { - t.Fatalf("%s: %v", name, err) - } - var probe map[string]any - if err := json.Unmarshal(data, &probe); err != nil || probe["v"] != float64(1) { - t.Fatalf("%s: json %s does not carry v:1", name, data) - } - } - s := sampleSource() - data, err := EncodeSource(s) - check("source", data, err) - s.V = Version - if got, err := DecodeSource(data); err != nil || !SameSource(got, s) { - t.Errorf("source round trip: %+v %v", got, err) - } - c := sampleCandidate() - data, err = EncodeCandidate(c) - check("candidate", data, err) - c.V = Version - if got, err := DecodeCandidate(data); err != nil || !SameCandidate(got, c) { - t.Errorf("candidate round trip: %+v %v", got, err) - } - for _, m := range []Module{ - {FP: "src1:abc", Src: 5, FPSrc: 5, Repository: "novox/mesh-sdk", Commit: "cccc"}, - {None: "a build that resolves packages when built", Repository: "novox/mesh-sdk", Commit: "cccc"}, - // The reader capabilities its manifest needs (ADR 0614 §4), written with the verdict. - {FP: "src1:abc", Repository: "novox/mesh-sdk", Commit: "cccc", Needs: []string{"seat:mesh-catalog", "word:tool-runner"}}, - } { - data, err = EncodeModule(m) - check("module", data, err) - m.V = Version - if got, err := DecodeModule(data); err != nil || !SameModule(got, m) { - t.Errorf("module round trip: %+v %v", got, err) - } - } - for _, f := range sampleForges() { - data, err = EncodeForge(f) - check("forge "+f.Kind, data, err) - f.V = Version - if got, err := DecodeForge(data); err != nil || !SameForge(got, f) { - t.Errorf("forge %s round trip: %+v %v", f.Kind, got, err) - } - } - // 1280: the taken and reach values (ADR 0556). - tk := Taken{Repository: "novox/mesh-sdk", Merge: "cccc", At: time.Date(2026, 10, 26, 20, 0, 0, 0, time.UTC)} - data, err = EncodeTaken(tk) - check("taken", data, err) - tk.V = Version - if got, err := DecodeTaken(data); err != nil || got != tk { - t.Errorf("taken round trip: %+v %v", got, err) - } - r := sampleReach() - data, err = EncodeReach(r) - check("reach", data, err) - if got, err := DecodeReach(data); err != nil || !SameReach(got, r) { - t.Errorf("reach round trip: %+v %v", got, err) - } - for name, dec := range map[string]func([]byte) error{ - "taken": func(b []byte) error { _, err := DecodeTaken(b); return err }, - "reach": func(b []byte) error { _, err := DecodeReach(b); return err }, - "source": func(b []byte) error { _, err := DecodeSource(b); return err }, - "candidate": func(b []byte) error { _, err := DecodeCandidate(b); return err }, - "module": func(b []byte) error { _, err := DecodeModule(b); return err }, - "forge": func(b []byte) error { _, err := DecodeForge(b); return err }, - } { - for _, in := range []string{`{"group":"g"}`, `{"v":2,"group":"g"}`} { - if err := dec([]byte(in)); !errors.Is(err, ErrUnknownVersion) { - t.Errorf("%s decode of %s = %v, want ErrUnknownVersion", name, in, err) - } - } - } -} - -func TestSourceValuesRefused(t *testing.T) { // 1200/AC4 - wantErr := func(name string, err error, words ...string) { - t.Helper() - if err == nil { - t.Errorf("%s accepted", name) - return - } - for _, w := range words { - if !strings.Contains(err.Error(), w) { - t.Errorf("%s: error %q does not name %q", name, err, w) - } - } - } - _, err := EncodeSource(Source{Group: "g"}) - wantErr("source without repositories", err, "repositor") - _, err = EncodeSource(Source{Group: "g", Heads: map[string]string{"a/b": ""}}) - wantErr("source with an empty head", err, "a/b") - _, err = EncodeSource(Source{Group: "g", Heads: map[string]string{"": "x"}}) - wantErr("source with an empty repository", err, "repositor") - - c := sampleCandidate() - c.HeadsHash = "000000000000" - _, err = EncodeCandidate(c) - wantErr("candidate with a stale hash", err, "heads hash") - c = sampleCandidate() - c.Pulls = nil - _, err = EncodeCandidate(c) - wantErr("candidate without pulls", err, "pull") - - _, err = EncodeModule(Module{FP: "src1:x", None: "why", Repository: "a/b", Commit: "c"}) - wantErr("module with both", err, "both") - _, err = EncodeModule(Module{Repository: "a/b", Commit: "c"}) - wantErr("module with neither", err, "fingerprint", "none") - _, err = EncodeModule(Module{FP: "abc", Repository: "a/b", Commit: "c"}) - wantErr("module with a bad fingerprint", err, "src1:") - - f := sampleForges()[1] - f.Merge = "" - _, err = EncodeForge(f) - wantErr("merged without merge commit", err, "merge") - f = sampleForges()[1] - f.Root = "" - _, err = EncodeForge(f) - wantErr("merged without root", err, "root") - f = sampleForges()[1] - f.Trees, f.Manifests, f.Locks, f.Paths = nil, nil, nil, nil - if data, err := EncodeForge(f); err != nil { - t.Errorf("a merged message of a repository with no module.json is refused: %v", err) - } else if got, err := DecodeForge(data); err != nil || len(got.Trees) != 0 { - t.Errorf("a merged message with no trees comes back as %+v, %v", got, err) - } - f.Manifests = map[string]string{"modules/a": "{}"} - _, err = EncodeForge(f) - wantErr("merged with a manifest and no trees", err, "modules/a") - f = sampleForges()[1] - f.Trees = map[string]string{"modules/a": ""} - _, err = EncodeForge(f) - wantErr("merged with an empty tree", err, "modules/a") - f = sampleForges()[0] - f.Action = "reopened" - _, err = EncodeForge(f) - wantErr("pull with an unknown action", err, "reopened") - f = sampleForges()[0] - f.Head = "" - _, err = EncodeForge(f) - wantErr("pull without head", err, "head") - f = sampleForges()[2] - f.Words = "" - _, err = EncodeForge(f) - wantErr("refused without words", err, "words") - f = sampleForges()[2] - f.Act = "" - _, err = EncodeForge(f) - wantErr("refused without act", err, "act") - _, err = EncodeForge(Forge{Kind: "closed", Repository: "a/b"}) - wantErr("unknown kind", err, "closed") - _, err = EncodeForge(Forge{Kind: ForgePull, Repository: "nope", Number: 1, Head: "h", Action: "opened"}) - wantErr("bad repository", err, "nope") - - // 1280: taken and reach values that do not hold (ADR 0556). - _, err = EncodeTaken(Taken{Repository: "a/b"}) - wantErr("taken without merge", err, "merge") - r := sampleReach() - r.Repository = "" - _, err = EncodeReach(r) - wantErr("reach without repository", err, "repository") - r = sampleReach() - m := r.Modules["gitea"] - m.Changes = append(m.Changes, Change{Commit: "dddd"}) - r.Modules["gitea"] = m - _, err = EncodeReach(r) - wantErr("reach with a change naming no fingerprint", err, "gitea") - r = sampleReach() - m = r.Modules["gitea"] - m.Changes = make([]Change, MostChanges+1) - r.Modules["gitea"] = m - _, err = EncodeReach(r) - wantErr("reach keeping too many changes", err, "more than") - r = sampleReach() - r.ReachedSrc = 0 - _, err = EncodeReach(r) - wantErr("reach with a head and no revision", err, "reached head") -} - -// sampleReach is a reach value of one repository with a module at its floor and a library-like empty one. -func sampleReach() Reach { - return Reach{V: Version, Repository: "novox/mesh-catalog", Indexed: "cccc", Reached: "cccc", ReachedSrc: 9, Modules: map[string]ModuleReach{ - "gitea": {FP: "src2:a", Nodes: 1, Floor: "cccc", Waiting: "gitea on ace runs src2:a and its health check failed", Changes: []Change{{Commit: "cccc", FP: "src2:a", Src: 9}, {Commit: "bbbb", FP: "src2:b", Src: 7}}}, - "zsh": {FP: "src2:z", Changes: []Change{{Commit: "aaaa", FP: "src2:z"}}}, - }} -} - -func TestSourceSame(t *testing.T) { // 1200/AC5 - if !SameSource(sampleSource(), sampleSource()) { - t.Error("equal sources differ") - } - s := sampleSource() - s.Heads["novox/mesh-sdk"] = "zzz" - if SameSource(s, sampleSource()) { - t.Error("different sources are the same") - } - if !SameCandidate(sampleCandidate(), sampleCandidate()) { - t.Error("equal candidates differ") - } - c := sampleCandidate() - c.Pulls[0].Modules = []string{"other"} - if SameCandidate(c, sampleCandidate()) { - t.Error("different candidates are the same") - } - m := Module{FP: "src1:a", Repository: "a/b", Commit: "c"} - if !SameModule(m, m) || SameModule(m, Module{FP: "src1:b", Repository: "a/b", Commit: "c"}) { - t.Error("SameModule wrong") - } - f := sampleForges()[1] - g := sampleForges()[1] - if !SameForge(f, g) { - t.Error("equal forge messages differ") - } - g.Trees["modules/a"] = "other" - if SameForge(f, g) { - t.Error("different forge messages are the same") - } - if !SameReach(sampleReach(), sampleReach()) { - t.Error("equal reach values differ") - } - for name, change := range map[string]func(*Reach){ - "indexed": func(r *Reach) { r.Indexed = "dddd" }, - "floor": func(r *Reach) { m := r.Modules["gitea"]; m.Floor = "bbbb"; r.Modules["gitea"] = m }, - "changes": func(r *Reach) { m := r.Modules["zsh"]; m.Changes = nil; r.Modules["zsh"] = m }, - "modules": func(r *Reach) { delete(r.Modules, "zsh") }, - "reached": func(r *Reach) { r.Reached, r.ReachedSrc = "bbbb", 7 }, - "holds": func(r *Reach) { r.Holds = []string{"gitea on ace does not yet run src2:a"} }, - } { - r := sampleReach() - change(&r) - if SameReach(r, sampleReach()) { - t.Errorf("reach values differing in %s are the same", name) - } - } -} - -func TestSourceBuckets(t *testing.T) { - if SourceBucket != "mesh-delivery-gate_source" || CandidatesBucket != "mesh-delivery-indexer_candidates" || - ModulesBucket != "mesh-delivery-indexer_modules" || SourceMainKey != "main" || - SourceHistory != 64 || CandidatesHistory != 10 || ModulesHistory != 20 || - TakenBucket != "mesh-delivery-indexer_taken" || ReachBucket != "mesh-delivery-view_reach" { - t.Error("a bucket or history differs from the decided layout") - } - // 1280: a repository keys taken and reach by its forge name, in lower case, from either spelling (ADR 0556). - for in, want := range map[string]string{"novox/Mesh-SDK": "novox/mesh-sdk", "http://h:3000/novox/mesh-sdk.git": "novox/mesh-sdk", - "a/b.c": "a/b=2ec", "": "", "nope": "", "/x": ""} { - got, err := RepositoryKey(in) - if (want == "") != (err != nil) || got != want { - t.Errorf("RepositoryKey(%q) = %q, %v; want %q", in, got, err, want) - } - } -} - -func TestForgeDirs(t *testing.T) { - pull := func(dirs []string, known bool) Forge { - return Forge{Kind: ForgePull, Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa", Action: "updated", Dirs: dirs, DirsKnown: known} - } - round := func(f Forge) Forge { - t.Helper() - data, err := EncodeForge(f) - if err != nil { - t.Fatal(err) - } - got, err := DecodeForge(data) - if err != nil { - t.Fatal(err) - } - return got - } - // 1233/AC2: the directories come back equal and in order. - got := round(pull([]string{"modules/a", "modules/b"}, true)) - if !reflect.DeepEqual(got.Dirs, []string{"modules/a", "modules/b"}) || !got.DirsKnown { - t.Errorf("dirs = %v known %v", got.Dirs, got.DirsKnown) - } - // 1233/AC3: a head changing no module directory is told apart from one whose directories could not be said. - none, unknown := round(pull(nil, true)), round(pull(nil, false)) - if !none.DirsKnown || len(none.Dirs) != 0 { - t.Errorf("known and empty: dirs %v known %v", none.Dirs, none.DirsKnown) - } - if unknown.DirsKnown || len(unknown.Dirs) != 0 { - t.Errorf("not known: dirs %v known %v", unknown.Dirs, unknown.DirsKnown) - } - // 1233/AC4: a merged or a refused message carrying directories is refused. - m := sampleForges()[1] - m.Dirs, m.DirsKnown = []string{"modules/a"}, true - if _, err := EncodeForge(m); err == nil { - t.Error("merged message with dirs accepted") - } - r := sampleForges()[2] - r.Dirs, r.DirsKnown = []string{"modules/a"}, true - if _, err := EncodeForge(r); err == nil { - t.Error("refused message with dirs accepted") - } - r = sampleForges()[2] - r.DirsKnown = true - if _, err := EncodeForge(r); err == nil { - t.Error("refused message with dirs-known accepted") - } - // directories on a pull message that does not say it knows them are refused too. - if _, err := EncodeForge(pull([]string{"modules/a"}, false)); err == nil { - t.Error("dirs without dirs-known accepted") - } -} - -func TestModulePath(t *testing.T) { // 1588/AC1 - data, err := EncodeModule(Module{FP: "src1:x", Repository: "a/b", Commit: "c", Path: "modules/build-agent"}) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), `"path":"modules/build-agent"`) { - t.Errorf("encoded %s has no path", data) - } - m, err := DecodeModule(data) - if err != nil || m.Path != "modules/build-agent" { - t.Errorf("round trip = %+v, %v", m, err) - } - // A value written before has no path: it decodes, checks, and encodes without the key. - old, err := DecodeModule([]byte(`{"v":1,"fp":"src1:x","repository":"a/b","commit":"c"}`)) - if err != nil || old.Path != "" { - t.Fatalf("old shape = %+v, %v", old, err) - } - again, _ := EncodeModule(old) - if strings.Contains(string(again), "path") { - t.Errorf("a module without a path encodes %s", again) - } -} - -func TestModuleRootPathAndToolchains(t *testing.T) { // 1615/AC1, AC2 - tc := map[string]ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}, "typescript": {Version: "tsc5", Recipe: "2"}} - data, err := EncodeModule(Module{FP: "src1:x", Repository: "a/b", Commit: "c", Path: RootPath, Toolchains: tc}) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), `"path":"."`) || !strings.Contains(string(data), `"toolchains":{"go":{"version":"go1.27.1","recipe":"1"}`) { - t.Errorf("encoded %s", data) - } - m, err := DecodeModule(data) - if err != nil || m.Path != RootPath || !reflect.DeepEqual(m.Toolchains, tc) { - t.Errorf("round trip = %+v, %v", m, err) - } - // A value written before has none: it decodes and encodes without the key. - old, err := DecodeModule([]byte(`{"v":1,"fp":"src1:x","repository":"a/b","commit":"c","path":"modules/a"}`)) - if err != nil || old.Toolchains != nil { - t.Fatalf("old shape = %+v, %v", old, err) - } - if again, _ := EncodeModule(old); strings.Contains(string(again), "toolchains") { - t.Errorf("a module without toolchains encodes %s", again) - } - for _, bad := range []map[string]ToolchainUsed{{"go": {Recipe: "1"}}, {"go": {Version: "v"}}} { - if _, err := EncodeModule(Module{FP: "src1:x", Repository: "a/b", Commit: "c", Toolchains: bad}); err == nil { - t.Errorf("toolchains %v accepted", bad) - } - } -} diff --git a/go/delivery/stepasks.go b/go/delivery/stepasks.go deleted file mode 100644 index a4ddb74..0000000 --- a/go/delivery/stepasks.go +++ /dev/null @@ -1,844 +0,0 @@ -package delivery - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "path" - "regexp" - "slices" - "sort" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// **Asks: a build or a check step is a message a build node pulls when it has a free slot** (novox/hq ADR 0525, as -// ADR 0536 §4 changes it; issue 1887). The builder puts a trunk fingerprint's build on the delivery stream of the -// artifact's language and a candidate's on the check stream; the checker puts a check of one level on the check stream, -// and a re-judge after a trunk move on the recheck stream. Each build module claims the node seat of its language -// (`node-build-go`, `node-build-node`, `node-build-image`), whose accepted kinds the broker derives as the work queue -// `SEAT_NODE_BUILD_` with one shared pull worker per kind; a holder pulls delivery first, then check, then -// recheck, at most its node's build capacity at once (ADR 0525 §4, §5). -// -// **An ask is one whole job on one node** (ADR 0525 §3): a build ask is the fetch, the build (or copy) of each artifact -// and the push; a check ask is the fetch, the digest and the tests of one level. Its end is written on the build module's -// step key `.` (StepsBucket), which the asker watches with AskStepWatch (ADR 0525 §6). - -// The kinds of ask: the seat's accepted kinds, in the order a holder pulls them. -const ( - AskDelivery = "delivery" - AskCheck = "check" - AskRecheck = "recheck" -) - -// AskKinds are the accepted kinds of a build seat, in the order a holder pulls them (ADR 0536 §4). -var AskKinds = []string{AskDelivery, AskCheck, AskRecheck} - -// The languages of the build seats: each is one build module's (mesh-build-) and one seat's -// (node-build-). -const ( - AskGo = "go" - AskNode = "node" - AskImage = "image" -) - -// AskLanguages are the build seats' languages. -var AskLanguages = []string{AskGo, AskNode, AskImage} - -// **An ask may name what its node needs** (issue 890, novox/hq ADR 0525 §1 as the placement record amends it). The seat's -// workers are shared, so the asker still chooses no node; a check ask whose packages' tests need something only some -// nodes have names it in Needs, and a holder whose node lacks one of them takes no part of the ask: it ends the ask's step -// refused as declined (StepDeclined) and drops the message, and the asker puts the ask again under a new id, so a node -// that has the need takes it. A need is a node seat whose holder installs what the tests read on its node; a package -// declares it beside its tests, and the build module's digest reports it (LevelDigest.Needs). - -// NeedNodeBuildNode is the need of a package whose integration tests read the Node build module's installed toolchain -// (ADR 0533 §1): the node holds the seat node-build-node, so its toolchain is installed where the Go build module's -// integration level is given it. -const NeedNodeBuildNode = "node-build-node" - -// AskNeeds are the needs an ask may name. -var AskNeeds = []string{NeedNodeBuildNode} - -// CheckNeed refuses a need that is not one of AskNeeds. -func CheckNeed(need string) error { - for _, n := range AskNeeds { - if need == n { - return nil - } - } - return fmt.Errorf("delivery: %q is not a need an ask may name (%s)", need, strings.Join(AskNeeds, ", ")) -} - -// Lacks is what of needs has does not say the node has, in the order of needs; a nil has has nothing. -func Lacks(needs []string, has func(need string) bool) []string { - var out []string - for _, n := range needs { - if has == nil || !has(n) { - out = append(out, n) - } - } - return out -} - -// PackageNeeds is one package and what its tests need of the node at a level. -type PackageNeeds struct { - Package string `json:"package"` - Needs []string `json:"needs"` -} - -// StepDeclined is the result of the step of an ask a holder declined: the ask's needs, and those the holder's node lacks. -// The step's state is refused; the asker puts the ask again under a new id (DeclinedOf). -type StepDeclined struct { - Declined bool `json:"declined"` - Needs []string `json:"needs"` - Lacks []string `json:"lacks"` -} - -// DeclinedOf says whether step is a declined ask's, and what its node lacked. -func DeclinedOf(step Step) (StepDeclined, bool) { - if step.State != StepRefused || len(step.Result) == 0 { - return StepDeclined{}, false - } - var d StepDeclined - if err := json.Unmarshal(step.Result, &d); err != nil || !d.Declined { - return StepDeclined{}, false - } - return d, true -} - -// UnmetNeeds are the needs of the packages the levels left untested (LevelAnswer.Unmet), each once, sorted. -func UnmetNeeds(levels []LevelAnswer) []string { - seen := map[string]bool{} - var out []string - for _, l := range levels { - for _, u := range l.Unmet { - for _, n := range u.Needs { - if !seen[n] { - seen[n] = true - out = append(out, n) - } - } - } - } - sort.Strings(out) - return out -} - -// AskRuns is how many times an ask is run before it is taken for poison (issue 1907): an ask whose run never ends (it -// takes its node down, or its process dies with it) is delivered again by its stream to the next free node (ADR 0525 -// §6); the holder that takes it past AskRuns runs it no more, writes its step failed saying so, and ends it, so the -// asker's key fails with why and no further node is taken down. NATS-native: the count is the stream's own delivery -// count of the message, no timer. -const AskRuns = 3 - -// AskMaxDeliver is the build seats' workers' bound of deliveries (the broker's MaxDeliver): past AskRuns, so the holder -// that says the poison gets its delivery, with one more for a holder that dies saying it. -const AskMaxDeliver = AskRuns + 2 - -// StepAskVersion is the version of an Ask value; a reader refuses another (ErrUnknownVersion). -const StepAskVersion = 1 - -func checkAskKind(kind string) error { - for _, k := range AskKinds { - if kind == k { - return nil - } - } - return fmt.Errorf("delivery: the ask kind %q is not delivery, check or recheck", kind) -} - -func checkAskLanguage(language string) error { - for _, l := range AskLanguages { - if language == l { - return nil - } - } - return fmt.Errorf("delivery: the ask language %q is not go, node or image", language) -} - -// BuildSeat is the node seat a build module of language claims: node-build-. -func BuildSeat(language string) (string, error) { - if err := checkAskLanguage(language); err != nil { - return "", err - } - return "node-build-" + language, nil -} - -// BuildModuleOf is the build module that holds language's seat: mesh-build-. -func BuildModuleOf(language string) (string, error) { - if err := checkAskLanguage(language); err != nil { - return "", err - } - return "mesh-build-" + language, nil -} - -// AskSubject is the subject an ask of kind for language is published on: the seat's accept subject, -// `mesh.seat.node-build-.accept.` (mesh-controller internal/broker SeatStreams). -func AskSubject(language, kind string) (string, error) { - seat, err := BuildSeat(language) - if err != nil { - return "", err - } - if err := checkAskKind(kind); err != nil { - return "", err - } - return "mesh.seat." + seat + ".accept." + kind, nil -} - -// ParseAskSubject is the inverse of AskSubject; it refuses a subject of another seat or kind. -func ParseAskSubject(subject string) (language, kind string, err error) { - rest, ok := strings.CutPrefix(subject, "mesh.seat.node-build-") - if !ok { - return "", "", fmt.Errorf("delivery: %q is not a build seat's accept subject", subject) - } - language, kind, ok = strings.Cut(rest, ".accept.") - if !ok { - return "", "", fmt.Errorf("delivery: %q is not a build seat's accept subject", subject) - } - if want, err := AskSubject(language, kind); err != nil || want != subject { - return "", "", fmt.Errorf("delivery: %q is not a build seat's accept subject: %v", subject, err) - } - return language, kind, nil -} - -// AskStream is the work-queue stream of language's seat, as the broker names a seat's stream: SEAT_NODE_BUILD_. -func AskStream(language string) (string, error) { - seat, err := BuildSeat(language) - if err != nil { - return "", err - } - return "SEAT_" + strings.ToUpper(strings.ReplaceAll(seat, "-", "_")), nil -} - -// AskWorker is the shared pull consumer of one kind on language's stream, filtered on that kind's subject: -// SEAT_NODE_BUILD___worker. Every holder of the seat pulls from it, so the work is shared without a -// holder knowing the others (ADR 0190), and the kinds' workers are apart so a holder can pull them in their order. -func AskWorker(language, kind string) (string, error) { - stream, err := AskStream(language) - if err != nil { - return "", err - } - if err := checkAskKind(kind); err != nil { - return "", err - } - return stream + "_" + strings.ToUpper(kind) + "_worker", nil -} - -// An ask id is a step id of the form buildstep.NewID gives: the time in milliseconds in base 36 (8 or 9 digits) and six -// random hex digits. Its form keeps it apart from a module's or a node's name in a key. -var askIDOK = regexp.MustCompile(`^[0-9a-z]{8,9}[0-9a-f]{6}$`) - -func checkAskID(id string) error { - if !askIDOK.MatchString(id) { - return fmt.Errorf("delivery: %q is not an ask id (base-36 milliseconds and six hex digits)", id) - } - return nil -} - -// StepAsk is the message on a build seat's stream: who asked what, of which kind and language, and the one whole job. -// Exactly one of Build, Check and Compose is set: a delivery ask is a build; a check ask a build (a candidate's -// fingerprint), a check or, on the Go seat, a compose; a recheck ask a check; an image ask is always a build. -type StepAsk struct { - V int `json:"v"` - ID string `json:"id"` - Kind string `json:"kind"` - Language string `json:"language"` - // Asker is the module that put the ask (mesh-delivery-builder or mesh-delivery-checker); the stream's grant is what - // makes it true, this field only names it to a reader. - Asker string `json:"asker"` - At time.Time `json:"at"` - Build *BuildJob `json:"build,omitempty"` - Check *CheckJob `json:"check,omitempty"` - Compose *ComposeJob `json:"compose,omitempty"` - // Needs are what the node that runs the ask must have (AskNeeds); a check ask's alone. Empty: any node. - Needs []string `json:"needs,omitempty"` - // Packages are the version of each mesh package the build or the check builds against, by its module path (a Go - // module's path, git.novox.be/novox/mesh-sdk/go; the shape takes a seat's package too), whatever version the - // repository's go.mod names (novox/hq ADR 0573 §2, §4; issue 1585): a Go package's version is a full commit, the - // trunk merge the indexer holds as the package's latest published version or, in a delivery group's check, the - // head of the group's member that publishes it. The build module writes each into its workspace, checksum-verified, - // and names them in its log (PackageVersions). A compose takes none: it builds the mesh-sdk its reader names. - Packages PackageVersions `json:"packages,omitempty"` -} - -// PackageVersions are the versions of the mesh packages a build or a check builds against, by package (a Go module's -// path): each a full commit (novox/hq ADR 0573 §2), or a Go semantic version, the form a delivery group's check names the -// group's tagged version in (`v0.1.15-feat-x.2`, ADR 0607 §3, §5; design 51 *Tagged versions*). -type PackageVersions map[string]string - -// Check refuses a package that is no module path or a version that is neither a full commit nor a Go semantic version, -// naming it. -func (p PackageVersions) Check() error { - for pkg, version := range p { - if pkg == "" || strings.ContainsAny(pkg, " \t\n@") || strings.HasPrefix(pkg, "/") { - return fmt.Errorf("delivery: the package %q is no module path", pkg) - } - if !askCommitOK.MatchString(version) && !PackageSemver.MatchString(version) { - return fmt.Errorf("delivery: the package %s names the version %q, which is neither a full commit nor a Go semantic version (v0.1.15-feat-x.2)", pkg, version) - } - } - return nil -} - -// ComposeJob is a compose ask's job (novox/hq ADR 0482 clause 4, issue 1622): fetch the commit, build the composer -// command (Package) in the Go module at Dir of the fetched tree, once per ComposerKey, run it in a run step shut off -// with Request on its standard input, and answer its standard output whole (ComposeAnswer). The assigner asks it, to -// compose a group with its mesh-sdk member's head, and to read the group's manifests with the mesh-sdk the running -// controller and node-engine pin (ADR 0482 clause 5, issue 1623). -type ComposeJob struct { - // Fetch is the fetch tool's arguments: the repository and the full commit whose mesh-sdk composes (mesh-sdk itself - // at a head, or a module that requires mesh-sdk, at the version it pins). - Fetch map[string]any `json:"fetch"` - // Dir is the directory of the Go module the composer is built in, relative to the repository's root ("go" for - // mesh-sdk, RootPath for a module at the root); Package is the composer's import path (mesh-sdk composer.Package). - Dir string `json:"dir"` - Package string `json:"package"` - // Request is the composer's request (mesh-sdk composer.Request), its manifests named by their digests in the - // indexer's ManifestsBucket: the build module fills them before the run (composer.Fill). - Request json.RawMessage `json:"request"` - // Reader names, for a reader of the ask, whose composition this is: the mesh-sdk head of a group, or a module whose - // mesh-sdk reads the group's manifests (mesh-controller, mesh-host). - Reader string `json:"reader,omitempty"` -} - -// ComposeAnswer is a compose ask's step result: whether this run built the composer or took the one kept under its -// key, the key, and the composer's verdict (mesh-sdk composer.Verdict) whole. -type ComposeAnswer struct { - Built bool `json:"built"` - Key string `json:"key"` - Verdict json.RawMessage `json:"verdict"` -} - -// ComposerKey is the key a build module keeps a built composer under: the repository, commit, directory and package it -// was built from and the toolchain it was built with, hashed (`composer-` and 16 hex digits), so each head is built once -// on a node (ADR 0482 clause 4). -func ComposerKey(repository, commit, dir, pkg string, toolchain ToolchainUsed) string { - sum := sha256.Sum256([]byte(strings.Join([]string{repository, commit, dir, pkg, toolchain.Version, toolchain.Recipe}, "\n"))) - return "composer-" + hex.EncodeToString(sum[:8]) -} - -// BuildJob is a build ask's job: fetch the commit (unless every artifact is a copy), then build or copy each artifact, -// pushing it when Publish says so, and answer each artifact's digest and reference (BuildAnswer). -type BuildJob struct { - // Module is the module's name; Path its directory in the repository (the build tool's `module`, "." for its root). - Module string `json:"module"` - Path string `json:"path,omitempty"` - // FP is the fingerprint the build is of (the builder's key), named for a reader of the ask. - FP string `json:"fp,omitempty"` - // Fetch is the fetch tool's arguments, as the build module's fetch tool takes them (buildstep.FetchRequest's Args); - // absent when every artifact is a copy. - Fetch map[string]any `json:"fetch,omitempty"` - // Artifacts are built in this order. - Artifacts []AskArtifact `json:"artifacts"` - // Publish says the build pushes an image or publishes a package: a trunk build only (ADR 0487 clause 12). - Publish bool `json:"publish,omitempty"` - // Toolchain is the toolchain the fingerprint was computed with; a build module running another refuses the ask. - Toolchain *ToolchainUsed `json:"toolchain,omitempty"` -} - -// AskArtifact is one artifact of a build ask: built from the fetched tree, or copied from From, an upstream pin (ADR -// 0498 clause 4). -type AskArtifact struct { - Artifact string `json:"artifact"` - From string `json:"from,omitempty"` - // Seat is the registry seat the artifact is published through (design 51 *Publishing*): npm-package-registry or - // go-package-registry for a package, container-image-registry for an image or an upstream copy, - // oci-artifact-registry for a bundle or an archive. Absent for an ask written before the registry milestone, which - // its build module pushes as before. - Seat string `json:"seat,omitempty"` - // Version is the version the builder gave the artifact at the trunk's claim (design 51 *The versions*), which the - // build module names in the seat's publish; absent for a candidate's build and for a bundle or an archive published - // by its digest alone (ADR 0617 §1). - Version string `json:"version,omitempty"` -} - -// CheckJob is a check ask's job, the levels of one check (ADR 0525 §3): fetch the head merged onto its trunk once, then -// for each level in order digest its packages, take the kept verdicts for the digests that have one, and test the rest, -// the packages the change's own tests are in first, every level and package running to its end also after a failure, -// so one check names every failure (CheckAnswer). -type CheckJob struct { - // Fetch is the fetch tool's arguments (repository, commit, ref, base, and the build module's own: beside, package). - Fetch map[string]any `json:"fetch"` - // Levels are run in this order (LevelUnit, LevelIntegration); a try may name one. - Levels []string `json:"levels"` - // Kept says the build module takes the kept verdicts of the checker's kept bucket (CheckerKeptBucket, by KeptKey) - // for the packages whose digest has one and covers the siblings the fetch placed, and tests only the others (issue - // 1887's design limit 1). A try that runs some tests (Run), and a level the candidate's own build module judges - // (Runner), keep nothing. - Kept bool `json:"kept,omitempty"` - // Runner is the test step's runner ("workspace": the candidate's own build module judges it, ADR 0535 §4), used - // when the merge changes a path under RunnerWhen (every merge when RunnerWhen is empty). - Runner string `json:"runner,omitempty"` - RunnerWhen string `json:"runner_when,omitempty"` - // Run is a try's test pattern (go test -run): each level runs it alone, digesting nothing. - Run string `json:"run,omitempty"` - // Packages, when set, are the only packages each level tests (issue 890, ADR 0552 §4): a placed ask names the - // packages the first ask left unmet, and the others are neither tested nor taken kept, so nothing is judged twice. - Packages []string `json:"packages,omitempty"` - // NodeNames are the names of every node with a record (novox/hq ADR 0567 §12), read by the checker from each - // `.node` of node-engine's have: the names check of ADR 0507 §9 looks for them in the tree, so no repository - // has to list them. The build step hands them to its test tool as the argument NodeNamesArg, and the build module - // gives them to the tests in NodeNamesEnv. - NodeNames []string `json:"node-names,omitempty"` - // Toolchain is the toolchain the checker agreed for the check's language (novox/hq ADR 0584): a node building with - // another declines the ask, so a pass is never recorded as run on a version it did not run on. Nil names none. - Toolchain *ToolchainUsed `json:"toolchain,omitempty"` -} - -// NodeNamesArg is the test tool's argument that carries a check's node names, and NodeNamesEnv the environment -// variable a build module sets for the tests it runs from it, the names separated by commas. -const ( - NodeNamesArg = "node-names" - NodeNamesEnv = "MESH_NODE_NAMES" -) - -func (a StepAsk) check() error { - if err := checkAskID(a.ID); err != nil { - return err - } - if err := checkAskKind(a.Kind); err != nil { - return err - } - if err := checkAskLanguage(a.Language); err != nil { - return err - } - if err := checkName("asker", a.Asker); err != nil { - return err - } - if a.At.IsZero() { - return fmt.Errorf("delivery: the ask %s names no time", a.ID) - } - jobs := 0 - for _, set := range []bool{a.Build != nil, a.Check != nil, a.Compose != nil} { - if set { - jobs++ - } - } - switch { - case jobs != 1: - return fmt.Errorf("delivery: the ask %s holds a build, a check or a compose, exactly one", a.ID) - case a.Compose != nil && (a.Kind != AskCheck || a.Language != AskGo): - return fmt.Errorf("delivery: the ask %s is a compose, which the Go seat's check stream alone carries", a.ID) - case a.Build != nil && a.Kind == AskRecheck: - return fmt.Errorf("delivery: the ask %s is a build on the recheck stream, which carries checks alone", a.ID) - case a.Check != nil && a.Kind == AskDelivery: - return fmt.Errorf("delivery: the ask %s is a check on the delivery stream, which carries trunk builds alone", a.ID) - case a.Check != nil && a.Language == AskImage: - return fmt.Errorf("delivery: the ask %s is a check of an image, which has no tests", a.ID) - case len(a.Needs) > 0 && a.Check == nil: - return fmt.Errorf("delivery: the ask %s names needs and is no check, whose tests alone need a node", a.ID) - case len(a.Packages) > 0 && a.Compose != nil: - return fmt.Errorf("delivery: the ask %s is a compose and names package versions, which a compose does not take", a.ID) - } - if err := a.Packages.Check(); err != nil { - return fmt.Errorf("the ask %s: %w", a.ID, err) - } - for _, n := range a.Needs { - if err := CheckNeed(n); err != nil { - return fmt.Errorf("the ask %s: %w", a.ID, err) - } - } - if a.Build != nil { - return a.Build.check(a.ID) - } - if a.Compose != nil { - return a.Compose.check(a.ID) - } - return a.Check.check(a.ID) -} - -func (c ComposeJob) check(id string) error { - if err := checkFetchArgs(id, c.Fetch); err != nil { - return err - } - if c.Dir == "" || c.Dir != path.Clean(c.Dir) || strings.HasPrefix(c.Dir, "/") || c.Dir == ".." || strings.HasPrefix(c.Dir, "../") { - return fmt.Errorf("delivery: the compose ask %s names no clean directory inside the repository (%q)", id, c.Dir) - } - if c.Package == "" { - return fmt.Errorf("delivery: the compose ask %s names no package to build", id) - } - if !json.Valid(c.Request) || len(c.Request) == 0 || c.Request[0] != '{' { - return fmt.Errorf("delivery: the compose ask %s holds no request object", id) - } - return nil -} - -// registrySeats are the seats an artifact is published through (ADR 0605 §1). -var registrySeats = catalogue.PublishSeats - -func (b BuildJob) check(id string) error { - if err := checkName("module", b.Module); err != nil { - return err - } - if len(b.Artifacts) == 0 { - return fmt.Errorf("delivery: the build ask %s names no artifact", id) - } - fetch := false - for _, a := range b.Artifacts { - if err := checkName("artifact", a.Artifact); err != nil { - return err - } - if a.Seat != "" && !slices.Contains(registrySeats, a.Seat) { - return fmt.Errorf("delivery: the build ask %s publishes %s through %q, which is no registry seat (%s)", id, - a.Artifact, a.Seat, strings.Join(registrySeats, ", ")) - } - if a.Version != "" && a.Seat == "" { - return fmt.Errorf("delivery: the build ask %s gives %s the version %s and names no seat to publish it through", - id, a.Artifact, a.Version) - } - fetch = fetch || a.From == "" - } - if fetch { - if b.Path == "" { - return fmt.Errorf("delivery: the build ask %s names no directory (path) for the module %s", id, b.Module) - } - if err := checkFetchArgs(id, b.Fetch); err != nil { - return err - } - } - return nil -} - -func (c CheckJob) check(id string) error { - if len(c.Levels) == 0 { - return fmt.Errorf("delivery: the check ask %s names no level", id) - } - for _, l := range c.Levels { - if err := checkLevel(l); err != nil { - return err - } - } - for _, p := range c.Packages { - if p == "" || strings.ContainsAny(p, " \t\n") { - return fmt.Errorf("delivery: the check ask %s names a package %q that is no import path", id, p) - } - } - seen := map[string]bool{} - for _, n := range c.NodeNames { - if err := checkName("node", n); err != nil || strings.Contains(n, ",") || seen[n] { - return fmt.Errorf("delivery: the check ask %s names the node %q, which is no node name or named twice", id, n) - } - seen[n] = true - } - return checkFetchArgs(id, c.Fetch) -} - -var askCommitOK = regexp.MustCompile(`^[0-9a-f]{40}$`) - -// PackageSemver is a Go semantic version a package may be named at (PackageVersions): v, three numbers, and an optional -// pre-release of Semantic Versioning's characters (a tagged version's, ADR 0607 §1). -var PackageSemver = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) - -// checkFetchArgs refuses fetch arguments with no repository or no full commit; the rest is the build module's fetch to -// judge, as a direct call's is. -func checkFetchArgs(id string, args map[string]any) error { - repository, _ := args["repository"].(string) - commit, _ := args["commit"].(string) - if repository == "" || !strings.Contains(repository, "/") { - return fmt.Errorf("delivery: the ask %s fetches no repository /", id) - } - if !askCommitOK.MatchString(commit) { - return fmt.Errorf("delivery: the ask %s fetches no full commit", id) - } - return nil -} - -// EncodeStepAsk is a as JSON at StepAskVersion; it refuses what DecodeStepAsk refuses. -func EncodeStepAsk(a StepAsk) ([]byte, error) { - a.V = StepAskVersion - a.At = a.At.UTC() - if err := a.check(); err != nil { - return nil, err - } - return json.Marshal(a) -} - -// DecodeStepAsk reads an ask; it refuses an unknown version (ErrUnknownVersion), an unknown kind or language, an id that -// is not an ask id, a build and a check together or neither, a job on a stream that does not carry it, and a fetch with -// no repository or no full commit. -func DecodeStepAsk(data []byte) (StepAsk, error) { - var probe struct { - V *int `json:"v"` - } - if err := json.Unmarshal(data, &probe); err != nil { - return StepAsk{}, fmt.Errorf("delivery: an ask is not JSON: %w", err) - } - if probe.V == nil || *probe.V != StepAskVersion { - return StepAsk{}, fmt.Errorf("%w: an ask of version %v, this reader reads %d", ErrUnknownVersion, probe.V, StepAskVersion) - } - var a StepAsk - if err := json.Unmarshal(data, &a); err != nil { - return StepAsk{}, fmt.Errorf("delivery: an ask does not fit its shape: %w", err) - } - if err := a.check(); err != nil { - return StepAsk{}, err - } - return a, nil -} - -// BuildAnswer is a build ask's step result: each artifact's digest and, for a pushed one, its reference. -type BuildAnswer struct { - Artifacts []AskBuilt `json:"artifacts"` - Toolchain *ToolchainUsed `json:"toolchain,omitempty"` - // Imports are the mesh packages (import paths) the built programs import, their tests left out, sorted and each - // once (novox/hq ADR 0593 §2): a Go build module reports them, the builder carries them onto the build's end - // (BuildLogEntry.Imports). Absent from a build module that reports none. - Imports []string `json:"imports,omitempty"` - // OutOfStep are the generated files the build's compare found out of step with what their generator writes (the test - // step's `out-of-step`, answered by a trunk build's ask too): the builder carries them onto the build's end - // (BuildLogEntry.OutOfStep). Absent when none is, or from a build module that compares none. - OutOfStep []string `json:"out-of-step,omitempty"` -} - -// AskBuilt is one artifact a build ask built. -type AskBuilt struct { - Artifact string `json:"artifact"` - Digest string `json:"digest"` - Reference string `json:"reference,omitempty"` -} - -// CheckAnswer is a check ask's step result, each part the build module's own tool's answer whole: the fetch's, then one -// LevelAnswer per level run, in order, every asked level also after one that did not pass. The checker judges and keeps from it as it -// did from the tools' answers. -type CheckAnswer struct { - Fetch json.RawMessage `json:"fetch"` - Levels []LevelAnswer `json:"levels,omitempty"` -} - -// LevelAnswer is one level of a check ask: the digest's answer (absent when nothing was digested: the level ran whole and -// keeps nothing), the kept verdicts taken, and one test result per test step run, in the order run. -type LevelAnswer struct { - Level string `json:"level"` - // Judged is what the digest under the candidate's runner said of the program that ran it ("candidate "), - // when the candidate judged the level (ADR 0535 §4, §5); empty otherwise. - Judged string `json:"judged,omitempty"` - Digest *LevelDigest `json:"digest,omitempty"` - Kept []KeptTaken `json:"kept,omitempty"` - Tests []json.RawMessage `json:"tests,omitempty"` - // Unmet are the packages the level did not test on this node, because their tests need what it lacks (issue 890): the - // asker keeps what passed and asks them again of a node that has it (StepAsk.Needs). - Unmet []PackageNeeds `json:"unmet,omitempty"` -} - -// LevelDigest is the digest's answer as a level keeps it in the ask's step result: its packages a list of -// {package, digest} sorted by package, never a map keyed by the package's path (issue 1930). The step result is a -// state value, and the node runtime refuses a value with a field name ending in a credential word (novox/hq ADR 0201): -// a Go package such as .../internal/token is such a name as a map key, and is a plain value in the list. It decodes -// the digest tool's own answer, and a value stored before issue 1930, with packages as a map too. -type LevelDigest struct { - Level string `json:"level,omitempty"` - Packages []PackageDigest `json:"packages"` - Beside []string `json:"beside,omitempty"` - WithoutTests []string `json:"without_tests,omitempty"` - JudgedBy string `json:"judged_by,omitempty"` - // Needs are the packages whose tests at this level need something of the node (issue 890), as they declare it. - Needs []PackageNeeds `json:"needs,omitempty"` -} - -// PackageDigest is one package's digest at a level. -type PackageDigest struct { - Package string `json:"package"` - Digest string `json:"digest"` -} - -// UnmarshalJSON reads packages as the list or as the map keyed by package path (the digest tool's answer, and a level -// answer stored before issue 1930), and sorts them by package. -func (d *LevelDigest) UnmarshalJSON(raw []byte) error { - var v struct { - Level string `json:"level"` - Packages json.RawMessage `json:"packages"` - Beside []string `json:"beside"` - WithoutTests []string `json:"without_tests"` - JudgedBy string `json:"judged_by"` - Needs []PackageNeeds `json:"needs"` - } - if err := json.Unmarshal(raw, &v); err != nil { - return err - } - var pkgs []PackageDigest - if p := strings.TrimSpace(string(v.Packages)); p != "" && p != "null" { - if strings.HasPrefix(p, "{") { - var m map[string]string - if err := json.Unmarshal(v.Packages, &m); err != nil { - return fmt.Errorf("a digest's packages: %w", err) - } - for pkg, digest := range m { - pkgs = append(pkgs, PackageDigest{Package: pkg, Digest: digest}) - } - } else if err := json.Unmarshal(v.Packages, &pkgs); err != nil { - return fmt.Errorf("a digest's packages: %w", err) - } - } - sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Package < pkgs[j].Package }) - *d = LevelDigest{Level: v.Level, Packages: pkgs, Beside: v.Beside, WithoutTests: v.WithoutTests, JudgedBy: v.JudgedBy, Needs: v.Needs} - return nil -} - -// NeedsOf is what the package's tests need of the node at the digest's level; nil when it declares nothing. -func (d *LevelDigest) NeedsOf(pkg string) []string { - if d == nil { - return nil - } - for _, n := range d.Needs { - if n.Package == pkg { - return n.Needs - } - } - return nil -} - -// Map is the digests by package, for a lookup; nil when it names none. -func (d *LevelDigest) Map() map[string]string { - if d == nil || len(d.Packages) == 0 { - return nil - } - m := make(map[string]string, len(d.Packages)) - for _, p := range d.Packages { - m[p.Package] = p.Digest - } - return m -} - -// KeptTaken is a package whose kept verdict the check took instead of testing it. -type KeptTaken struct { - Package string `json:"package"` - Digest string `json:"digest"` - Tests int `json:"tests"` -} - -// AskStepKey is the key, in the build module's StepsBucket, of the step that runs an ask on node: `.` -// (ADR 0525 §6). The node is the one that pulled the ask, which the asker learns from the key. -func AskStepKey(node, askID string) (string, error) { - if err := checkAskID(askID); err != nil { - return "", err - } - return StepKey(node, askID) -} - -// AskStepWatch is the pattern an asker watches its ask's step with, the node a wildcard: `*.`. -func AskStepWatch(askID string) (string, error) { - if err := checkAskID(askID); err != nil { - return "", err - } - return "*." + askID, nil -} - -// ParseAskStepKey reads the node and the ask id from a step key of an ask; it refuses a key that is not `.`, such as a have key `.` or a step key a direct tool call wrote under another id form. -func ParseAskStepKey(key string) (node, askID string, err error) { - node, askID, err = ParseStepKey(key) - if err != nil { - return "", "", err - } - if err := checkAskID(askID); err != nil { - return "", "", fmt.Errorf("delivery: %q is not an ask's step key: %w", key, err) - } - return node, askID, nil -} - -// StepBound is how long an asker waits for one ask's step once a build node took it (30 minutes): past it the asker has -// stopped waiting, so a key still running is nobody's. The build modules end such a key (buildstep.EndStale); the -// checker's, the builder's and the watchman's own copies of the number are to read this one (issue 1916). -const StepBound = 30 * time.Minute - -// PausesState is the state a build module keeps its nodes' pause keys in. It has no age, unlike StepsState, whose keys -// are per step and dropped after their age: a pause kept among the steps would end by itself. -const PausesState = "pauses" - -// PauseKey is the key, in a build module's PausesState, that keeps whether the module on node takes asks: -// `paused.` (issue 1887's design limit 2: every holder pulls from one shared worker, so a node's pause is its holder -// not fetching, kept so a restart keeps it). A pause key is never an ask's step key: its second part is a node. -func PauseKey(node string) (string, error) { - if err := checkName("node", node); err != nil { - return "", err - } - return "paused." + node, nil -} - -// Pause is the value of a pause key: whether the node's module takes no asks, who said so and when. -type Pause struct { - V int `json:"v"` - Paused bool `json:"paused"` - By string `json:"by,omitempty"` - At time.Time `json:"at"` -} - -// EncodePause is p as JSON at Version. -func EncodePause(p Pause) ([]byte, error) { - p.V = Version - p.At = p.At.UTC() - return json.Marshal(p) -} - -// DecodePause reads a pause value; it refuses an unknown version (ErrUnknownVersion). -func DecodePause(data []byte) (Pause, error) { - var p Pause - if err := decode(data, &p); err != nil { - return Pause{}, err - } - return p, nil -} - -// CapacityKey is the key, in a build module's PausesState, where the module on node says the capacity it judges by -// now and why: `capacity.` (issue 1806's follow-up). The pauses state has no age, so the value stands until the -// module writes another; a reader such as the delivery view's build_usage says it in place of the configured capacity. -func CapacityKey(node string) (string, error) { - if err := checkName("node", node); err != nil { - return "", err - } - return "capacity." + node, nil -} - -// Capacity is a build seat's capacity on one node as its holder judges by it: the configured capacity (the module's -// setting `capacity`), bounded by the cores its node's build slice gives (its CPU quota, read live) over the cores one -// step needs (the module's setting `cores-per-step`). Effective is min(Configured, ByCPU) when a quota is known, else -// Configured; one step at least while Configured is above zero, so a node whose quota is under one step's need still -// builds, slowly. -type Capacity struct { - V int `json:"v"` - Effective int `json:"effective"` - Configured int `json:"configured"` - // ConfiguredFrom says where Configured was read: mesh-config's value as the config watch handed it, or the settings - // file. - ConfiguredFrom string `json:"configured-from,omitempty"` - // QuotaCores is the build slice's CPU quota in processors (cpu.max: 1200% is 12), 0 when none is known; QuotaFrom - // says where it was read (the cgroup, or the share the composer gave) or why none is known. - QuotaCores float64 `json:"quota-cores,omitempty"` - QuotaFrom string `json:"quota-from"` - // CoresPerStep is what one step of this module needs; ByCPU is floor(QuotaCores / CoresPerStep), at least 1, and 0 - // when no quota is known. - CoresPerStep float64 `json:"cores-per-step"` - ByCPU int `json:"by-cpu,omitempty"` - // BuildHours is the node's build window (HH:MM-HH:MM, its local time and zone), absent when it builds at any hour; - // OutsideHours says the time judged fell outside it, so Effective is 0 and the next step waits for the window to open. - BuildHours string `json:"build-hours,omitempty"` - OutsideHours bool `json:"outside-hours,omitempty"` - // BuildHoursFault is why the node's build-hours value cannot be read, naming the value: it then builds at any hour. - BuildHoursFault string `json:"build-hours-fault,omitempty"` - // Ineligible is why the module found its node cannot run its asks (buildstep.Puller.Eligible), absent when it can: - // Effective is then 0 at every hour. - Ineligible string `json:"ineligible,omitempty"` - // Why is the capacity in words: what bounds it. - Why string `json:"why"` - At time.Time `json:"at,omitzero"` -} - -// EncodeCapacity is c as JSON at Version. -func EncodeCapacity(c Capacity) ([]byte, error) { - c.V = Version - c.At = c.At.UTC() - return json.Marshal(c) -} - -// DecodeCapacity reads a capacity value; it refuses an unknown version (ErrUnknownVersion). -func DecodeCapacity(data []byte) (Capacity, error) { - var c Capacity - if err := decode(data, &c); err != nil { - return Capacity{}, err - } - return c, nil -} diff --git a/go/delivery/stepasks_compose_test.go b/go/delivery/stepasks_compose_test.go deleted file mode 100644 index a9547f0..0000000 --- a/go/delivery/stepasks_compose_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package delivery - -import ( - "encoding/json" - "reflect" - "strings" - "testing" - "time" -) - -func composeAsk() StepAsk { - return StepAsk{ID: "mh3k2x9qa1b2c5", Kind: AskCheck, Language: AskGo, Asker: "mesh-delivery-assigner", - At: time.Date(2026, 10, 24, 3, 0, 0, 0, time.UTC), - Compose: &ComposeJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": askCommit}, - Dir: "go", Package: "git.novox.be/novox/mesh-sdk/go/cmd/mesh-compose", - Request: json.RawMessage(`{"v":1,"cases":[{"name":"change","manifests":{"demo":{"digest":"d1"}}}]}`), Reader: "mesh-sdk head"}} -} - -// 1622: a compose ask encodes and decodes with every field. -func TestAComposeAskRoundTrips(t *testing.T) { - a := composeAsk() - raw, err := EncodeStepAsk(a) - if err != nil { - t.Fatal(err) - } - got, err := DecodeStepAsk(raw) - if err != nil { - t.Fatal(err) - } - a.V = StepAskVersion - if !reflect.DeepEqual(got, a) { - t.Fatalf("round trip:\n got %+v\nwant %+v", got, a) - } -} - -// 1622: a compose ask goes only on the Go seat's check stream, holds no other job beside it, fetches a full commit, -// builds a package in a clean directory of the repository and carries a request object. -func TestAComposeAskIsRefusedWhereItDoesNotBelong(t *testing.T) { - for name, change := range map[string]func(*StepAsk){ - "on the delivery stream": func(a *StepAsk) { a.Kind = AskDelivery }, - "on the recheck stream": func(a *StepAsk) { a.Kind = AskRecheck }, - "on the Node seat": func(a *StepAsk) { a.Language = AskNode }, - "beside a check": func(a *StepAsk) { a.Check = checkAsk().Check }, - "with a short commit": func(a *StepAsk) { a.Compose.Fetch["commit"] = "0123456" }, - "in no directory": func(a *StepAsk) { a.Compose.Dir = "" }, - "outside the repository": func(a *StepAsk) { a.Compose.Dir = "../x" }, - "with no package": func(a *StepAsk) { a.Compose.Package = "" }, - "with no request": func(a *StepAsk) { a.Compose.Request = nil }, - "with a request not JSON": func(a *StepAsk) { a.Compose.Request = json.RawMessage(`[1]`) }, - } { - a := composeAsk() - change(&a) - if _, err := EncodeStepAsk(a); err == nil { - t.Errorf("a compose ask %s is encoded", name) - } - } -} - -// 1622/AC3: a composer is kept under one key per repository, commit, directory, package and toolchain, so two asks of -// one head build it once on a node, and another head or toolchain builds its own. -func TestAComposerIsKeptByWhatItWasBuiltFrom(t *testing.T) { - tc := ToolchainUsed{Version: "go1.26.0", Recipe: "3"} - k := ComposerKey("novox/mesh-sdk", askCommit, "go", "p", tc) - if k != ComposerKey("novox/mesh-sdk", askCommit, "go", "p", tc) || !strings.HasPrefix(k, "composer-") || len(k) != len("composer-")+16 { - t.Fatalf("key = %q", k) - } - for _, other := range []string{ - ComposerKey("novox/mesh-sdk", strings.Repeat("1", 40), "go", "p", tc), - ComposerKey("novox/mesh-controller", askCommit, ".", "p", tc), - ComposerKey("novox/mesh-sdk", askCommit, "go", "p", ToolchainUsed{Version: "go1.26.1", Recipe: "3"}), - } { - if other == k { - t.Errorf("two composers built from different inputs share the key %s", k) - } - } -} diff --git a/go/delivery/stepasks_test.go b/go/delivery/stepasks_test.go deleted file mode 100644 index bcdcc41..0000000 --- a/go/delivery/stepasks_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package delivery - -import ( - "encoding/json" - "errors" - "reflect" - "strings" - "testing" - "time" -) - -const askCommit = "0123456789abcdef0123456789abcdef01234567" - -func buildAsk() StepAsk { - return StepAsk{ID: "mh3k2x9qa1b2c3", Kind: AskDelivery, Language: AskGo, Asker: "mesh-delivery-builder", - At: time.Date(2026, 10, 23, 13, 0, 0, 0, time.UTC), - Build: &BuildJob{Module: "mesh-delivery-view", Path: "modules/mesh-delivery-view", FP: "ab12", - Fetch: map[string]any{"repository": "novox/mesh-delivery", "commit": askCommit, "dir": "modules/mesh-delivery-view", "paths": []any{"cmd"}}, - Artifacts: []AskArtifact{{Artifact: "bundle", Seat: "oci-artifact-registry", Version: "0.1.4"}, - {Artifact: "base", From: "registry/x@sha256:00"}}, - Publish: true, Toolchain: &ToolchainUsed{Version: "go1.26.0", Recipe: "3"}}, - Packages: PackageVersions{"git.novox.be/novox/mesh-sdk/go": askCommit}} -} - -func checkAsk() StepAsk { - return StepAsk{ID: "mh3k2x9qa1b2c4", Kind: AskCheck, Language: AskNode, Asker: "mesh-delivery-checker", - At: time.Date(2026, 10, 23, 13, 0, 0, 0, time.UTC), - Check: &CheckJob{Fetch: map[string]any{"repository": "novox/mesh-catalog", "commit": askCommit, "ref": "refs/pull/3/head", "base": askCommit}, - Levels: []string{LevelUnit, LevelIntegration}, Kept: true, Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Run: "", - NodeNames: []string{"anchor", "laptop"}}, - Packages: PackageVersions{"git.novox.be/novox/mesh-sdk/go": askCommit, "git.novox.be/novox/mesh-controller/modules/route-proxy/contract": askCommit, - "git.novox.be/novox/mesh-contracts/go": "v0.1.15-feat-x.2"}} // ADR 0607 §5: a group's tagged version -} - -// 1887/A1a: an ask encodes and decodes with every field; 1585/S4: the package versions too. -func TestAskRoundTrips(t *testing.T) { - for _, a := range []StepAsk{buildAsk(), checkAsk()} { - raw, err := EncodeStepAsk(a) - if err != nil { - t.Fatal(err) - } - got, err := DecodeStepAsk(raw) - if err != nil { - t.Fatal(err) - } - a.V = StepAskVersion - if !reflect.DeepEqual(got, a) { - t.Fatalf("round trip:\n got %+v\nwant %+v", got, a) - } - } -} - -// 1887/A1a: an ask with an unknown kind or language is refused. -func TestAskRefusesUnknownKindAndLanguage(t *testing.T) { - cases := map[string]func(*StepAsk){ - "kind": func(a *StepAsk) { a.Kind = "build" }, - "language": func(a *StepAsk) { a.Language = "typescript" }, - "id": func(a *StepAsk) { a.ID = "postgres" }, - "neither": func(a *StepAsk) { a.Build = nil }, - "both": func(a *StepAsk) { a.Check = &CheckJob{Levels: []string{LevelUnit}, Fetch: a.Build.Fetch} }, - "recheck": func(a *StepAsk) { a.Kind = AskRecheck }, - "commit": func(a *StepAsk) { a.Build.Fetch = map[string]any{"repository": "novox/x", "commit": "main"} }, - "path": func(a *StepAsk) { a.Build.Path = "" }, - "needs": func(a *StepAsk) { a.Needs = []string{NeedNodeBuildNode} }, // issue 890: a build needs no node - // Issue 1585, ADR 0607 §5: a package's version is a full commit or a Go semantic version, of a module path. - "version": func(a *StepAsk) { a.Packages["git.novox.be/novox/mesh-sdk/go"] = "main" }, - "no v": func(a *StepAsk) { a.Packages["git.novox.be/novox/mesh-sdk/go"] = "0.1.15-feat-x.2" }, - "pre-chars": func(a *StepAsk) { a.Packages["git.novox.be/novox/mesh-sdk/go"] = "v0.1.15-feat/x.2" }, - "package": func(a *StepAsk) { a.Packages["git.novox.be/novox/x y"] = askCommit }, - "compose": func(a *StepAsk) { - a.Kind, a.Build = AskCheck, nil - a.Compose = &ComposeJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": askCommit}, Dir: "go", Package: "x", Request: json.RawMessage(`{}`)} - }, - } - for name, change := range cases { - a := buildAsk() - change(&a) - if _, err := EncodeStepAsk(a); err == nil { - t.Errorf("%s: encoded", name) - } - } - // ADR 0567 §12: a check's node names are each a node name, once. - for _, names := range [][]string{{"anchor", "anchor"}, {"a.b"}, {""}, {"a,b"}} { - c := checkAsk() - c.Check.NodeNames = names - if _, err := EncodeStepAsk(c); err == nil { - t.Errorf("the node names %q: encoded", names) - } - } - // An artifact is published through a registry seat, and a version names the seat it is published through - // (design 51). - for _, art := range []AskArtifact{{Artifact: "bundle", Seat: "mesh-artifact-store"}, {Artifact: "bundle", Version: "0.1.4"}} { - a := buildAsk() - a.Build.Artifacts = []AskArtifact{art} - if _, err := EncodeStepAsk(a); err == nil { - t.Errorf("the artifact %+v: encoded", art) - } - } - // A copy alone fetches nothing and needs no path. - a := buildAsk() - a.Build.Fetch, a.Build.Path, a.Build.Artifacts = nil, "", []AskArtifact{{Artifact: "base", From: "registry/x@sha256:00"}} - if _, err := EncodeStepAsk(a); err != nil { - t.Errorf("a copy alone: %v", err) - } - c := checkAsk() - c.Kind = AskDelivery - if _, err := EncodeStepAsk(c); err == nil { - t.Error("a check on the delivery stream encoded") - } - c = checkAsk() - c.Language = AskImage - if _, err := EncodeStepAsk(c); err == nil { - t.Error("a check of an image encoded") - } - // Issue 890: a check names a known need and round-trips it; an unknown need is refused. - c = checkAsk() - c.Check.Packages = []string{"a b"} - if _, err := EncodeStepAsk(c); err == nil { - t.Error("a package that is no import path encoded") - } - c.Check.Packages = []string{"x/pkg/a"} - c.Needs = []string{"gpu"} - if _, err := EncodeStepAsk(c); err == nil { - t.Error("an unknown need encoded") - } - c.Needs = []string{NeedNodeBuildNode} - if raw, err := EncodeStepAsk(c); err != nil { - t.Errorf("a check needing %s: %v", NeedNodeBuildNode, err) - } else if back, err := DecodeStepAsk(raw); err != nil || len(back.Needs) != 1 || back.Needs[0] != NeedNodeBuildNode { - t.Errorf("the need did not round-trip: %+v %v", back.Needs, err) - } - raw, _ := EncodeStepAsk(buildAsk()) - for _, bad := range []string{ - strings.Replace(string(raw), `"kind":"delivery"`, `"kind":"queue"`, 1), - strings.Replace(string(raw), `"language":"go"`, `"language":"rust"`, 1), - } { - if _, err := DecodeStepAsk([]byte(bad)); err == nil { - t.Errorf("decoded %s", bad) - } - } - if _, err := DecodeStepAsk([]byte(strings.Replace(string(raw), `"v":1`, `"v":2`, 1))); !errors.Is(err, ErrUnknownVersion) { - t.Errorf("version 2: %v", err) - } -} - -// 1887/A1a: the subjects, stream and workers are the seat's, as the broker derives them. -func TestAskSubjects(t *testing.T) { - s, err := AskSubject(AskGo, AskCheck) - if err != nil || s != "mesh.seat.node-build-go.accept.check" { - t.Fatalf("AskSubject = %q, %v", s, err) - } - language, kind, err := ParseAskSubject("mesh.seat.node-build-image.accept.delivery") - if err != nil || language != AskImage || kind != AskDelivery { - t.Fatalf("ParseAskSubject = %q %q %v", language, kind, err) - } - for _, bad := range []string{"mesh.seat.node-build-agent.accept.build", "mesh.seat.node-build-go.accept.queue", "mesh.seat.node-build-go.event.x"} { - if _, _, err := ParseAskSubject(bad); err == nil { - t.Errorf("parsed %s", bad) - } - } - if st, _ := AskStream(AskNode); st != "SEAT_NODE_BUILD_NODE" { - t.Errorf("AskStream = %q", st) - } - if w, _ := AskWorker(AskGo, AskRecheck); w != "SEAT_NODE_BUILD_GO_RECHECK_worker" { - t.Errorf("AskWorker = %q", w) - } - if seat, _ := BuildSeat(AskImage); seat != "node-build-image" { - t.Errorf("BuildSeat = %q", seat) - } - if m, _ := BuildModuleOf(AskNode); m != "mesh-build-node" { - t.Errorf("BuildModuleOf = %q", m) - } - if _, err := BuildSeat("agent"); err == nil { - t.Error("BuildSeat(agent)") - } -} - -// 1887/A4b: a building key names the asks put for it; a done key or a name that is no ask id is refused. -func TestABuildingKeyNamesItsAsks(t *testing.T) { - b := BuilderBuild{State: BuildBuilding, By: "novox.builder", Asks: map[string]string{AskGo: "mh3k2x9qa1b2c3"}} - raw, err := EncodeBuilderBuild(b) - if err != nil { - t.Fatal(err) - } - got, err := DecodeBuilderBuild(raw) - if err != nil || len(got.Asks) != 1 || got.Asks[AskGo] != "mh3k2x9qa1b2c3" { - t.Fatalf("decoded %+v, %v", got, err) - } - for _, bad := range []BuilderBuild{ - {State: BuildBuilding, Asks: map[string]string{AskGo: "postgres"}}, - {State: BuildBuilding, Asks: map[string]string{"typescript": "mh3k2x9qa1b2c3"}}, - {State: BuildFailed, Error: "x", Asks: map[string]string{AskGo: "mh3k2x9qa1b2c3"}}, - } { - if _, err := EncodeBuilderBuild(bad); err == nil { - t.Errorf("encoded %+v", bad) - } - } -} - -// 1930/AC1: a level answer stored before issue 1930, its digest's packages a map keyed by package path, and the digest -// tool's own answer, read as the list sorted by package; the list form reads back the same and keys no field by a path. -func TestALevelDigestReadsTheMapAndTheList(t *testing.T) { - var old LevelAnswer - if err := json.Unmarshal([]byte(`{"level":"unit","digest":{"level":"unit","packages":{"x/pkg/a":"d-a","x/internal/token":"d-t"},"beside":["novox/y abc"],"judged_by":"installed"}}`), &old); err != nil { - t.Fatal(err) - } - want := []PackageDigest{{Package: "x/internal/token", Digest: "d-t"}, {Package: "x/pkg/a", Digest: "d-a"}} - if old.Digest == nil || !reflect.DeepEqual(old.Digest.Packages, want) || old.Digest.JudgedBy != "installed" || len(old.Digest.Beside) != 1 { - t.Fatalf("the old form read %+v", old.Digest) - } - raw, err := json.Marshal(old) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(raw), `"x/internal/token":`) { - t.Errorf("the list form keys a field by a package path: %s", raw) - } - var back LevelAnswer - if err := json.Unmarshal(raw, &back); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(back.Digest, old.Digest) || back.Digest.Map()["x/internal/token"] != "d-t" { - t.Errorf("the list form read back %+v, want %+v", back.Digest, old.Digest) - } - var none LevelAnswer - if err := json.Unmarshal([]byte(`{"level":"unit"}`), &none); err != nil || none.Digest != nil || none.Digest.Map() != nil { - t.Errorf("a level without a digest read %+v, %v", none.Digest, err) - } -} diff --git a/go/delivery/steps.go b/go/delivery/steps.go deleted file mode 100644 index 61195d3..0000000 --- a/go/delivery/steps.go +++ /dev/null @@ -1,128 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "regexp" - "strings" - "time" -) - -// StepsState is the name of the state a build module keeps its long steps in; its bucket is `_steps`, built -// by StepsBucket (novox/hq ADR 0469 clause 2; issue 1471). A tool call is cut at 30 s, so a fetch, a test level or a -// build answers at once with a step id and writes its progress and its end to the key `.`. -const StepsState = "steps" - -// StepsBucket is the bucket of a build module's steps. -func StepsBucket(module string) string { return module + "_" + StepsState } - -// The states of a step. A step ends in passed, failed or refused; its key is written for each of them, and that -// write is the event the step's reader waits on. -const ( - StepRunning = "running" - StepPassed = "passed" - StepFailed = "failed" - StepRefused = "refused" -) - -var stepIDOK = regexp.MustCompile(`^[a-z0-9]+$`) - -func checkStepID(id string) error { - if !stepIDOK.MatchString(id) { - return fmt.Errorf("delivery: the step id %q is not lower-case letters and digits", id) - } - return nil -} - -// StepKey is the key of a node's step: `.`. It refuses a node that is not a name and an id that is not -// lower-case letters and digits (one key token, never a dot). -func StepKey(node, id string) (string, error) { - if err := checkName("node", node); err != nil { - return "", err - } - if err := checkStepID(id); err != nil { - return "", err - } - return node + "." + id, nil -} - -// ParseStepKey is the inverse of StepKey. -func ParseStepKey(key string) (node, id string, err error) { - node, id, ok := strings.Cut(key, ".") - if !ok { - return "", "", fmt.Errorf("delivery: %q is not a step key, .", key) - } - if _, err := StepKey(node, id); err != nil { - return "", "", fmt.Errorf("delivery: %q is not a step key: %w", key, err) - } - return node, id, nil -} - -// Step is the value of a steps key: which tool runs on which node, how far it is, when it started and ended (UTC, as -// the toolchains' At), why it was refused or failed, and what it returned. -type Step struct { - V int `json:"v"` - ID string `json:"id"` - Tool string `json:"tool"` - Node string `json:"node"` - State string `json:"state"` - Started time.Time `json:"started"` - Ended time.Time `json:"ended,omitzero"` - Reason string `json:"reason,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - // What says what the step is, for a reader of the steps state: an ask's kind, repository, head and levels or - // module (issue 1890). Empty for a step a tool started by hand. - What string `json:"what,omitempty"` - // Phase is the part of its job a running ask's step is in (fetch, a level's digest, lint or tests), and PhaseAt - // when it entered it: the step key is written again at each phase, so a slow step says where it stands (issue 1890). - // A reader waiting for the end treats such a write as any running one. - Phase string `json:"phase,omitempty"` - PhaseAt time.Time `json:"phase-at,omitzero"` - // Owner is the process that runs the step: its node's boot id, its pid and its start (buildstep.Owner), written when - // the step begins and kept by every later write of it. A build module's start sweep ends at once a running step whose - // owner is not itself: that process is gone (issue 1890, note 21). Empty in a key an older build wrote. - Owner string `json:"owner,omitempty"` -} - -func (s Step) check() error { - if err := checkStepID(s.ID); err != nil { - return err - } - if err := checkName("node", s.Node); err != nil { - return err - } - if s.Tool == "" { - return fmt.Errorf("delivery: a step needs its tool") - } - switch s.State { - case StepRunning, StepPassed, StepFailed, StepRefused: - default: - return fmt.Errorf("delivery: the step state %q is not running, passed, failed or refused", s.State) - } - if len(s.Result) > 0 && !json.Valid(s.Result) { - return fmt.Errorf("delivery: the step's result is not JSON") - } - return nil -} - -// EncodeStep is s as JSON at Version; it refuses an id that is not one key token, a node that is not a name, an empty -// tool, a state outside the four and a result that is not JSON. -func EncodeStep(s Step) ([]byte, error) { - if err := s.check(); err != nil { - return nil, err - } - s.V = Version - return json.Marshal(s) -} - -// DecodeStep reads a steps value; it refuses an unknown version (ErrUnknownVersion) and what EncodeStep refuses. -func DecodeStep(data []byte) (Step, error) { - var s Step - if err := decode(data, &s); err != nil { - return Step{}, err - } - if err := s.check(); err != nil { - return Step{}, err - } - return s, nil -} diff --git a/go/delivery/steps_test.go b/go/delivery/steps_test.go deleted file mode 100644 index b9dbb7f..0000000 --- a/go/delivery/steps_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package delivery - -import ( - "errors" - "strings" - "testing" - "time" -) - -// 1471/AC4: an encoded step with V 99 is refused with ErrUnknownVersion; so is a state 'done'. -func TestDecodeStepRefusesAnUnknownVersionAndAState(t *testing.T) { - good, err := EncodeStep(Step{ID: "k3j2", Tool: "fetch", Node: "ace", State: StepRunning, Started: time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)}) - if err != nil { - t.Fatal(err) - } - if _, err := DecodeStep(good); err != nil { - t.Fatalf("a good step: %v", err) - } - v99 := strings.Replace(string(good), `"v":1`, `"v":99`, 1) - if _, err := DecodeStep([]byte(v99)); !errors.Is(err, ErrUnknownVersion) { - t.Errorf("version 99: %v, want ErrUnknownVersion", err) - } - done := strings.Replace(string(good), `"running"`, `"done"`, 1) - if _, err := DecodeStep([]byte(done)); err == nil || errors.Is(err, ErrUnknownVersion) { - t.Errorf("state done: %v, want a refusal of the state", err) - } - if _, err := EncodeStep(Step{ID: "a", Tool: "fetch", Node: "ace", State: "done"}); err == nil { - t.Error("EncodeStep wrote the state done") - } -} - -func TestStepKeyIsNodeDotIDAndRefusesWhatIsNotOneToken(t *testing.T) { - key, err := StepKey("ace", "k3j2") - if err != nil || key != "ace.k3j2" { - t.Fatalf("StepKey = %q, %v", key, err) - } - node, id, err := ParseStepKey(key) - if err != nil || node != "ace" || id != "k3j2" { - t.Errorf("ParseStepKey = %q, %q, %v", node, id, err) - } - for _, id := range []string{"", "a.b", "A", "a-b", "a b", "*"} { - if _, err := StepKey("ace", id); err == nil { - t.Errorf("StepKey accepted the id %q", id) - } - } - for _, key := range []string{"ace", "ace.", "ace.a.b"} { - if _, _, err := ParseStepKey(key); err == nil { - t.Errorf("ParseStepKey accepted %q", key) - } - } - if StepsBucket("mesh-build-go") != "mesh-build-go_steps" { - t.Errorf("bucket = %q", StepsBucket("mesh-build-go")) - } -} - -func TestStepEncodingKeepsItsFieldsAndOmitsAnOpenEnd(t *testing.T) { - at := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) - raw, _ := EncodeStep(Step{ID: "a1", Tool: "fetch", Node: "ace", State: StepRunning, Started: at}) - want := `{"v":1,"id":"a1","tool":"fetch","node":"ace","state":"running","started":"2026-10-20T12:00:00Z"}` - if string(raw) != want { - t.Errorf("running = %s", raw) - } - raw, _ = EncodeStep(Step{ID: "a1", Tool: "fetch", Node: "ace", State: StepPassed, Started: at, Ended: at, Result: []byte(`{"sha":"x"}`)}) - got, err := DecodeStep(raw) - if err != nil || got.State != StepPassed || string(got.Result) != `{"sha":"x"}` || !got.Ended.Equal(at) { - t.Errorf("passed = %+v, %v", got, err) - } - if _, err := EncodeStep(Step{ID: "a1", Tool: "fetch", Node: "ace", State: StepPassed, Result: []byte(`{`)}); err == nil { - t.Error("a result that is not JSON was written") - } -} diff --git a/go/delivery/stuck.go b/go/delivery/stuck.go deleted file mode 100644 index 2de3c46..0000000 --- a/go/delivery/stuck.go +++ /dev/null @@ -1,65 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "time" -) - -// WatchmanModule is the watchman's module: its buckets are its own state, `_`, and the controller's -// broker grants it alone the bus's connect and disconnect events (novox/hq ADR 0431). -const WatchmanModule = "mesh-delivery-watchman" - -// StuckBucket holds what stopped moving (novox/hq ADR 0433 §2): one key per key whose pulse expired, written by the -// watchman, read by the controller's self-check, which raises each as a condition until it says moved. No key of it -// is removed: a key that moves again is written as moved. -const StuckBucket = WatchmanModule + "_stuck" - -// StuckKey is the key, in StuckBucket, of the key that stopped moving: `.`, formed as PulseKey forms a -// pulse's, so a node or part gone offline is `mesh-delivery-watchman_presence.`. -func StuckKey(bucket, key string) (string, error) { - stuck, err := PulseKey(bucket, key) - if err != nil { - return "", fmt.Errorf("delivery: no stuck key: %w", err) - } - return stuck, nil -} - -// ParseStuckKey is the inverse of StuckKey, splitting at the first dot. -func ParseStuckKey(stuckKey string) (bucket, key string, err error) { - bucket, key, err = ParsePulseKey(stuckKey) - if err != nil { - return "", "", fmt.Errorf("delivery: %q is not a stuck key: %w", stuckKey, err) - } - return bucket, key, nil -} - -// Stuck is the value of a stuck key: the key that stopped moving, what it waited for in words, since when, and -// whether it has moved again since. The watchman writes it; the controller reads it. -type Stuck struct { - V int `json:"v"` - Bucket string `json:"bucket"` - Key string `json:"key"` - Waited string `json:"waited,omitempty"` - Since time.Time `json:"since"` - Moved bool `json:"moved,omitempty"` -} - -// EncodeStuck is s as JSON at Version. -func EncodeStuck(s Stuck) ([]byte, error) { - s.V = Version - return json.Marshal(s) -} - -// DecodeStuck reads a stuck value; it refuses an unknown version (ErrUnknownVersion). -func DecodeStuck(data []byte) (Stuck, error) { - var s Stuck - err := decode(data, &s) - return s, err -} - -// SameStuck says whether two stuck values are the same, so a writer writes only when they differ. -func SameStuck(a, b Stuck) bool { - return a.V == b.V && a.Bucket == b.Bucket && a.Key == b.Key && a.Waited == b.Waited && a.Since.Equal(b.Since) && - a.Moved == b.Moved -} diff --git a/go/delivery/stuck_test.go b/go/delivery/stuck_test.go deleted file mode 100644 index d204f19..0000000 --- a/go/delivery/stuck_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package delivery - -import ( - "encoding/json" - "errors" - "strings" - "testing" - "time" -) - -// 1153/AC1: the stuck key is the stopped key's bucket and key, split at the first dot, refused as a pulse key is. -// 1153/AC4: the watchman's module and its three buckets, each that module's state. -func TestStuckKey(t *testing.T) { - if WatchmanModule != "mesh-delivery-watchman" { - t.Fatalf("WatchmanModule = %q", WatchmanModule) - } - for name, bucket := range map[string]string{"presence": PresenceBucket, "pulse": PulseBucket, "stuck": StuckBucket} { - if bucket != WatchmanModule+"_"+name { - t.Errorf("the %s bucket is %q, not %s_%s", name, bucket, WatchmanModule, name) - } - } - key, err := StuckKey(PresenceBucket, "ace") - if err != nil || key != "mesh-delivery-watchman_presence.ace" { - t.Fatalf("StuckKey = %q, %v", key, err) - } - key, err = StuckKey("mesh-controller_builds", "zsh.build.b-1") - if err != nil || key != "mesh-controller_builds.zsh.build.b-1" { - t.Fatalf("StuckKey = %q, %v", key, err) - } - bucket, rest, err := ParseStuckKey(key) - if err != nil || bucket != "mesh-controller_builds" || rest != "zsh.build.b-1" { - t.Fatalf("ParseStuckKey = %q %q %v", bucket, rest, err) - } - for _, c := range [][2]string{{"", "k"}, {"a.b", "k"}, {"b", ""}, {"b", "k x"}, {"b", ".k"}, {"b", "k."}, {"b", "k>"}} { - if _, err := StuckKey(c[0], c[1]); err == nil { - t.Errorf("StuckKey(%q, %q) accepted", c[0], c[1]) - } - } - for _, bad := range []string{"", "nodot", ".k", "b."} { - if _, _, err := ParseStuckKey(bad); err == nil { - t.Errorf("ParseStuckKey(%q) accepted", bad) - } - } -} - -// 1153/AC2: a stuck value round-trips with "v":1, and an unknown or missing version is refused loudly. -// 1153/AC3: equal stuck values are the same; one moved is not. -func TestStuckValue(t *testing.T) { - { - a := Stuck{V: Version, Bucket: "b", Key: "k", Waited: "w", Since: time.Unix(100, 0).UTC()} - b := a - if !SameStuck(a, b) { - t.Fatal("equal values are not the same") - } - b.Moved = true - if SameStuck(a, b) { - t.Fatal("a moved value is the same as a stuck one") - } - } - s := Stuck{Bucket: PresenceBucket, Key: "ace", Waited: "ace to come back online", - Since: time.Date(2026, 10, 18, 20, 0, 0, 0, time.UTC)} - raw, err := EncodeStuck(s) - if err != nil { - t.Fatal(err) - } - var probe map[string]any - if err := json.Unmarshal(raw, &probe); err != nil || probe["v"] != float64(1) { - t.Fatalf("encoded %s: v = %v (%v)", raw, probe["v"], err) - } - back, err := DecodeStuck(raw) - s.V = Version - if err != nil || !SameStuck(back, s) { - t.Fatalf("decoded %+v, %v; want %+v", back, err, s) - } - for _, bad := range []string{`{"v":2,"bucket":"b","key":"k"}`, `{"bucket":"b","key":"k"}`} { - _, err := DecodeStuck([]byte(bad)) - if !errors.Is(err, ErrUnknownVersion) { - t.Errorf("DecodeStuck(%s) = %v, want ErrUnknownVersion", bad, err) - } - if strings.Contains(bad, `"v":2`) && (err == nil || !strings.Contains(err.Error(), "2") || !strings.Contains(err.Error(), "1")) { - t.Errorf("DecodeStuck(%s) = %v: does not name the version seen and the one read", bad, err) - } - } -} diff --git a/go/delivery/testdata/build-record-build-agent.json b/go/delivery/testdata/build-record-build-agent.json deleted file mode 100644 index 0e756e0..0000000 --- a/go/delivery/testdata/build-record-build-agent.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"build-1792395447088936520","repository":"http://novox.internal:20000/novox/mesh-controller.git","module":"build-agent","commit":"7a74cee83f88e6585a5511c3cc53bdf0f5303d20","path":"modules/build-agent","manifest-digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","source-fingerprint":"src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7","source-inputs":{"Module":"build-agent","Tree":"11c0b4f90cae0d6e4ef9cc48cbfdf9f6dbe52bca","Bases":["sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc","sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c"],"Contexts":{"server":"narrow:d51ca744fd11af59d616c858f76c27b3e58e16e11fbdc0c720eb9d7d19d55fac"},"Toolchains":null,"Unpinned":""},"made":[{"name":"server","kind":"image","reference":"artifact-store://build-agent/server@sha256:a79c13f1c727ff94959e80824cc704ea58b80d80ecdd00fd447698acebb35090"},{"name":"files","kind":"archive","reference":"artifact-store://build-agent/files/blobs/sha256:bccd3f47851436d1b45a3063cc2d7e34c5c109b2d030739bfbb4b9781703f9a9"}],"at":"2026-10-19T09:38:00Z"} diff --git a/go/delivery/testdata/declaration-ace.json.gz b/go/delivery/testdata/declaration-ace.json.gz deleted file mode 100644 index 11f4a41..0000000 Binary files a/go/delivery/testdata/declaration-ace.json.gz and /dev/null differ diff --git a/go/delivery/testdata/declaration-g14.json.gz b/go/delivery/testdata/declaration-g14.json.gz deleted file mode 100644 index 1629385..0000000 Binary files a/go/delivery/testdata/declaration-g14.json.gz and /dev/null differ diff --git a/go/delivery/testdata/manifest-mailu.json b/go/delivery/testdata/manifest-mailu.json deleted file mode 100644 index 568be14..0000000 --- a/go/delivery/testdata/manifest-mailu.json +++ /dev/null @@ -1,772 +0,0 @@ -{ - "module": "mailu", - "version": "1", - "upgrade": { - "policy": "record", - "why": "people's mail: any change to how its containers are declared recreates them together in one send, and the mail is down for everyone until they are up again — a person chooses the moment (hq ADR 0242, issue 295)" - }, - "unhealthy": { - "severity": "urgent", - "why": "while mailu is broken, mail is refused or stuck for everyone with a mailbox here (hq ADR 0355, issue 687)" - }, - "capabilities": [ - "container-runtime" - ], - "requires": [ - "postgres-database", - "route", - "secret" - ], - "contributes": { - "postgres-database": { - "name": "mailu" - }, - "route": { - "web": { - "label": "mail", - "endpoint": "web-tls", - "scheme": "https", - "insecure": true - }, - "acme": { - "label": "mail", - "path": "/.well-known/acme-challenge", - "endpoint": "web", - "priority": 100 - }, - "autoconfig": { - "label": "autoconfig", - "endpoint": "autoconfig" - }, - "autodiscover": { - "label": "autodiscover", - "endpoint": "autoconfig" - }, - "automx": { - "label": "automx", - "endpoint": "autoconfig" - } - } - }, - "binds": { - "postgres-database": "${dir:state}/database.json", - "route": "${dir:state}/route.json" - }, - "secrets": { - "postgres-database": "${dir:state}/database.secret", - "secret": { - "secret-key": "${dir:state}/secret-key.secret", - "admin": "${dir:state}/admin.secret", - "api-token": "${dir:state}/api-token.secret" - } - }, - "tools": [ - "mailu_list_users", - "mailu_create_user", - "mailu_change_password", - "mailu_delete_user", - "mailu_list_aliases", - "mailu_create_alias", - "mailu_delete_alias", - "mailu_list_domains", - "mailu_read_mail", - "mailu_search_mail", - "mailu_health" - ], - "emits": [ - "user.created", - "user.deleted", - "alias.created", - "alias.deleted" - ], - "listens": [ - { - "name": "smtp", - "port": 25, - "protocol": "tcp", - "from": "anywhere", - "why": "mail from other mail servers", - "fixed": true - }, - { - "name": "pop3", - "port": 110, - "protocol": "tcp", - "from": "anywhere", - "why": "POP3, kept at parity with the predecessor; pruning legacy protocols is its own deliberate change", - "fixed": true - }, - { - "name": "imap", - "port": 143, - "protocol": "tcp", - "from": "anywhere", - "why": "IMAP with STARTTLS, kept at parity", - "fixed": true - }, - { - "name": "smtps", - "port": 465, - "protocol": "tcp", - "from": "anywhere", - "why": "submission over TLS", - "fixed": true - }, - { - "name": "submission", - "port": 587, - "protocol": "tcp", - "from": "anywhere", - "why": "submission; also what the smtp provision serves consumers", - "fixed": true - }, - { - "name": "imaps", - "port": 993, - "protocol": "tcp", - "from": "anywhere", - "why": "IMAP over TLS", - "fixed": true - }, - { - "name": "pop3s", - "port": 995, - "protocol": "tcp", - "from": "anywhere", - "why": "POP3 over TLS, kept at parity", - "fixed": true - }, - { - "name": "web", - "port": 7080, - "protocol": "tcp", - "from": "mesh", - "why": "the web front over http; only the ACME HTTP-01 passthrough is routed here — everything else 301s to https and would loop a proxy" - }, - { - "name": "web-tls", - "port": 7443, - "protocol": "tcp", - "from": "mesh", - "why": "the web front over its own TLS (admin, webmail, API); its public name is a route grant reaching it here" - }, - { - "name": "autoconfig", - "port": 4243, - "protocol": "tcp", - "from": "mesh", - "why": "automx: mail client autoconfiguration; the autoconfig, autodiscover and automx names are route grants reaching it here" - }, - { - "name": "admin-api", - "port": 8080, - "protocol": "tcp", - "from": "machine", - "why": "the admin API, which this module's own code reaches on loopback from the node's runtime now that it runs outside the mailu network" - } - ], - "data": { - "own": [ - { - "id": "mail", - "path": "${dir:data-mail}", - "class": "valuable", - "why": "every mailbox" - }, - { - "id": "dkim", - "path": "${dir:data-dkim}", - "class": "valuable", - "why": "the domain's signing keys; a new one means a new DNS record" - }, - { - "id": "data", - "path": "${dir:data-data}", - "class": "valuable", - "why": "the server's own data" - }, - { - "id": "dav", - "path": "${dir:data-dav}", - "class": "valuable", - "why": "calendars and contacts" - }, - { - "id": "webmail", - "path": "${dir:data-webmail}", - "class": "valuable", - "why": "the webmail's own data: its users' settings and address books" - }, - { - "id": "queue", - "path": "${dir:data-mailqueue}", - "class": "valuable", - "why": "mail accepted and not yet delivered, kept nowhere else" - }, - { - "id": "filter", - "path": "${dir:data-filter}", - "class": "rebuildable", - "why": "the spam filter's learned statistics; it learns again" - }, - { - "id": "certs", - "path": "${dir:data-certs}", - "class": "rebuildable", - "why": "certificates, issued again" - }, - { - "id": "automx", - "path": "${dir:data-automx}", - "class": "rebuildable", - "why": "client autoconfiguration, written again" - }, - { - "id": "fetchmail", - "path": "${dir:data-fetchmail}", - "class": "rebuildable", - "why": "which remote messages were fetched; lost, some are fetched twice" - }, - { - "id": "clamav", - "path": "${dir:data-clamav}", - "class": "cache", - "why": "virus signatures, downloaded again" - }, - { - "id": "redis", - "path": "${dir:data-redis}", - "class": "cache", - "why": "the filter's working set" - } - ], - "consumers": { - "smtp": { - "class": "valuable", - "in": "mail", - "why": "a consumer's mailbox holds the only copy of its mail" - } - } - }, - "config": { - "domain": { - "type": "string", - "description": "The mail domain Mailu serves, such as example.org: its first mail domain, the postmaster's and the one consumers send as; the operator's, with no default (novox/hq ADR 0112).", - "trusted": true, - "required": true - }, - "proxy-address": { - "type": "string", - "description": "The address of the reverse proxy in front of Mailu, whose X-Real-IP header Mailu believes; the operator's, with no default.", - "trusted": true, - "required": true - }, - "sitename": { - "type": "string", - "description": "The name Mailu's web pages show for the mail service; the operator's, with no default.", - "trusted": true, - "required": true - }, - "website": { - "type": "string", - "description": "The address of the website Mailu's web pages link to, as a URL; the operator's, with no default.", - "trusted": true, - "required": true - }, - "url": { - "type": "string", - "description": "The address the module's tools reach Mailu's API at, as a URL; empty leaves them to MESH_MAILU_URL.", - "trusted": true, - "default": "" - }, - "container": { - "type": "string", - "description": "The name of the container holding Mailu's mailboxes, which the module's tools read mail in; empty is mailu-imap.", - "trusted": true, - "default": "" - } - }, - "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh", - "unknown": "program" - }, - { - "id": "state", - "type": "directory", - "mode": "0700", - "place": ".", - "unknown": "program" - }, - { - "id": "grants", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "data-automx", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "config-env", - "type": "file", - "path": "${dir:state}/mailu.env", - "mode": "0644", - "template": "files/mailu.env" - }, - { - "id": "secret-env", - "type": "file", - "path": "${dir:state}/secret.env", - "mode": "0600", - "template": "files/secret.env" - }, - { - "id": "database-env", - "type": "file", - "path": "${dir:state}/database.env", - "mode": "0600", - "template": "files/database.env" - }, - { - "id": "admin-env", - "type": "file", - "path": "${dir:state}/admin.env", - "mode": "0600", - "template": "files/admin.env" - }, - { - "id": "data-certs", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "data-data", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "data-dkim", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "data-mail", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "data-mailqueue", - "type": "directory", - "mode": "0755", - "unknown": "program" - }, - { - "id": "data-filter", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "data-clamav", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "data-redis", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "data-webmail", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "data-dav", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "data-fetchmail", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "data-overrides-nginx", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "data-overrides-dovecot", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "data-overrides-postfix", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "data-overrides-rspamd", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "data-overrides-roundcube", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "net", - "type": "network", - "name": "mailu" - }, - { - "id": "resolver", - "type": "container", - "name": "mailu-resolver", - "image": "ghcr.io/mailu/unbound@sha256:2c037bc5c988b5c0ab7e1b6e6ffa762e14ba1e45c96281ac87f8b9be8eee8b6e", - "health": { - "kind": "runtime" - }, - "network": "mailu", - "env-file": [ - "${dir:state}/mailu.env", - "${dir:state}/secret.env" - ], - "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", - "ip": "192.168.203.254" - }, - { - "id": "redis", - "type": "container", - "name": "mailu-redis", - "image": "redis@sha256:4bed291aa5efb9f0d77b76ff7d4ab71eee410962965d052552db1fb80576431d", - "network": "mailu", - "volumes": [ - "${dir:data-redis}:/data" - ] - }, - { - "id": "admin", - "type": "container", - "name": "mailu-admin", - "image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a", - "health": { - "kind": "runtime" - }, - "network": "mailu", - "ports": [ - "8080" - ], - "env-file": [ - "${dir:state}/mailu.env", - "${dir:state}/secret.env", - "${dir:state}/database.env", - "${dir:state}/admin.env" - ], - "volumes": [ - "${dir:data-data}:/data", - "${dir:data-dkim}:/dkim" - ], - "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified" - }, - { - "id": "imap", - "type": "container", - "name": "mailu-imap", - "image": "ghcr.io/mailu/dovecot@sha256:7f0ed5db996fbdc00adc5c5e38a08492e04f7eb4a9fbd66a03aa9a28ddf23993", - "health": { - "kind": "runtime" - }, - "network": "mailu", - "env-file": [ - "${dir:state}/mailu.env" - ], - "volumes": [ - "${dir:data-mail}:/mail", - "${dir:data-overrides-dovecot}:/overrides:ro" - ], - "dns": [ - "192.168.203.254" - ] - }, - { - "id": "smtp", - "type": "container", - "name": "mailu-smtp", - "image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e", - "health": { - "kind": "tool", - "tool": "mailu_health", - "interval": "60s", - "timeout": "30s", - "looks": 3, - "grace": "120s" - }, - "network": "mailu", - "env-file": [ - "${dir:state}/mailu.env" - ], - "volumes": [ - "${dir:data-mailqueue}:/queue", - "${dir:data-overrides-postfix}:/overrides:ro" - ], - "dns": [ - "192.168.203.254" - ] - }, - { - "id": "antispam", - "type": "container", - "name": "mailu-antispam", - "image": "ghcr.io/mailu/rspamd@sha256:ff3666d8a61f17d309c5c6f6bcf4d40470b82299ca706ac650301175bb1a079d", - "health": { - "kind": "runtime" - }, - "network": "mailu", - "env-file": [ - "${dir:state}/mailu.env" - ], - "volumes": [ - "${dir:data-filter}:/var/lib/rspamd", - "${dir:data-overrides-rspamd}:/etc/rspamd/override.d:ro" - ], - "dns": [ - "192.168.203.254" - ] - }, - { - "id": "antivirus", - "type": "container", - "name": "mailu-antivirus", - "image": "clamav/clamav-debian@sha256:b12ef8fefddbba7d88de59bea8a32622f365339154adf02d38fd089112e6745a", - "network": "mailu", - "volumes": [ - "${dir:data-clamav}:/var/lib/clamav" - ], - "dns": [ - "192.168.203.254" - ] - }, - { - "id": "webmail", - "type": "container", - "name": "mailu-webmail", - "image": "ghcr.io/mailu/webmail@sha256:bdbee44cdb05a4658f0e3b62cc448de55ca8f8aea172279fda594826144c04f6", - "health": { - "kind": "runtime" - }, - "network": "mailu", - "env-file": [ - "${dir:state}/mailu.env", - "${dir:state}/secret.env" - ], - "volumes": [ - "${dir:data-webmail}:/data", - "${dir:data-overrides-roundcube}:/overrides:ro" - ], - "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", - "dns": [ - "192.168.203.254" - ] - }, - { - "id": "webdav", - "type": "container", - "name": "mailu-webdav", - "image": "ghcr.io/mailu/radicale@sha256:690ed6edf189dfef100a5a8b37c195ebf5d9241ac5f23f2f44b8b7b75726e3de", - "health": { - "kind": "runtime" - }, - "network": "mailu", - "env-file": [ - "${dir:state}/mailu.env", - "${dir:state}/secret.env" - ], - "volumes": [ - "${dir:data-dav}:/data" - ], - "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", - "dns": [ - "192.168.203.254" - ] - }, - { - "id": "fetchmail", - "type": "container", - "name": "mailu-fetchmail", - "image": "ghcr.io/mailu/fetchmail@sha256:f881c8412d3bbe73d638469b48321558d6403a9d45bfa043c1e52c752103d42d", - "health": { - "kind": "runtime" - }, - "network": "mailu", - "env-file": [ - "${dir:state}/mailu.env", - "${dir:state}/secret.env" - ], - "volumes": [ - "${dir:data-fetchmail}:/data" - ], - "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", - "dns": [ - "192.168.203.254" - ] - }, - { - "id": "front", - "type": "container", - "name": "mailu-front", - "image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d", - "health": { - "kind": "exec", - "command": "curl -m3 -skfLo /dev/null http://127.0.0.1:10204/health && kill -0 $(cat /run/dovecot/master.pid) && python3 -c \"import smtplib; s = smtplib.SMTP('127.0.0.1', 25, timeout=5); s.ehlo('health'); s.quit()\" && openssl x509 -checkend 1209600 -noout -in /certs/letsencrypt/live/mailu/nginx-chain.pem", - "interval": "60s", - "timeout": "15s", - "looks": 3, - "grace": "120s" - }, - "network": "mailu", - "env-file": [ - "${dir:state}/mailu.env" - ], - "ports": [ - "25", - "110", - "143", - "465", - "587", - "993", - "995", - "7080:80", - "7443:443" - ], - "volumes": [ - "${dir:data-certs}:/certs", - "${dir:data-overrides-nginx}:/overrides:ro" - ], - "dns": [ - "192.168.203.254" - ], - "logging": "journald" - }, - { - "id": "runtime-config", - "type": "file", - "path": "${dir:mesh-state}/config.json", - "mode": "0600", - "template": "files/config.json", - "takes-config": ["domain", "proxy-address", "sitename", "website", "url", "container"], - "merge": "json" - }, - { - "id": "automx", - "type": "container", - "name": "mailu-automx", - "artifact": "automx", - "network": "mailu", - "env-file": [ - "${dir:state}/mailu.env" - ], - "ports": [ - "4243" - ], - "volumes": [ - "${dir:data-automx}:/data" - ] - } - ], - "build": { - "on": [ - { - "arg": "PYTHON_BASE", - "image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228" - } - ], - "artifacts": [ - { - "name": "code", - "kind": "bundle", - "language": "typescript", - "entrypoints": [ - "index.js", - "tools/index.js", - "provisioner/index.js" - ], - "loads": [ - "index.js", - "tools/index.js", - "provisioner/index.js" - ], - "env": { - "MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1", - "MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret", - "MESH_MAILU_IMAP_CONTAINER": "mailu-imap", - "MESH_MAILU_SMTP_CONTAINER": "mailu-smtp", - "MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json", - "MESH_RECEIVES": "${dir:grants}/mesh.json" - } - }, - { - "name": "automx", - "kind": "image", - "from": "automx/Dockerfile" - }, - { - "name": "files", - "kind": "archive", - "from": "files" - } - ] - }, - "provides": [ - { - "name": "smtp", - "scope": "mesh", - "identity": { - "max": 64, - "in": "a mailbox's local part" - } - } - ], - "serves": { - "smtp": { - "port": 587, - "domain": "${config:domain}" - } - }, - "receives": { - "smtp": "${dir:grants}/mesh.json" - }, - "grants": { - "smtp": "${dir:grants}" - }, - "jails": [ - { - "name": "mailu-front", - "failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=(?:,|$)\n ^.*client login failed: \"AUTH not supported\" while in http auth state, client: , server: \\S+:25,", - "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d" - } - ] -} diff --git a/go/delivery/token.go b/go/delivery/token.go deleted file mode 100644 index 9cfcde9..0000000 --- a/go/delivery/token.go +++ /dev/null @@ -1,118 +0,0 @@ -package delivery - -import ( - "crypto/ed25519" - "encoding/base64" - "encoding/json" - "fmt" - "strings" -) - -// Token is what a person carries to a machine that is joining (novox/hq ADR 0004): where the bus is, what certificate -// to expect there, whose signature to believe afterwards, and a one-time right to join; and, when it was issued for the -// machine's tunnel key, the machine's first tunnel (ADR 0169). -// -// Moved here from the node-engine (mesh-host internal/identity, which keeps an alias) so the vault, which makes the -// token from the lease write on (novox/hq ADR 0587 §3, issue 774), mesh-cli, which opens and prints it, and the -// node-engine, which parses it, read one shape. The field names are the wire format the controller's `token issue` -// writes, unchanged, so a token of either issuer reads the same; the tunnel stays in it (the lead's settlement of -// ADR 0587 §3, 2026-11-02). -type Token struct { - Version int `json:"v"` - - // Node is what the mesh calls this machine: the bus user it dials as is named after it, so it must be known before - // the mesh can say anything. - Node string `json:"node,omitempty"` - - Broker string `json:"broker,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` - Signer []byte `json:"signer,omitempty"` - Secret string `json:"secret"` - - // Adopted says this node joins adopted (novox/hq ADR 0100); absent for a converged node. - Adopted bool `json:"adopted,omitempty"` - - // Tunnel is this machine's first tunnel, when the token was issued for the key it made (novox/hq ADR 0169): its own - // address and the hub to reach, so it reaches the bus over the private network and the bus never faces the internet. - Tunnel *TokenTunnel `json:"tunnel,omitempty"` -} - -// TokenTunnel is the joining machine's side of its first tunnel, in the field names the controller writes. -type TokenTunnel struct { - Key string `json:"key"` - Address string `json:"address"` - Range string `json:"range"` - HubKey string `json:"hub_key"` - HubEndpoint string `json:"hub_endpoint"` -} - -// ParseToken reads a token a person pasted. Every refusal says *this is not a token* rather than *this is the wrong -// token*: telling "not from the mesh I joined" apart from "malformed" is a signature check later (ADR 0004). A token -// missing a part is refused whole, naming every part, never used partially: without the fingerprint a node connects -// to whatever answers, without the signing key it cannot tell a declaration from a forgery. -func ParseToken(encoded string) (Token, error) { - raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded)) - if err != nil { - return Token{}, fmt.Errorf("this is not a token: %w", err) - } - var t Token - if err := json.Unmarshal(raw, &t); err != nil { - return Token{}, fmt.Errorf("this is not a token: %w", err) - } - if t.Version != 1 { - return Token{}, fmt.Errorf( - "this token says it is version %d, and this host understands version 1", t.Version) - } - if missing := t.Missing(); len(missing) > 0 { - return Token{}, fmt.Errorf( - "this token is missing %s, so it cannot be used to join anything", - strings.Join(missing, ", ")) - } - return t, nil -} - -// Missing names every part of t a joining machine needs and t lacks. -func (t Token) Missing() []string { - var missing []string - if strings.TrimSpace(t.Broker) == "" { - missing = append(missing, "the broker's address") - } - if strings.TrimSpace(t.Fingerprint) == "" { - missing = append(missing, "the broker certificate's fingerprint") - } - if len(t.Signer) != ed25519.PublicKeySize { - missing = append(missing, "the vault's signing key") - } - if strings.TrimSpace(t.Secret) == "" { - missing = append(missing, "the one-time secret") - } - if tt := t.Tunnel; tt != nil { - for _, part := range []struct{ value, says string }{ - {tt.Key, "the tunnel key it was issued for"}, {tt.Address, "this machine's address"}, - {tt.Range, "the private network's range"}, {tt.HubKey, "the hub's tunnel key"}, - {tt.HubEndpoint, "where the hub's tunnel is dialled"}, - } { - if strings.TrimSpace(part.value) == "" { - missing = append(missing, part.says) - } - } - } - return missing -} - -// Encode is t as one line a person carries: base64 (URL alphabet, no padding) of its JSON at version 1, as the -// controller's `token issue` writes it. Refused when a part is missing. -func (t Token) Encode() (string, error) { - t.Version = 1 - if missing := t.Missing(); len(missing) > 0 { - return "", fmt.Errorf("delivery: the token is missing %s", strings.Join(missing, ", ")) - } - raw, err := json.Marshal(t) - if err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(raw), nil -} - -// SignerKey is the signing key the token carries, as a key: the controller's before the lease write, the vault's after. -func (t Token) SignerKey() ed25519.PublicKey { return ed25519.PublicKey(t.Signer) } diff --git a/go/delivery/token_test.go b/go/delivery/token_test.go deleted file mode 100644 index 5e7d73d..0000000 --- a/go/delivery/token_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package delivery - -import ( - "encoding/hex" - "reflect" - "strings" - "testing" -) - -// controllerToken is a token made by the controller's own `token issue` code: mesh-control origin/main 5c281ad9, -// internal/token Token.Encode, run on 2026-11-02 for node anchor, adopted, with a first tunnel, its signer the ed25519 -// key of the seed 00 01 .. 1f. Copied, never written by hand. -const controllerToken = "eyJ2IjoxLCJub2RlIjoiYW5jaG9yIiwiYnJva2VyIjoiMTkyLjAuMi4xMDo0MjIyIiwiZmluZ2VycHJpbnQiOiJzaGEyNTY6YWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYiIsInNpZ25lciI6IkE2RUh2L1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmc9Iiwic2VjcmV0IjoiYS1vbmUtdGltZS1zZWNyZXQiLCJhZG9wdGVkIjp0cnVlLCJ0dW5uZWwiOnsia2V5IjoiZEdobExXMWhZMmhwYm1WekxXdGxlUT09IiwiYWRkcmVzcyI6IjEwLjAuMC43LzI0IiwicmFuZ2UiOiIxMC4wLjAuMC8yNCIsImh1Yl9rZXkiOiJkR2hsTFdoMVluTXRhMlY1IiwiaHViX2VuZHBvaW50IjoiMTk4LjUxLjEwMC4xOjUxODIwIn19" - -// 774/S36/AC1: a token the controller made reads, with the moved code, field for field as it was written; encoding it -// again gives the same line; a part missing is refused naming it; garbage and another version are not tokens. -func TestATokenTheControllerMadeReads(t *testing.T) { - signer, _ := hex.DecodeString("03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8") - want := Token{Version: 1, Node: "anchor", Broker: "192.0.2.10:4222", - Fingerprint: "sha256:" + strings.Repeat("ab", 32), Signer: signer, Secret: "a-one-time-secret", Adopted: true, - Tunnel: &TokenTunnel{Key: "dGhlLW1hY2hpbmVzLWtleQ==", Address: "10.0.0.7/24", Range: "10.0.0.0/24", - HubKey: "dGhlLWh1YnMta2V5", HubEndpoint: "198.51.100.1:51820"}} - got, err := ParseToken(" " + controllerToken + "\n") - if err != nil || !reflect.DeepEqual(got, want) || len(got.SignerKey()) != 32 { - t.Fatalf("parsed %+v, %v", got, err) - } - if again, err := got.Encode(); err != nil || again != controllerToken { - t.Errorf("encoded again: %q, %v", again, err) - } - for _, c := range []struct { - cut func(*Token) - expect string - }{ - {func(t *Token) { t.Broker = "" }, "broker's address"}, - {func(t *Token) { t.Fingerprint = "" }, "fingerprint"}, - {func(t *Token) { t.Signer = t.Signer[:5] }, "signing key"}, - {func(t *Token) { t.Secret = "" }, "one-time secret"}, - {func(t *Token) { t.Tunnel = &TokenTunnel{Key: "k"} }, "where the hub's tunnel is dialled"}, - } { - cut := want - cut.Tunnel = &TokenTunnel{} - *cut.Tunnel = *want.Tunnel - c.cut(&cut) - if _, err := cut.Encode(); err == nil || !strings.Contains(err.Error(), c.expect) { - t.Errorf("a token without %s: %v", c.expect, err) - } - } - for _, bad := range []string{"", "!!!not base64!!!", "aGVsbG8", "eyJ2Ijo5OX0"} { - if _, err := ParseToken(bad); err == nil { - t.Errorf("%q parsed as a token", bad) - } - } -} diff --git a/go/delivery/toolchains.go b/go/delivery/toolchains.go deleted file mode 100644 index ecaebe4..0000000 --- a/go/delivery/toolchains.go +++ /dev/null @@ -1,159 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "sort" - "strings" - "time" -) - -// ToolchainsState is the name of the state a build module keeps its toolchains in; its bucket is -// `_toolchains`, built as the SDK names a module's state, by ToolchainsBucket (novox/hq ADR 0465 clause 2). -const ToolchainsState = "toolchains" - -// ToolchainsBucket is the bucket of a build module's toolchains. -func ToolchainsBucket(module string) string { return module + "_" + ToolchainsState } - -// ToolchainNoReport is the reason AgreedToolchain gives when no node has reported a language's toolchain. -const ToolchainNoReport = "toolchain not yet reported" - -func checkToolchainLanguage(language string) error { - switch language { - case "go", "typescript": - return nil - } - return fmt.Errorf("delivery: the toolchain language %q is not go or typescript", language) -} - -// ToolchainKey is the key of a node's toolchain of one language: `.`. It refuses a node that is not a -// name and a language other than go or typescript. -func ToolchainKey(node, language string) (string, error) { - if err := checkName("node", node); err != nil { - return "", err - } - if err := checkToolchainLanguage(language); err != nil { - return "", err - } - return node + "." + language, nil -} - -// ParseToolchainKey is the inverse of ToolchainKey. -func ParseToolchainKey(key string) (node, language string, err error) { - node, language, ok := strings.Cut(key, ".") - if !ok { - return "", "", fmt.Errorf("delivery: %q is not a toolchain key, .", key) - } - if _, err := ToolchainKey(node, language); err != nil { - return "", "", fmt.Errorf("delivery: %q is not a toolchain key: %w", key, err) - } - return node, language, nil -} - -// Toolchain is the value of a toolchains key: the version of the language's toolchain installed on the node and the -// build module's recipe version, and when the module saw them. -type Toolchain struct { - V int `json:"v"` - Language string `json:"language"` - Version string `json:"version"` - Recipe string `json:"recipe"` - At time.Time `json:"at"` -} - -func (t Toolchain) check() error { - if err := checkToolchainLanguage(t.Language); err != nil { - return err - } - if t.Version == "" || t.Recipe == "" { - return fmt.Errorf("delivery: a %s toolchain needs its version and its recipe", t.Language) - } - return nil -} - -// EncodeToolchain is t as JSON at Version; it refuses a language other than go or typescript and an empty version or -// recipe. -func EncodeToolchain(t Toolchain) ([]byte, error) { - if err := t.check(); err != nil { - return nil, err - } - t.V = Version - return json.Marshal(t) -} - -// DecodeToolchain reads a toolchains value; it refuses an unknown version (ErrUnknownVersion) and what EncodeToolchain -// refuses. -func DecodeToolchain(data []byte) (Toolchain, error) { - var t Toolchain - if err := decode(data, &t); err != nil { - return Toolchain{}, err - } - if err := t.check(); err != nil { - return Toolchain{}, err - } - return t, nil -} - -// NodeToolchain is one node's entry of a language, as read from the toolchains bucket. -type NodeToolchain struct { - Node string - Toolchain -} - -// SittingOut is a node whose toolchain of a language is not the agreed one: it builds nothing of that language until -// it reports the agreed version (novox/hq ADR 0584 clause 2). -type SittingOut struct { - Node string - Version string - Recipe string -} - -// Agreement is what the nodes' entries of one language come to (novox/hq ADR 0584). Toolchain is the agreed one, set -// when Wait is empty. Wait is why nothing is agreed: "toolchain not yet reported" with no entries, or "builders -// disagree" naming each version and recipe with the nodes reporting it, when no version has a strict majority. -// SittingOut lists, sorted by node, the nodes that report another version than the agreed one. -type Agreement struct { - Toolchain Toolchain2 - SittingOut []SittingOut - Wait string -} - -// ToolchainAgreement is the agreed toolchain of a language: the version and recipe a strict majority of the -// reporting nodes report (novox/hq ADR 0584 clause 1). With no strict majority (two against two) or no entry it -// gives no toolchain and says why in Wait. The answer does not depend on the entries' order. The entries must all be -// of one language. -func ToolchainAgreement(entries []NodeToolchain) Agreement { - if len(entries) == 0 { - return Agreement{Wait: ToolchainNoReport} - } - groups := map[Toolchain2][]string{} - for _, e := range entries { - k := Toolchain2{Language: e.Language, Version: e.Version, Recipe: e.Recipe} - groups[k] = append(groups[k], e.Node) - } - for k, nodes := range groups { - if 2*len(nodes) > len(entries) { - a := Agreement{Toolchain: k} - for _, e := range entries { - if e.Version != k.Version || e.Recipe != k.Recipe { - a.SittingOut = append(a.SittingOut, SittingOut{Node: e.Node, Version: e.Version, Recipe: e.Recipe}) - } - } - sort.Slice(a.SittingOut, func(i, j int) bool { return a.SittingOut[i].Node < a.SittingOut[j].Node }) - return a - } - } - parts := make([]string, 0, len(groups)) - for k, nodes := range groups { - sort.Strings(nodes) - parts = append(parts, fmt.Sprintf("%s recipe %s on %s", k.Version, k.Recipe, strings.Join(nodes, ", "))) - } - sort.Strings(parts) - return Agreement{Wait: "builders disagree: " + strings.Join(parts, "; ")} -} - -// AgreedToolchain is ToolchainAgreement's toolchain, or none and why (its Wait): the version a strict majority of the -// nodes report (novox/hq ADR 0584, which changes ADR 0405 clause 2 and ADR 0477 clause 2). -func AgreedToolchain(entries []NodeToolchain) (tc Toolchain2, none string) { - a := ToolchainAgreement(entries) - return a.Toolchain, a.Wait -} diff --git a/go/delivery/toolchains_test.go b/go/delivery/toolchains_test.go deleted file mode 100644 index 36fa002..0000000 --- a/go/delivery/toolchains_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package delivery - -import ( - "strings" - "testing" - "time" -) - -func tcEntry(node, lang, version, recipe string) NodeToolchain { - return NodeToolchain{Node: node, Toolchain: Toolchain{Language: lang, Version: version, Recipe: recipe, At: time.Unix(100, 0).UTC()}} -} - -// 1443/AC1 -func TestToolchainKeyAndValue(t *testing.T) { - key, err := ToolchainKey("ace", "go") - if err != nil || key != "ace.go" { - t.Fatalf("%q %v", key, err) - } - node, lang, err := ParseToolchainKey(key) - if err != nil || node != "ace" || lang != "go" { - t.Errorf("%q %q %v", node, lang, err) - } - for _, bad := range []string{"", "ace", "ace.rust", ".go", "a.b.go", "ace."} { - if _, _, err := ParseToolchainKey(bad); err == nil { - t.Errorf("ParseToolchainKey(%q) passed", bad) - } - } - if _, err := ToolchainKey("ace", "python"); err == nil { - t.Error("language python keyed") - } - want := Toolchain{Language: "go", Version: "1.26.1", Recipe: "3", At: time.Unix(100, 0).UTC()} - data, err := EncodeToolchain(want) - if err != nil { - t.Fatal(err) - } - got, err := DecodeToolchain(data) - want.V = Version - if err != nil || got != want { - t.Errorf("%+v %v", got, err) - } - for name, bad := range map[string]Toolchain{ - "python": {Language: "python", Version: "3", Recipe: "1"}, - "no version": {Language: "go", Recipe: "1"}, - "no recipe": {Language: "typescript", Version: "5"}, - } { - if _, err := EncodeToolchain(bad); err == nil { - t.Errorf("%s was encoded", name) - } - if _, err := DecodeToolchain([]byte(`{"v":1,"language":"` + bad.Language + `","version":"` + bad.Version + `","recipe":"` + bad.Recipe + `"}`)); err == nil { - t.Errorf("%s was decoded", name) - } - } - if ToolchainsBucket("go-build") != "go-build_toolchains" { - t.Error(ToolchainsBucket("go-build")) - } -} - -// 1443/AC2-4, ADR 0584: the agreed toolchain is the strict majority's; a minority node sits out; a tie waits. -func TestToolchainAgreement(t *testing.T) { - e := tcEntry - for _, c := range []struct { - name string - in []NodeToolchain - version string - out []SittingOut - wait []string // "" wait means agreed - }{ - {"all agree", []NodeToolchain{e("ace", "go", "1.26.1", "3"), e("novox", "go", "1.26.1", "3")}, "1.26.1", nil, nil}, - {"three against one", []NodeToolchain{e("ace", "typescript", "26.12.1", "3"), e("g14", "typescript", "26.13.0", "3"), e("novox", "typescript", "26.13.0", "3"), e("shanks", "typescript", "26.13.0", "3")}, - "26.13.0", []SittingOut{{"ace", "26.12.1", "3"}}, nil}, - {"two against two", []NodeToolchain{e("ace", "go", "1.26.1", "3"), e("novox", "go", "1.26.2", "3"), e("g14", "go", "1.26.1", "3"), e("shanks", "go", "1.26.2", "3")}, - "", nil, []string{"builders disagree", "1.26.1 recipe 3 on ace, g14", "1.26.2 recipe 3 on novox, shanks"}}, - {"one node alone", []NodeToolchain{e("ace", "go", "1.26.1", "3")}, "1.26.1", nil, nil}, - {"no report", nil, "", nil, []string{ToolchainNoReport}}, - } { - a := ToolchainAgreement(c.in) - rev := make([]NodeToolchain, len(c.in)) - for i, x := range c.in { - rev[len(c.in)-1-i] = x - } - if b := ToolchainAgreement(rev); b.Wait != a.Wait || b.Toolchain != a.Toolchain { - t.Errorf("%s: order changed the answer: %+v vs %+v", c.name, a, b) - } - if a.Toolchain.Version != c.version || len(a.SittingOut) != len(c.out) { - t.Errorf("%s: %+v", c.name, a) - } - for i := range c.out { - if a.SittingOut[i] != c.out[i] { - t.Errorf("%s: sitting out %+v, want %+v", c.name, a.SittingOut, c.out) - } - } - if (len(c.wait) == 0) != (a.Wait == "") { - t.Errorf("%s: wait %q", c.name, a.Wait) - } - for _, w := range c.wait { - if !strings.Contains(a.Wait, w) { - t.Errorf("%s: %q does not name %q", c.name, a.Wait, w) - } - } - if tc, none := AgreedToolchain(c.in); tc != a.Toolchain || none != a.Wait { - t.Errorf("%s: AgreedToolchain %+v %q", c.name, tc, none) - } - } -} diff --git a/go/delivery/trunk.go b/go/delivery/trunk.go deleted file mode 100644 index 42d7971..0000000 --- a/go/delivery/trunk.go +++ /dev/null @@ -1,32 +0,0 @@ -package delivery - -import "strings" - -// RepositoryName is the repository as the forge names it, "owner/name", from either spelling the mesh holds: the name -// itself, or the clone URL a build record carries ("http://host:port/owner/name.git", with or without ".git" or a -// trailing slash). A string of fewer than two path segments is returned as it is, trimmed (issue 1281). -func RepositoryName(s string) string { - s = strings.TrimSpace(s) - s = strings.TrimRight(s, "/") - s = strings.TrimSuffix(s, ".git") - s = strings.TrimRight(s, "/") - parts := strings.FieldsFunc(s, func(r rune) bool { return r == '/' || r == ':' }) - if len(parts) < 2 { - return s - } - return parts[len(parts)-2] + "/" + parts[len(parts)-1] -} - -// SameRepository is whether two spellings name one repository: the same owner and name, whichever way each is written -// (RepositoryName), compared without regard to case as the forge does. An empty spelling names none (issue 1281). -func SameRepository(a, b string) bool { - a, b = RepositoryName(a), RepositoryName(b) - return a != "" && strings.EqualFold(a, b) -} - -// IsTrunkBuild is whether the record is a successful build of the trunk that recorded its source inputs: an outcome -// (the controller writes no state on one; state asked is a request, records.go:57-59), not failed, and ref "main" or none. A build a delivery plan asked after a merge, or rebuild or build --behind, follows the branch -// without naming it, so it carries no ref; a build of any other branch or of a commit names it (issue 1281). -func (r BuildRecord) IsTrunkBuild() bool { - return r.State == "" && r.Failed == "" && r.SourceInputs != nil && (r.Ref == "" || r.Ref == "main") -} diff --git a/go/delivery/trunk_test.go b/go/delivery/trunk_test.go deleted file mode 100644 index eaa8357..0000000 --- a/go/delivery/trunk_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package delivery - -import ( - "encoding/json" - "testing" -) - -func TestSameRepository(t *testing.T) { // 1281/AC1 - const forge = "novox/mesh-catalog" - for _, c := range []struct { - a, b string - same bool - }{ - // the clone URL as a build record carries it (mesh-controller.command builds, 2026-10-19 ~21:20 local) - {forge, "http://novox.internal:20000/novox/mesh-catalog.git", true}, - {forge, "http://novox.internal:20000/novox/mesh-catalog", true}, - {forge, "http://novox.internal:20000/novox/mesh-catalog.git/", true}, - {"http://novox.internal:20000/novox/mesh-catalog.git/", forge, true}, - {forge, "novox/mesh-catalog", true}, - {forge, "Novox/Mesh-Catalog", true}, - {forge, "http://novox.internal:20000/novox/mesh-catalog-x.git", false}, - {forge, "novox/mesh-catalog-x", false}, - {forge, "http://novox.internal:20000/other/mesh-catalog.git", false}, - {forge, "other/mesh-catalog", false}, - {forge, "http://novox.internal:20000/novox/mesh-media-catalog.git", false}, - {forge, "", false}, - {"", "", false}, - } { - if got := SameRepository(c.a, c.b); got != c.same { - t.Errorf("SameRepository(%q, %q) = %v, want %v", c.a, c.b, got, c.same) - } - } - if got := RepositoryName("http://novox.internal:20000/novox/mesh-catalog.git"); got != "novox/mesh-catalog" { - t.Errorf("RepositoryName = %q", got) - } -} - -func TestIsTrunkBuild(t *testing.T) { // 1281/AC2 - inputs := &SourceInputs{Module: "zsh", Tree: "t"} - for _, c := range []struct { - name string - r BuildRecord - want bool - }{ - {"built on main", BuildRecord{Ref: "main", SourceInputs: inputs}, true}, - // a build a delivery plan asked after a merge, or rebuild / build --behind, carries no ref (live builds, - // 2026-10-19: zsh build-1792417485811374131, mesh-delivery build-1792435539606203626) - {"built with no ref", BuildRecord{SourceInputs: inputs}, true}, - {"another branch", BuildRecord{Ref: "feat/x", SourceInputs: inputs}, false}, - {"a commit", BuildRecord{Ref: "9efdd87a", SourceInputs: inputs}, false}, - {"asked", BuildRecord{State: "asked", SourceInputs: inputs}, false}, - {"answered, state answered, no source inputs", BuildRecord{State: "answered"}, false}, - {"failed", BuildRecord{Failed: "boom", Ref: "main", SourceInputs: inputs}, false}, - {"no source inputs", BuildRecord{Ref: "main"}, false}, - {"no ref and no source inputs", BuildRecord{}, false}, - } { - if got := c.r.IsTrunkBuild(); got != c.want { - t.Errorf("%s: IsTrunkBuild = %v, want %v", c.name, got, c.want) - } - } - // decoded as the controller writes it: the key "ref" is absent - var r BuildRecord - raw := `{"id":"build-1792417485811374131","repository":"http://novox.internal:20000/novox/mesh-catalog.git","module":"zsh","source-inputs":{"Module":"zsh","Tree":"t"}}` - if err := json.Unmarshal([]byte(raw), &r); err != nil || !r.IsTrunkBuild() { - t.Errorf("a decoded record with no ref: trunk = %v, err %v", r.IsTrunkBuild(), err) - } -} diff --git a/go/delivery/turns.go b/go/delivery/turns.go deleted file mode 100644 index e9f0000..0000000 --- a/go/delivery/turns.go +++ /dev/null @@ -1,174 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "reflect" - "strings" - "time" -) - -// **The merge turns** (novox/hq ADR 0548 clauses 3 and 4). An approved merge holds a turn on each repository it goes into: -// before it reads the checker's verdicts for the merge, the gate writes one key per repository in its bucket turns, naming -// the deliveries that wait in the order of their Approve, the verdict key each waits for and since when. The gate is the -// key's one writer; the checker is granted to read the bucket, reads it absent as empty, and claims no check of a repository -// a turn names, except the check of the waiting delivery's own heads (TurnHolds). The key goes when the last delivery in it -// merged or left. -const ( - // TurnsBucket is the gate's bucket of merge turns. - TurnsBucket = GateModule + "_turns" - // TurnsHistory is the revisions the turns bucket keeps per key. - TurnsHistory = 5 -) - -// TurnsKey is the key, in TurnsBucket, of a repository's turn: `.`, lowercase, each part escaped (EscapeName), -// so the key names the repository the checker's verdicts name as a trunk whatever its case. -func TurnsKey(repository string) (string, error) { - repository = strings.ToLower(repository) - if _, err := escapeRepository(repository); err != nil { - return "", err - } - owner, name, _ := strings.Cut(repository, "/") - return EscapeName(owner) + "." + EscapeName(name), nil -} - -// ParseTurnsKey is the inverse of TurnsKey: the repository, lowercase. -func ParseTurnsKey(key string) (string, error) { - owner, name, ok := strings.Cut(key, ".") - if !ok || owner == "" || name == "" || strings.Contains(name, ".") { - return "", fmt.Errorf("delivery: %q is not a turns key, .", key) - } - o, err := UnescapeName(owner) - if err != nil { - return "", fmt.Errorf("delivery: %q is not a turns key: %w", key, err) - } - n, err := UnescapeName(name) - if err != nil { - return "", fmt.Errorf("delivery: %q is not a turns key: %w", key, err) - } - repository := o + "/" + n - if again, err := TurnsKey(repository); err != nil || again != key { - return "", fmt.Errorf("delivery: %q is not a turns key: it does not name a lowercase /", key) - } - return repository, nil -} - -// Turn is the value of a turns key: the repository and the approved deliveries that wait to merge into it, in the order of -// their Approve. A turn with no delivery is no value: its key is deleted. -type Turn struct { - V int `json:"v"` - Repository string `json:"repository"` - Waiting []TurnWait `json:"waiting"` -} - -// TurnWait is one approved delivery in a turn: its group (a lone pull request's branch), the hash of the heads the Approve -// covers (the checker's verdict key's hash, HeadsHash over its members), the Approve's ask key, when it was approved and since -// when it waits; and, while a check of another delivery runs on the repository, that check's verdict key, when the gate saw -// it start, and the step the checker says it is at. -type TurnWait struct { - Group string `json:"group"` - Heads string `json:"heads"` - Ask string `json:"ask,omitempty"` - Approved time.Time `json:"approved"` - Since time.Time `json:"since"` - // WaitsFor is the verdict key (`.`) of the running check the merge waits for; empty when none runs. - WaitsFor string `json:"waits-for,omitempty"` - WaitsSince time.Time `json:"waits-since,omitzero"` - Step string `json:"step,omitempty"` -} - -func (t Turn) check() error { - repository := strings.ToLower(t.Repository) - if repository != t.Repository { - return fmt.Errorf("delivery: the turn's repository %q is not lowercase", t.Repository) - } - if _, err := escapeRepository(repository); err != nil { - return err - } - if len(t.Waiting) == 0 { - return fmt.Errorf("delivery: the turn of %s names no delivery: its key is deleted instead", t.Repository) - } - seen := map[string]bool{} - for i, w := range t.Waiting { - if w.Group == "" { - return fmt.Errorf("delivery: a delivery in the turn of %s names no group", t.Repository) - } - if !isHex12(w.Heads) { - return fmt.Errorf("delivery: the heads hash %q in the turn of %s is not 12 lowercase hex digits", w.Heads, t.Repository) - } - if seen[w.Heads] { - return fmt.Errorf("delivery: the turn of %s names the heads %s twice", t.Repository, w.Heads) - } - seen[w.Heads] = true - if w.Approved.IsZero() || w.Since.IsZero() { - return fmt.Errorf("delivery: the delivery %s in the turn of %s says no time of its Approve or of its wait", w.Group, t.Repository) - } - if i > 0 && w.Approved.Before(t.Waiting[i-1].Approved) { - return fmt.Errorf("delivery: the turn of %s is not in the order of the Approve: %s was approved before %s", t.Repository, w.Group, t.Waiting[i-1].Group) - } - if w.WaitsFor != "" { - if _, _, err := ParseCheckerVerdictKey(w.WaitsFor); err != nil { - return fmt.Errorf("delivery: the delivery %s in the turn of %s waits for what is not a verdict key: %w", w.Group, t.Repository, err) - } - } else if !w.WaitsSince.IsZero() || w.Step != "" { - return fmt.Errorf("delivery: the delivery %s in the turn of %s says when and where a check runs, and names no check", w.Group, t.Repository) - } - } - return nil -} - -// TurnHolds says whether a turn holds off the check of the heads: it names deliveries, and none of them of these heads. A -// turn never holds the check of heads that wait in it (the carry a waiting merge needs): two approved merges in one turn whose -// trunk moved would otherwise each hold the other's carry, and neither would ever merge (review of ADR 0548's build, round 1). -func TurnHolds(t Turn, heads string) bool { - if len(t.Waiting) == 0 { - return false - } - for _, w := range t.Waiting { - if w.Heads == heads { - return false - } - } - return true -} - -// EncodeTurn is t as JSON at Version; it refuses a turn with no delivery, a repository that is not lowercase /, -// a delivery without its group, heads hash or times, the same heads twice, an order other than the Approve's, and a check -// waited for that is not a verdict key. -func EncodeTurn(t Turn) ([]byte, error) { - if err := t.check(); err != nil { - return nil, err - } - t.V = Version - return json.Marshal(t) -} - -// DecodeTurn reads a turn; it refuses an unknown version (ErrUnknownVersion) and what EncodeTurn refuses. -func DecodeTurn(data []byte) (Turn, error) { - var t Turn - if err := decode(data, &t); err != nil { - return Turn{}, err - } - if err := t.check(); err != nil { - return Turn{}, err - } - return t, nil -} - -// SameTurn says whether two turns are the same value, so the gate writes only when they differ. -func SameTurn(a, b Turn) bool { - if len(a.Waiting) != len(b.Waiting) || a.Repository != b.Repository || a.V != b.V { - return false - } - for i := range a.Waiting { - x, y := a.Waiting[i], b.Waiting[i] - if !x.Approved.Equal(y.Approved) || !x.Since.Equal(y.Since) || !x.WaitsSince.Equal(y.WaitsSince) { - return false - } - x.Approved, y.Approved, x.Since, y.Since, x.WaitsSince, y.WaitsSince = time.Time{}, time.Time{}, time.Time{}, time.Time{}, time.Time{}, time.Time{} - if !reflect.DeepEqual(x, y) { - return false - } - } - return true -} diff --git a/go/delivery/userlist.go b/go/delivery/userlist.go deleted file mode 100644 index be703a1..0000000 --- a/go/delivery/userlist.go +++ /dev/null @@ -1,146 +0,0 @@ -package delivery - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/seal" -) - -// **The grants part's user list, sealed to the bus node** (novox/hq ADR 0547 clauses 7 and 8, issue 1945): the contract -// between the grants part (mesh-delivery modules/mesh-grants), which writes it, and the node-engine of the node holding -// mesh-broker (mesh-host), which follows it once the operator's take-over says so. -// -// - The grants part composes the bus's user list (the accounts block, buscompose.ComposeAccounts, with the vault's -// hashes), seals it to the bus node's sealing key (seal.Seal, the key the node says in its have of the bus module, -// UserListWritten.Key), and puts the box as one object of its object bucket GrantsUserListObjects, named by the -// list's digest (UserListObjectName). -// - It writes the key GrantsUserListKey of its state `user-list` (bucket GrantsUserListBucket): the GrantsUserList -// below, which names the object, the list's digest, its revision, its user count and the users left out, and which -// source the bus node follows (Follow: FollowGrants, the one source since the controller ended, ADR 0590). -// - The node-engine of the bus node watches that key alone. It opens the object, checks the digest, writes the bus's -// user-list file (mode 0600) only when the file's digest differs, and says what it loaded in its have of the bus -// module (UserListWritten, Source GrantsUserListSource). - -// The names of the grants part's user list on the bus. -const ( - // GrantsUserListBucket is the grants part's state `user-list`. - GrantsUserListBucket = "mesh-grants_user-list" - // GrantsUserListKey is the one key of it the bus node reads. - GrantsUserListKey = "sealed" - // GrantsUserListObjects is the grants part's object bucket, where each sealed list is one object. - GrantsUserListObjects = "mesh-grants-user-list" - // GrantsUserListObjectStream is the bucket's stream. - GrantsUserListObjectStream = "OBJ_" + GrantsUserListObjects - // GrantsUserListSource is the source a have names for a list taken from the grants part. - GrantsUserListSource = GrantsUserListBucket + "." + GrantsUserListKey -) - -// FollowGrants is the list the bus node follows: the grants part's, the one source (ADR 0590). -const FollowGrants = "grants" - -// GrantsUserList is the value of the grants part's key `user-list.sealed`. -type GrantsUserList struct { - V int `json:"v"` - // Follow is which list the bus node follows: FollowGrants, the one value it takes. - Follow string `json:"follow"` - // Digest is the list's digest, `sha256:` of the file's bytes as the bus loads them. - Digest string `json:"digest"` - // Object is the object's name in GrantsUserListObjects (UserListObjectName of Digest). - Object string `json:"object"` - // SealedTo is the fingerprint of the key the object is sealed to (SealingKeyID), and Node the node it holds. - SealedTo string `json:"sealed_to"` - Node string `json:"node"` - // Revision counts the lists the grants part wrote: one more for each new digest. - Revision uint64 `json:"revision"` - Users int `json:"users"` - // LeftOut are the users the list leaves out for want of a hash in the vault. - LeftOut []string `json:"left_out"` - At time.Time `json:"at"` -} - -// EncodeGrantsUserList is g as JSON at Version; it refuses a value the bus node could not follow. -func EncodeGrantsUserList(g GrantsUserList) ([]byte, error) { - g.V = Version - if g.LeftOut == nil { - g.LeftOut = []string{} - } - if err := g.check(); err != nil { - return nil, err - } - return json.Marshal(g) -} - -// DecodeGrantsUserList reads the key's value; it refuses another version and what Encode refuses. -func DecodeGrantsUserList(data []byte) (GrantsUserList, error) { - var g GrantsUserList - if err := decode(data, &g); err != nil { - return GrantsUserList{}, err - } - if err := g.check(); err != nil { - return GrantsUserList{}, err - } - return g, nil -} - -func (g GrantsUserList) check() error { - if g.Follow != FollowGrants { - return fmt.Errorf("delivery: the user list's follow %q is not %s, the one list since the controller ended (ADR 0590)", - g.Follow, FollowGrants) - } - if g.Digest == "" { - return nil // a switch with no list written yet: the bus node keeps what it follows - } - if !strings.HasPrefix(g.Digest, "sha256:") || g.Object != UserListObjectName(g.Digest) { - return fmt.Errorf("delivery: the user list names object %q for digest %q; the object is the digest's hex", g.Object, g.Digest) - } - return nil -} - -// UserListDigest is a list's digest as the have and the key say it, `sha256:` of its bytes. -func UserListDigest(content []byte) string { - sum := sha256.Sum256(content) - return "sha256:" + hex.EncodeToString(sum[:]) -} - -// UserListObjectName is the object a list of that digest is put under: the digest's hex. -func UserListObjectName(digest string) string { return strings.TrimPrefix(digest, "sha256:") } - -// SealingKeyID is a short fingerprint of a public key in PEM, `sha256:<16 hex>`, to say which key a box is sealed to -// without carrying the key. -func SealingKeyID(publicPEM string) string { - sum := sha256.Sum256([]byte(strings.TrimSpace(publicPEM))) - return "sha256:" + hex.EncodeToString(sum[:8]) -} - -// SealUserList is the object a list is put as: the sealed box (seal.Seal) of its bytes to the bus node's key, as JSON. -func SealUserList(list []byte, nodePEM string) ([]byte, error) { - if !strings.Contains(nodePEM, "PUBLIC KEY") { - return nil, fmt.Errorf("delivery: the bus node's key is not a public key in PEM") - } - box, err := seal.Seal(string(list), nodePEM) - if err != nil { - return nil, fmt.Errorf("delivery: the user list could not be sealed: %w", err) - } - return json.Marshal(box) -} - -// OpenUserList opens an object with open (the node's own key) and checks it is the list of digest. -func OpenUserList(object []byte, digest string, open func(seal.Box) ([]byte, error)) ([]byte, error) { - var box seal.Box - if err := json.Unmarshal(object, &box); err != nil { - return nil, fmt.Errorf("delivery: the object is no sealed box: %w", err) - } - list, err := open(box) - if err != nil { - return nil, err - } - if got := UserListDigest(list); got != digest { - return nil, fmt.Errorf("delivery: the opened list's digest is %s, and the key names %s", got, digest) - } - return list, nil -} diff --git a/go/delivery/verdicts.go b/go/delivery/verdicts.go deleted file mode 100644 index 0f73bfe..0000000 --- a/go/delivery/verdicts.go +++ /dev/null @@ -1,245 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "slices" - "strconv" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// The verdicts on manifests (novox/hq ADR 0578 §1, §3): the indexer judges every manifest it takes in once, with its own -// judge, and writes the verdict in its own state; every other part decodes a manifest leniently (catalogue.Decode) and -// takes the verdict from here. This is the contract only: what a verdict holds, its key and its event. The rules that -// make a verdict are the judge's, a package of mesh-delivery the indexer and the checker use, and no reader runs them. - -const ( - // VerdictsBucket is the indexer's state of verdicts, one key per manifest digest (VerdictsKey). - VerdictsBucket = IndexerModule + "_verdicts" - // VerdictsHistory is the revisions the verdicts state keeps per key: a verdict is written again only when the judge - // that made it changed and the verdict with it. - VerdictsHistory = 5 - - // VerdictAccepted is the state of a manifest the judge found no problem in. - VerdictAccepted = "accepted" - // VerdictRefused is the state of a manifest the judge found a problem in; Problems says each. - VerdictRefused = "refused" - - // VerdictManifestWaits is the state of a MANIFEST's verdict (not a proposal's: VerdictWaits, in placement_proposal.go, - // has the same word for a different thing, which is why this one carries Manifest in its name). It is the state of a - // manifest that carries a field the indexer's judge does not know (novox/hq ADR 0578 §1, amended): the manifest is not - // refused, because a newer judge may know the field; Unknown names each field. A reader holds a module whose manifest - // waits as it holds a refused one (ManifestVerdict.Held) and keeps what runs. In a modules value it is written as a - // refusal naming the fields (Module.Unknown), for readers that do not know this state. - VerdictManifestWaits = "waits" - - // ManifestRefusedEvent is the indexer's event saying a manifest was refused (ManifestRefused), so the watchman - // raises it as a condition beside the indexer's finding. - ManifestRefusedEvent = "manifest-refused" - - // ManifestAcceptedEvent is the indexer's event saying a manifest that asks for bus state was accepted - // (ManifestAccepted), carrying its bus-state entries whole, so the bus module creates them without reading the - // indexer's state (novox/hq ADR 0595 §4, design 25 § 12.2). - ManifestAcceptedEvent = "manifest-accepted" - - // ManifestOutgrown is the finding of a manifest the indexer accepted before and a stricter judge finds problems in - // (novox/hq ADR 0579 §1): it stays accepted, its problems are the verdict's Warnings, and the watchman raises it as a - // warning until the manifest is mended. - ManifestOutgrown = "manifest-outgrown" - - // ManifestWaits is the finding of a manifest whose verdict waits (VerdictManifestWaits): the watchman raises it as a warning - // until a judge knows the fields or the manifest is mended. - ManifestWaits = "manifest-waits" -) - -// ManifestVerdict is the value of a verdicts key: the module whose manifest it is, accepted or refused, every problem the judge -// found (none when accepted), the judge that made it (the indexer's build fingerprint) and when. -type ManifestVerdict struct { - V int `json:"v"` - Module string `json:"module"` - State string `json:"state"` - Problems []string `json:"problems,omitempty"` - // Unknown are the fields of a manifest the judge does not know, named when the state is VerdictManifestWaits and nowhere else. - Unknown []string `json:"unknown,omitempty"` - // Warnings are the problems a stricter judge found in a manifest it accepted before, digest unchanged (novox/hq ADR - // 0579 §1): the verdict stays accepted. - Warnings []string `json:"warnings,omitempty"` - Judge string `json:"judge"` - At time.Time `json:"at"` -} - -// Refused is whether v refuses its manifest. -func (v ManifestVerdict) Refused() bool { return v.State == VerdictRefused } - -// Waits is whether v says the manifest waits for a judge that knows its fields. -func (v ManifestVerdict) Waits() bool { return v.State == VerdictManifestWaits } - -// Held is whether a reader holds the module as refused: the manifest is refused or waits. -func (v ManifestVerdict) Held() bool { return v.Refused() || v.Waits() } - -// AsRefused is v as a reader pinned before the verdict waits takes it: a waiting verdict is a refusal naming what it waits -// on, any other verdict is v. For a part that hands a verdict to a program it does not deliver with itself (the checker's -// sandbox composer). -func (v ManifestVerdict) AsRefused() ManifestVerdict { - if !v.Waits() { - return v - } - v.State, v.Problems, v.Unknown = VerdictRefused, []string{v.WaitsOn()}, nil - return v -} - -// Same is whether v and o say the same of a manifest: the module, the state, the problems and the unknown fields, whoever judged and when. -// The indexer writes a verdict only when it is not the same as the one it holds. -func (v ManifestVerdict) Same(o ManifestVerdict) bool { - return v.Module == o.Module && v.State == o.State && slices.Equal(v.Problems, o.Problems) && slices.Equal(v.Unknown, o.Unknown) && slices.Equal(v.Warnings, o.Warnings) -} - -func (v ManifestVerdict) check() error { - if err := checkName("module", v.Module); err != nil { - return err - } - if v.State != VerdictManifestWaits && len(v.Unknown) > 0 { - return fmt.Errorf("delivery: the verdict on %s names unknown fields and does not wait", v.Module) - } - switch v.State { - case VerdictAccepted: - if len(v.Problems) > 0 { - return fmt.Errorf("delivery: the verdict on %s accepts its manifest and names problems", v.Module) - } - case VerdictRefused: - if len(v.Problems) == 0 { - return fmt.Errorf("delivery: the verdict on %s refuses its manifest and names no problem", v.Module) - } - if len(v.Warnings) > 0 { - return fmt.Errorf("delivery: the verdict on %s refuses its manifest and warns: a refusal names problems", v.Module) - } - case VerdictManifestWaits: - if len(v.Unknown) == 0 || len(v.Problems) > 0 || len(v.Warnings) > 0 { - return fmt.Errorf("delivery: the verdict on %s waits and names no unknown field, or names problems or warnings: a wait names the fields", v.Module) - } - default: - return fmt.Errorf("delivery: the verdict on %s is %q, none of %s, %s and %s", v.Module, v.State, VerdictAccepted, VerdictRefused, VerdictManifestWaits) - } - if v.Judge == "" { - return fmt.Errorf("delivery: the verdict on %s names no judge", v.Module) - } - return nil -} - -// VerdictsKey is the key, in VerdictsBucket, of the verdict on a manifest: its digest (ManifestDigest), as in the -// manifests bucket. -func VerdictsKey(digest string) (string, error) { - if !isHex64(digest) { - return "", fmt.Errorf("delivery: %q is not a manifest digest, 64 lowercase hex digits", digest) - } - return digest, nil -} - -// EncodeManifestVerdict is v as JSON at Version; it refuses a verdict without a module or a judge, a state other than accepted -// or refused, a refusal naming no problem and an acceptance naming one. -func EncodeManifestVerdict(v ManifestVerdict) ([]byte, error) { - if err := v.check(); err != nil { - return nil, err - } - v.V = Version - return json.Marshal(v) -} - -// DecodeManifestVerdict reads a verdict; it refuses an unknown version (ErrUnknownVersion) and what EncodeManifestVerdict refuses. -func DecodeManifestVerdict(data []byte) (ManifestVerdict, error) { - var v ManifestVerdict - if err := decode(data, &v); err != nil { - return ManifestVerdict{}, err - } - if err := v.check(); err != nil { - return ManifestVerdict{}, err - } - return v, nil -} - -// TakeManifestVerdict is how a reader takes the verdict on a manifest (ADR 0578 §3): held, with its problems, when the -// indexer wrote a refusal, or, for a manifest that waits, with the words that say which fields it waits on. A missing verdict (nil) is taken as accepted, because every manifest the indexer holds before -// it has judged it was taken in under the judges that ran until then; that lasts until the indexer's first pass after its -// delivery has written every verdict. A reader holds a refused module and names it with Why, and reads the rest. -func TakeManifestVerdict(v *ManifestVerdict) (refused bool, problems []string) { - if v == nil || !v.Held() { - return false, nil - } - if v.Waits() { - return true, []string{v.WaitsOn()} - } - return true, v.Problems -} - -// WaitsOn says, in words, what a waiting manifest waits on. -func (v ManifestVerdict) WaitsOn() string { - return "it carries " + strings.Join(v.Unknown, ", ") + ", which the indexer's judge does not know yet" -} - -// Why is a refusal in a reader's words: the module, that the indexer's judge refused its manifest, and each problem. -func (v ManifestVerdict) Why() string { - if !v.Held() { - return "" - } - judge := "the indexer's judge" - if v.Judge != "" { - judge += " (" + v.Judge + ")" - } - if v.Waits() { - return fmt.Sprintf("%s holds the manifest of %s: %s", judge, v.Module, v.WaitsOn()) - } - why := fmt.Sprintf("%s refused the manifest of %s:", judge, v.Module) - for _, p := range v.Problems { - why += " " + p + ";" - } - return why[:len(why)-1] -} - -// ManifestRefused is the body of the indexer's event ManifestRefusedEvent: the module, the digest of its manifest, the -// judge and each problem, as its verdict holds them. -type ManifestRefused struct { - V int `json:"v"` - Module string `json:"module"` - Digest string `json:"digest"` - Judge string `json:"judge"` - Problems []string `json:"problems"` - // Revision is the indexer's revision of the verdict it says (novox/hq ADR 0622 §2): a reader following verdicts by - // their events asks its first view again on a gap in it. Zero from an indexer that does not say it. - Revision uint64 `json:"revision,omitempty"` -} - -// RefusedOf is the event body saying v, the verdict on the manifest of digest. -func RefusedOf(digest string, v ManifestVerdict) ManifestRefused { - return ManifestRefused{V: Version, Module: v.Module, Digest: digest, Judge: v.Judge, Problems: v.Problems} -} - -// ManifestAccepted is the body of the indexer's event ManifestAcceptedEvent: the module, the digest of its manifest, the -// judge, and every bus-state entry the manifest asks for, whole (novox/hq ADR 0595 §4). -type ManifestAccepted struct { - V int `json:"v"` - Module string `json:"module"` - Digest string `json:"digest"` - Judge string `json:"judge"` - BusState []catalogue.BusStateEntry `json:"bus-state"` - // Revision is the indexer's revision of the verdict it says (novox/hq ADR 0622 §2; the bus module applies a replay - // only if newer, design 25 § 12.2). Zero from an indexer that does not say it. - Revision uint64 `json:"revision,omitempty"` -} - -// AcceptedOf is the event body saying v, the verdict accepting the manifest m of digest. -func AcceptedOf(digest string, v ManifestVerdict, m catalogue.Manifest) ManifestAccepted { - return ManifestAccepted{V: Version, Module: v.Module, Digest: digest, Judge: v.Judge, BusState: m.BusState} -} - -// EventID is the event's id, one per manifest digest and judge, so the bus takes it once: -// `manifest-accepted--`. The empty one of a module leaving the trunk carries no digest, so it is named by -// its module and the indexer's revision: `manifest-accepted-left--`, so two leaves are both kept. -func (a ManifestAccepted) EventID() string { - if a.Digest == "" { - return ManifestAcceptedEvent + "-left-" + a.Module + "-" + strconv.FormatUint(a.Revision, 10) - } - return ManifestAcceptedEvent + "-" + a.Digest + "-" + a.Judge -} diff --git a/go/delivery/verdicts_test.go b/go/delivery/verdicts_test.go deleted file mode 100644 index 090af8a..0000000 --- a/go/delivery/verdicts_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package delivery - -import ( - "encoding/json" - "reflect" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// A verdict on a manifest round-trips, refuses what it cannot be, and a reader takes a missing one as accepted (novox/hq -// ADR 0578 §1, §3); a modules value carries it. -func TestAManifestVerdictIsTheContractReadersTake(t *testing.T) { - at := time.Date(2026, 11, 1, 2, 0, 0, 0, time.UTC) - refused := ManifestVerdict{Module: "nats", State: VerdictRefused, Problems: []string{"a", "b"}, Judge: "src2:x", At: at} - data, err := EncodeManifestVerdict(refused) - if err != nil { - t.Fatal(err) - } - back, err := DecodeManifestVerdict(data) - if err != nil || !back.Same(refused) || back.Judge != "src2:x" || !back.At.Equal(at) { - t.Fatalf("round trip: %+v %v", back, err) - } - if !strings.Contains(back.Why(), "refused the manifest of nats: a; b") { - t.Fatalf("why: %q", back.Why()) - } - for name, bad := range map[string]ManifestVerdict{ - "no module": {State: VerdictAccepted, Judge: "j"}, - "no judge": {Module: "m", State: VerdictAccepted}, - "another state": {Module: "m", State: "maybe", Judge: "j"}, - "refused, no problem": {Module: "m", State: VerdictRefused, Judge: "j"}, - "accepted, a problem": {Module: "m", State: VerdictAccepted, Problems: []string{"x"}, Judge: "j"}, - "refused, a warning": {Module: "m", State: VerdictRefused, Problems: []string{"x"}, Warnings: []string{"y"}, Judge: "j"}, - "waits, no field": {Module: "m", State: VerdictManifestWaits, Judge: "j"}, - "waits, a problem": {Module: "m", State: VerdictManifestWaits, Unknown: []string{"f"}, Problems: []string{"x"}, Judge: "j"}, - "fields, accepted": {Module: "m", State: VerdictAccepted, Unknown: []string{"f"}, Judge: "j"}, - } { - if _, err := EncodeManifestVerdict(bad); err == nil { - t.Errorf("%s: encoded", name) - } - } - if refusedTaken, _ := TakeManifestVerdict(nil); refusedTaken { - t.Fatal("a missing verdict was taken as a refusal") - } - if r, p := TakeManifestVerdict(&refused); !r || len(p) != 2 { - t.Fatalf("a refusal was not taken: %v %v", r, p) - } - if k, err := VerdictsKey(strings.Repeat("a", 64)); err != nil || k != strings.Repeat("a", 64) { - t.Fatalf("key: %q %v", k, err) - } - if _, err := VerdictsKey("nats"); err == nil { - t.Fatal("a key that is no digest") - } - m := Module{FP2: "src2:" + strings.Repeat("b", 64), Manifest: strings.Repeat("c", 64), Repository: "novox/x", - Commit: "c", Verdict: VerdictRefused, Problems: []string{"p"}} - data, err = EncodeModule(m) - if err != nil { - t.Fatal(err) - } - got, err := DecodeModule(data) - if err != nil { - t.Fatal(err) - } - if r, p := TakeManifestVerdict(got.VerdictOf("x")); !r || p[0] != "p" { - t.Fatalf("the module's verdict: %+v", got) - } - m.Problems = nil - if _, err := EncodeModule(m); err == nil { - t.Fatal("a module refused naming no problem was encoded") - } - // ADR 0579 §1: an accepted manifest a stricter judge outgrew carries its warnings and is taken as accepted. - outgrown := ManifestVerdict{Module: "x", State: VerdictAccepted, Warnings: []string{"w"}, Judge: "j"} - if _, err := EncodeManifestVerdict(outgrown); err != nil || outgrown.Same(ManifestVerdict{Module: "x", State: VerdictAccepted}) { - t.Fatalf("an outgrown verdict: %v", err) - } - m.Verdict, m.Warnings = "", []string{"w"} - if r, _ := TakeManifestVerdict(m.VerdictOf("x")); r || len(m.VerdictOf("x").Warnings) != 1 { - t.Fatalf("an outgrown module: %+v", m.VerdictOf("x")) - } - - // 1966/AC6: the accepted event of a manifest with two bus-state entries carries both whole, and its id is - // manifest-accepted--. - entries := []catalogue.BusStateEntry{ - {Name: "drafts", Storage: catalogue.StorageKeyValue, Template: catalogue.TemplateState, - Options: map[string]json.RawMessage{catalogue.OptionPastValues: json.RawMessage(`5`)}}, - {Name: "records", Storage: catalogue.StorageLedger, Template: catalogue.TemplateLedger, - Types: []catalogue.LedgerType{{Name: "decision", Operations: []catalogue.LedgerOperation{{Name: "submit", Effect: catalogue.EffectSetFields}}}}}, - } - digest := strings.Repeat("d", 64) - accepted := AcceptedOf(digest, ManifestVerdict{Module: "mesh-knowledge", State: VerdictAccepted, Judge: "src2:j"}, - catalogue.Manifest{Module: "mesh-knowledge", BusState: entries}) - raw, err := json.Marshal(accepted) - if err != nil { - t.Fatal(err) - } - var heard ManifestAccepted - if err := json.Unmarshal(raw, &heard); err != nil || heard.Module != "mesh-knowledge" || heard.Digest != digest || - heard.V != Version || !reflect.DeepEqual(heard.BusState, entries) { - t.Fatalf("the accepted event reads as %+v (%v)", heard, err) - } - if id := heard.EventID(); id != "manifest-accepted-"+digest+"-src2:j" { - t.Fatalf("the accepted event's id is %q", id) - } - // The empty one of a module leaving the trunk has no digest: its id names the module and the revision, so two - // modules' differ, and so do two leaves of one module. - if a, b, again := (ManifestAccepted{Module: "a", Judge: "j", Revision: 7}).EventID(), (ManifestAccepted{Module: "b", Judge: "j", Revision: 7}).EventID(), - (ManifestAccepted{Module: "a", Judge: "j", Revision: 9}).EventID(); a == b || a == again || a != "manifest-accepted-left-a-7" { - t.Fatalf("the ids of modules leaving are %q, %q and %q", a, b, again) - } - // Both verdict events carry the indexer's revision (ADR 0622 §2), and one without it reads as revision 0. - for _, ev := range []any{ManifestAccepted{Module: "m", Revision: 41}, ManifestRefused{Module: "m", Revision: 41}} { - raw, _ := json.Marshal(ev) - var back struct { - Revision uint64 `json:"revision"` - } - if json.Unmarshal(raw, &back); back.Revision != 41 { - t.Errorf("%T carries no revision: %s", ev, raw) - } - } - // A manifest with a field the judge does not know waits (clause 3 of the record on a release's Approve): readers hold it - // as a refused one, it round-trips through a modules value, an old value decodes unchanged, and it is not a refusal. - waits := ManifestVerdict{Module: "nats", State: VerdictManifestWaits, Unknown: []string{"core-credentials", "tool-runner"}, Judge: "j"} - data, err = EncodeManifestVerdict(waits) - if err != nil { - t.Fatal(err) - } - if back, err = DecodeManifestVerdict(data); err != nil || !back.Same(waits) || back.Same(ManifestVerdict{Module: "nats", State: VerdictManifestWaits, Unknown: []string{"x"}, Judge: "j"}) { - t.Fatalf("a waiting verdict: %+v %v", back, err) - } - if r, p := TakeManifestVerdict(&waits); !r || waits.Refused() || !waits.Held() || !strings.Contains(p[0], "core-credentials, tool-runner") || - !strings.Contains(waits.Why(), "holds the manifest of nats") { - t.Fatalf("a waiting verdict is held like a refusal: %v %v %q", r, p, waits.Why()) - } - if r := waits.AsRefused(); !r.Refused() || r.Waits() || len(r.Problems) != 1 || len(r.Unknown) != 0 || refused.AsRefused().State != VerdictRefused { - t.Fatalf("a waiting verdict as a reader before it takes it: %+v", r) - } - // In a modules value a wait is written as a refusal naming the fields (an older reader holds it as refused). - w := Module{FP2: "src2:" + strings.Repeat("b", 64), Manifest: strings.Repeat("c", 64), Repository: "novox/x", Commit: "c", - Verdict: VerdictRefused, Problems: []string{waits.WaitsOn()}, Unknown: []string{"f"}} - data, err = EncodeModule(w) - if err != nil { - t.Fatal(err) - } - if got, err = DecodeModule(data); err != nil || got.VerdictOf("x").Unknown[0] != "f" || !got.VerdictOf("x").Waits() || got.VerdictOf("x").Refused() { - t.Fatalf("the module's waiting verdict: %+v %v", got, err) - } - if r, p := TakeManifestVerdict(got.VerdictOf("x")); !r || !strings.Contains(p[0], "which the indexer's judge does not know yet") { - t.Fatalf("a waiting module is held: %v %v", r, p) - } - w.Verdict, w.Problems = "", nil - if _, err := EncodeModule(w); err == nil { - t.Fatal("unknown fields with no wait were encoded") - } -} diff --git a/go/delivery/version_numbered.go b/go/delivery/version_numbered.go deleted file mode 100644 index 634cb84..0000000 --- a/go/delivery/version_numbered.go +++ /dev/null @@ -1,57 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" -) - -// The builder's event version.numbered (design 51 *Events of the builder*, *The floor*): said once per version the builder -// numbers, on its own family `version` (seven days), apart from its builds' ends, so a waiter on a build's end does not -// wake on it. A build module consumes it to publish a build's version with the held digest, which stores nothing. -const ( - // VersionNumberedEvent is the event's local name. - VersionNumberedEvent = "version.numbered" - // VersionNumberedSubject is its subject, as a consumer's filter names it. - VersionNumberedSubject = "mesh.mod." + BuilderModule + ".event." + VersionNumberedEvent -) - -// VersionNumbered is the body of VersionNumberedEvent: a numbered version of a name, the build it was numbered for, and -// what the build module needs to publish it. -type VersionNumbered struct { - // Name is the numbered name, as builder_versions lists it; Kind its artifact's kind. - Name string `json:"name"` - Kind string `json:"kind"` - Version string `json:"version"` - Tag string `json:"tag,omitempty"` - // Module and Fingerprint are the build the version was numbered for; Commit its source commit. - Module string `json:"module"` - Fingerprint string `json:"fingerprint"` - Commit string `json:"commit,omitempty"` - // Digest is the built artifact's, when the build held one; Seat the registry seat it is published through. - Digest string `json:"digest,omitempty"` - Seat string `json:"seat,omitempty"` - // Head is the head the version answers, when it answers a tag ask. - Head string `json:"head,omitempty"` - // Again is when builder_publish_again said it once more, so the bus's dedup does not drop it. - Again string `json:"again,omitempty"` -} - -// EncodeVersionNumbered writes the body, refusing one that names no name, version, module or fingerprint. -func EncodeVersionNumbered(n VersionNumbered) ([]byte, error) { - if n.Name == "" || n.Version == "" || n.Module == "" || n.Fingerprint == "" { - return nil, fmt.Errorf("delivery: %s names its name, version, module and fingerprint", VersionNumberedEvent) - } - return json.Marshal(n) -} - -// DecodeVersionNumbered reads the body, with the same refusal. -func DecodeVersionNumbered(data []byte) (VersionNumbered, error) { - var n VersionNumbered - if err := json.Unmarshal(data, &n); err != nil { - return VersionNumbered{}, fmt.Errorf("delivery: %s is not readable: %w", VersionNumberedEvent, err) - } - if _, err := EncodeVersionNumbered(n); err != nil { - return VersionNumbered{}, err - } - return n, nil -} diff --git a/go/delivery/view_answers.go b/go/delivery/view_answers.go deleted file mode 100644 index 4ba3bfa..0000000 --- a/go/delivery/view_answers.go +++ /dev/null @@ -1,337 +0,0 @@ -package delivery - -import ( - "bytes" - "encoding/json" - "fmt" - "strings" -) - -// The asks the view may answer, and its answers (novox/hq ADR 0528 clause 6, ADR 0429 §3 and §6, ADR 0437 §10). The -// gate's merge ask (`merge..`, MergeAskKey) needs proof of who answered (ADR 0421 §1), so it is -// answered on the phone alone until issue 953 closes. The assigner's asks need no such proof, and the view may -// answer them: the hold of a fingerprint whose health check failed (`hold..`: release it or -// keep it held) and the restore of a data module set back (`restore...`: restore and -// lose newer writes, or leave it stopped, ADR 0429 §6). The assigner writes them in its bucket asks; the view writes -// its answer `.view` in its bucket answers, and the teller, which alone fills an ask's answer, takes it from -// there as it takes a warrant from the phone. -const ( - // ViewModule is the module of the view part. - ViewModule = "mesh-delivery-view" - // ViewAnswersBucket is the view's bucket of answers, the only state the view writes. - ViewAnswersBucket = ViewModule + "_answers" - // ViewAnswersHistory is the revisions the bucket keeps per key. - ViewAnswersHistory = 5 - // AssignerAsksBucket is the assigner's bucket of asks that need no proof of who answered. - AssignerAsksBucket = AssignerModule + "_asks" - // AssignerAsksHistory is the revisions the bucket keeps per key. - AssignerAsksHistory = 5 - - // AskKindHold is the kind of the assigner's ask about a held fingerprint. - AskKindHold = "hold" - // AskKindRestore is the kind of the assigner's ask about a data module set back. - AskKindRestore = "restore" - // AskKindBackup is the kind of the assigner's ask about a backup before an apply that failed: back up again, or go - // on without one (novox/hq ADR 0539 clause 16). - AskKindBackup = "backup" - // AskKindRecord is the kind of the assigner's ask about a move a person must allow, for a module of policy record: - // a composition judged to recreate a container or move data, or one that cannot be judged (ADR 0539 clause 14). - AskKindRecord = "record" - // AskKindPlacement is the kind of the seat mesh-placement's authorising ask about a proposal (novox/hq ADR 0567 §5): - // `placement.`. The kind needs no proof, as the bus's put does not: its yes is an Approve, which only a - // channel proving who answered takes and the view never does (ViewAnswerFits). - AskKindPlacement = "placement" - - viewWord = "view" -) - -// AskKey is an ask's key read whole: its Kind, and the parts its kind has: a merge's Group and heads Hash, a hold's -// Module and fingerprint FP, a restore's and a backup's Node, Module and FP, a record's Node, Module and the digest -// Composed of the composition it would allow, a placement's Proposal. -type AskKey struct { - Proposal string - Kind string - Group string - Hash string - Node string - Module string - FP string - Composed string -} - -// AskNeedsProof says whether an ask of this kind must prove who answered it, so the view may not answer it: the -// merge's Approve does (ADR 0421 §1, issue 953), the assigner's hold and restore do not. An unknown kind is refused. -func AskNeedsProof(kind string) (bool, error) { - switch kind { - case AskKindMerge: - return true, nil - case AskKindHold, AskKindRestore, AskKindBackup, AskKindRecord, AskKindPlacement: - return false, nil - } - return false, fmt.Errorf("delivery: %q is not a kind of ask, %s, %s, %s, %s, %s or %s", kind, AskKindMerge, AskKindHold, - AskKindRestore, AskKindBackup, AskKindRecord, AskKindPlacement) -} - -// HoldAskKey is the key, in AssignerAsksBucket, of the ask about a module's held fingerprint: `hold..`. -func HoldAskKey(module, fingerprint string) (string, error) { - key, err := BuilderBuildKey(module, fingerprint) - if err != nil { - return "", err - } - return AskKindHold + "." + key, nil -} - -// RestoreAskKey is the key, in AssignerAsksBucket, of the ask about a data module set back on a node from a -// fingerprint: `restore...`. -func RestoreAskKey(node, module, fingerprint string) (string, error) { - if err := checkName("node", node); err != nil { - return "", err - } - key, err := BuilderBuildKey(module, fingerprint) - if err != nil { - return "", err - } - return AskKindRestore + "." + node + "." + key, nil -} - -// BackupAskKey is the key, in AssignerAsksBucket, of the ask about a module's failed backup on a node before it applies -// a fingerprint: `backup...`. -func BackupAskKey(node, module, fingerprint string) (string, error) { - key, err := ComposedKey(node, module, fingerprint) - if err != nil { - return "", err - } - return AskKindBackup + "." + key, nil -} - -// PlacementAskKey is the key, in AssignerAsksBucket, of the authorising ask about a proposal of placement: -// `placement.` (ADR 0567 §5). -func PlacementAskKey(proposal string) (string, error) { - if err := CheckProposalID(proposal); err != nil { - return "", err - } - return AskKindPlacement + "." + proposal, nil -} - -// RecordAskKey is the key, in AssignerAsksBucket, of the ask about a move of a module on a node to the composition -// whose digest it names: `record...`, so a new revision at the same fingerprint is asked -// again (ADR 0539 clause 14). -func RecordAskKey(node, module, composed string) (string, error) { - have, err := HaveKey(node, module) - if err != nil { - return "", err - } - if err := checkDigest(composed, "a record ask's composition digest"); err != nil { - return "", err - } - return AskKindRecord + "." + have + "." + strings.TrimPrefix(composed, "sha256:"), nil -} - -// ParseAnyAskKey reads an ask key of any kind (MergeAskKey, HoldAskKey, RestoreAskKey, BackupAskKey, RecordAskKey); it -// refuses an unknown kind -// and a key whose parts do not fit its kind, naming the key. -func ParseAnyAskKey(key string) (AskKey, error) { - kind, rest, _ := strings.Cut(key, ".") - refused := func(err error) (AskKey, error) { - return AskKey{}, fmt.Errorf("delivery: %q is not an ask key: %w", key, err) - } - switch kind { - case AskKindMerge: - _, group, hash, err := ParseAskKey(key) - if err != nil { - return AskKey{}, err - } - return AskKey{Kind: kind, Group: group, Hash: hash}, nil - case AskKindHold: - module, fp, err := ParseBuilderBuildKey(rest) - if err != nil { - return refused(err) - } - return AskKey{Kind: kind, Module: module, FP: fp}, nil - case AskKindRestore: - node, build, ok := strings.Cut(rest, ".") - if !ok { - return refused(fmt.Errorf("a restore ask is restore...")) - } - if err := checkName("node", node); err != nil { - return refused(err) - } - module, fp, err := ParseBuilderBuildKey(build) - if err != nil { - return refused(err) - } - return AskKey{Kind: kind, Node: node, Module: module, FP: fp}, nil - case AskKindBackup: - node, module, fp, isNode, err := ParseComposedKey(rest) - if err != nil || isNode { - return refused(fmt.Errorf("a backup ask is backup...")) - } - return AskKey{Kind: kind, Node: node, Module: module, FP: fp}, nil - case AskKindPlacement: - if err := CheckProposalID(rest); err != nil { - return refused(err) - } - return AskKey{Kind: kind, Proposal: rest}, nil - case AskKindRecord: - parts := strings.Split(rest, ".") - if len(parts) != 3 { - return refused(fmt.Errorf("a record ask is record...")) - } - node, module, isNode, err := ParseHaveKey(parts[0] + "." + parts[1]) - if err != nil || isNode { - return refused(fmt.Errorf("a record ask is record...")) - } - composed := "sha256:" + parts[2] - if err := checkDigest(composed, "a record ask's composition digest"); err != nil { - return refused(err) - } - return AskKey{Kind: kind, Node: node, Module: module, Composed: composed}, nil - } - _, err := AskNeedsProof(kind) - return refused(err) -} - -// ViewAnswerKey is the key, in ViewAnswersBucket, of the view's answer to the ask at askKey: `.view`. An ask -// that needs proof of who answered is refused with its reason: it is answered on the phone (issue 953). -func ViewAnswerKey(askKey string) (string, error) { - ask, err := ParseAnyAskKey(askKey) - if err != nil { - return "", err - } - if proof, _ := AskNeedsProof(ask.Kind); proof { - return "", fmt.Errorf("delivery: the ask %s needs proof of who answered: answer it on the phone (issue 953)", askKey) - } - return askKey + "." + viewWord, nil -} - -// ParseViewAnswerKey is the inverse of ViewAnswerKey: the ask's key. It refuses a key without `.view`, an ask key of -// a kind that needs proof, and a key of another bucket, naming it. -func ParseViewAnswerKey(key string) (askKey string, err error) { - askKey, ok := strings.CutSuffix(key, "."+viewWord) - if !ok { - return "", fmt.Errorf("delivery: %q is not a view answer key, .view", key) - } - if _, err := ViewAnswerKey(askKey); err != nil { - return "", fmt.Errorf("delivery: %q is not a view answer key: %w", key, err) - } - return askKey, nil -} - -// AssignerAsk is the value of a key in AssignerAsksBucket: the router's ask whole, as the assigner composes it, so the -// teller publishes it unchanged. Asking counts the times the assigner wrote the ask at this key, as the gate's Ask -// does: 0 or 1 for the first, n for the n-th, and the ask's id is AskingID(key, Asking), so each asking has its own id -// and an answer to an earlier asking is never taken for a later one. The ask is the operator-channel seat's -// (mesh-contracts operatorchannel.Ask) as its JSON, as in the gate's Ask: its id, its options and its digest are -// checked by the assigner and the teller through operator-channel's client (novox/hq ADR 0627 §4). -type AssignerAsk struct { - V int `json:"v"` - Ask json.RawMessage `json:"ask"` - Asking int `json:"asking,omitempty"` -} - -func (a AssignerAsk) check(key string) error { - ask, err := ParseAnyAskKey(key) - if err != nil { - return err - } - if proof, _ := AskNeedsProof(ask.Kind); proof { - return fmt.Errorf("delivery: the assigner asks no %s ask: it needs proof of who answered", ask.Kind) - } - if a.Asking < 0 { - return fmt.Errorf("delivery: the ask's asking %d is negative", a.Asking) - } - return routerAsk(a.Ask) -} - -// EncodeAssignerAsk is a, the ask at key, as JSON at Version; it refuses a key of a kind that needs proof or of no -// kind, a negative asking, and a value carrying no router's ask. -func EncodeAssignerAsk(key string, a AssignerAsk) ([]byte, error) { - if err := a.check(key); err != nil { - return nil, err - } - a.V = Version - return json.Marshal(a) -} - -// DecodeAssignerAsk reads the ask at key; it refuses an unknown version (ErrUnknownVersion) and what EncodeAssignerAsk -// refuses. -func DecodeAssignerAsk(key string, data []byte) (AssignerAsk, error) { - var a AssignerAsk - if err := decode(data, &a); err != nil { - return AssignerAsk{}, err - } - if err := a.check(key); err != nil { - return AssignerAsk{}, err - } - return a, nil -} - -// SameAssignerAsk says whether two asks are the same value: the same version, asking and router's ask, its JSON -// compared compacted. -func SameAssignerAsk(a, b AssignerAsk) bool { - return a.V == b.V && a.Asking == b.Asking && bytes.Equal(compacted(a.Ask), compacted(b.Ask)) -} - -func compacted(raw json.RawMessage) []byte { - var b bytes.Buffer - if json.Compact(&b, raw) != nil { - return raw - } - return b.Bytes() -} - -// ViewAnswer is the view's answer to an ask: the id of the asking it answers (the ask's id, AskingID of its key and -// asking), the option the operator chose and their words. The entry's own time says when. -type ViewAnswer struct { - V int `json:"v"` - Asking string `json:"asking"` - Option string `json:"option"` - Words string `json:"words,omitempty"` -} - -func (a ViewAnswer) check() error { - if strings.TrimSpace(a.Asking) == "" { - return fmt.Errorf("delivery: a view answer names no asking") - } - if strings.TrimSpace(a.Option) == "" { - return fmt.Errorf("delivery: a view answer names no option") - } - return nil -} - -// ViewAnswerFits says whether the view's answer a answers the ask at key as it stands now: it refuses an answer to -// another asking of the ask (a stale answer to an earlier writing of the same key), naming both. Whether the ask -// offers the option chosen, and at the level acknowledge only (an option that authorises is answered on a channel -// that proves who answered, ADR 0259 §5, ADR 0546 clause 8, never in the view, which proves nobody), the teller reads -// from the ask through operator-channel's client (novox/hq ADR 0627 §4). -func ViewAnswerFits(a ViewAnswer, key string, ask AssignerAsk) error { - if want := AskingID(key, ask.Asking); a.Asking != want { - return fmt.Errorf("delivery: the view's answer is to the asking %s, and the ask stands at the asking %s", a.Asking, want) - } - return nil -} - -// EncodeViewAnswer is a as JSON at Version; it refuses an answer naming no asking or no option. -func EncodeViewAnswer(a ViewAnswer) ([]byte, error) { - if err := a.check(); err != nil { - return nil, err - } - a.V = Version - return json.Marshal(a) -} - -// DecodeViewAnswer reads a view answer; it refuses an unknown version (ErrUnknownVersion) and an answer naming no -// asking or no option. -func DecodeViewAnswer(data []byte) (ViewAnswer, error) { - var a ViewAnswer - if err := decode(data, &a); err != nil { - return ViewAnswer{}, err - } - if err := a.check(); err != nil { - return ViewAnswer{}, err - } - return a, nil -} - -// SameViewAnswer says whether two view answers are the same value. -func SameViewAnswer(a, b ViewAnswer) bool { return a == b } diff --git a/go/delivery/view_answers_test.go b/go/delivery/view_answers_test.go deleted file mode 100644 index 2d44d4b..0000000 --- a/go/delivery/view_answers_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package delivery - -import ( - "encoding/json" - "strings" - "testing" -) - -// 1297/B1c: the kinds of ask, and which need proof of who answered: the merge does, hold and restore do not, an -// unknown kind is refused. -func TestAskNeedsProof(t *testing.T) { - for kind, want := range map[string]bool{AskKindMerge: true, AskKindHold: false, AskKindRestore: false} { - if got, err := AskNeedsProof(kind); err != nil || got != want { - t.Errorf("AskNeedsProof(%q) = %v, %v; want %v", kind, got, err, want) - } - } - if _, err := AskNeedsProof("approve"); err == nil { - t.Errorf("an unknown kind was read") - } -} - -// 1297/B1c: each kind of ask key round-trips through ParseAnyAskKey; a key that fits no kind is refused, naming it. -func TestParseAnyAskKey(t *testing.T) { - fp := fp2of('d') - merge, _ := MergeAskKey("feat/r4g", []Head{{Repository: "novox/mesh-sdk", Number: 1, Head: strings.Repeat("1", 40)}}) - hold, err := HoldAskKey("mesh-mail", fp) - if err != nil || hold != "hold.mesh-mail."+strings.Repeat("d", 64) { - t.Fatalf("HoldAskKey = %q, %v", hold, err) - } - restore, err := RestoreAskKey("ace", "mesh-mail", fp) - if err != nil || restore != "restore.ace.mesh-mail."+strings.Repeat("d", 64) { - t.Fatalf("RestoreAskKey = %q, %v", restore, err) - } - placement, err := PlacementAskKey("p-one") - if err != nil || placement != "placement.p-one" { - t.Fatalf("PlacementAskKey = %q, %v", placement, err) - } - if proof, err := AskNeedsProof(AskKindPlacement); proof || err != nil { - t.Error("the assigner cannot keep a placement ask: its kind needs proof (its yes's level does)") - } - for key, want := range map[string]AskKey{ - hold: {Kind: AskKindHold, Module: "mesh-mail", FP: fp}, - restore: {Kind: AskKindRestore, Node: "ace", Module: "mesh-mail", FP: fp}, - placement: {Kind: AskKindPlacement, Proposal: "p-one"}, - } { - if got, err := ParseAnyAskKey(key); err != nil || got != want { - t.Errorf("ParseAnyAskKey(%q) = %+v, %v", key, got, err) - } - } - if got, err := ParseAnyAskKey(merge); err != nil || got.Kind != AskKindMerge || got.Group != "feat/r4g" || len(got.Hash) != 12 { - t.Errorf("ParseAnyAskKey(%q) = %+v, %v", merge, got, err) - } - for _, bad := range []string{"want.ace.mesh-mail", "hold.mesh-mail", "restore.mesh-mail." + strings.Repeat("d", 64), "approve.x.y", "restore.a.b.c.d", "placement.P", ""} { - if _, err := ParseAnyAskKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ParseAnyAskKey(%q) = %v, want a refusal naming it", bad, err) - } - } - if _, err := RestoreAskKey("a.b", "m", fp); err == nil { - t.Errorf("a restore ask on a dotted node was keyed") - } -} - -// 1297/B1c: the view's answer key is the ask's key and `.view`, and round-trips; the merge's ask is refused with its -// reason (issue 953); another bucket's key is refused, naming it. -func TestViewAnswerKey(t *testing.T) { - hold, _ := HoldAskKey("mesh-mail", fp2of('e')) - key, err := ViewAnswerKey(hold) - if err != nil || key != hold+".view" { - t.Fatalf("ViewAnswerKey = %q, %v", key, err) - } - if back, err := ParseViewAnswerKey(key); err != nil || back != hold { - t.Errorf("ParseViewAnswerKey = %q, %v", back, err) - } - merge, _ := MergeAskKey("g", []Head{{Repository: "novox/hq", Number: 2, Head: strings.Repeat("2", 40)}}) - if _, err := ViewAnswerKey(merge); err == nil || !strings.Contains(err.Error(), "phone") || !strings.Contains(err.Error(), "953") { - t.Errorf("the merge's ask was answerable in the view, or its reason is missing: %v", err) - } - for _, bad := range []string{hold, merge + ".view", "want.ace.mesh-mail.view", "ace.mesh-mail", ""} { - if _, err := ParseViewAnswerKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ParseViewAnswerKey(%q) = %v, want a refusal naming it", bad, err) - } - } -} - -// 1297/B1c: a view answer round-trips; one naming no asking or no option is refused. -func TestViewAnswerRoundTrip(t *testing.T) { - a := ViewAnswer{Asking: "hold-mesh-mail-aaaa-0123456789ab", Option: "release", Words: "fixed by #12"} - data, err := EncodeViewAnswer(a) - if err != nil { - t.Fatal(err) - } - got, err := DecodeViewAnswer(data) - a.V = Version - if err != nil || !SameViewAnswer(got, a) { - t.Errorf("view answer came back %+v, %v", got, err) - } - if _, err := EncodeViewAnswer(ViewAnswer{Asking: "x", Option: " "}); err == nil { - t.Errorf("a view answer naming no option was accepted") - } - if _, err := EncodeViewAnswer(ViewAnswer{Option: "release"}); err == nil { - t.Errorf("a view answer naming no asking was accepted") - } -} - -func holdAsk(t *testing.T, asking int) (string, AssignerAsk) { - t.Helper() - key, err := HoldAskKey("mesh-mail", fp2of('f')) - if err != nil { - t.Fatal(err) - } - raw, _ := json.Marshal(map[string]any{"id": AskingID(key, asking), "headline": "mesh-mail is held", - "explanation": "its health failed", "who": "operator", "expires": "2026-10-30T00:00:00Z", "on-expiry": "it stays held", - "options": []map[string]string{{"id": "release", "label": "Release"}, {"id": "keep", "label": "Keep held"}}}) - return key, AssignerAsk{Asking: asking, Ask: raw} -} - -// 1297/B1c: the assigner's ask round-trips at its key; an ask of a kind that needs proof, a negative asking and a value -// carrying no router's ask are refused (its id and options are the teller's to check, ADR 0627 §4). -func TestAssignerAskRoundTrip(t *testing.T) { - key, a := holdAsk(t, 2) - data, err := EncodeAssignerAsk(key, a) - if err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(string(data), `{"v":1,"ask":`) { - t.Errorf("the ask is written as %s", data) - } - got, err := DecodeAssignerAsk(key, data) - a.V = Version - if err != nil || !SameAssignerAsk(got, a) { - t.Errorf("the ask came back %+v, %v", got, err) - } - stale := a - stale.Asking = -1 - if _, err := EncodeAssignerAsk(key, stale); err == nil { - t.Errorf("a negative asking was accepted") - } - merge, _ := MergeAskKey("g", []Head{{Repository: "novox/hq", Number: 2, Head: strings.Repeat("2", 40)}}) - if _, err := EncodeAssignerAsk(merge, AssignerAsk{Ask: a.Ask}); err == nil { - t.Errorf("the assigner was let write a merge's ask") - } - for _, raw := range []string{"", "null", "[1]"} { - none := a - none.Ask = json.RawMessage(raw) - if _, err := EncodeAssignerAsk(key, none); err == nil || !strings.Contains(err.Error(), "no router's ask") { - t.Errorf("a value carrying %q as its ask: %v", raw, err) - } - } - spaced := a - spaced.Ask = json.RawMessage(" " + string(a.Ask) + "\n") - if !SameAssignerAsk(spaced, a) { - t.Errorf("the same ask written with spaces is another") - } -} - -// 1297/B1c: a view answer fits only the asking it names: an answer to an earlier asking of the same key is refused, -// naming both. Whether the option is offered, at the level acknowledge, the teller reads (ADR 0627 §4). -func TestViewAnswerFits(t *testing.T) { - key, first := holdAsk(t, 0) - _, second := holdAsk(t, 2) - answer := ViewAnswer{Asking: AskingID(key, 0), Option: "release"} - if err := ViewAnswerFits(answer, key, first); err != nil { - t.Errorf("the answer to the first asking does not fit it: %v", err) - } - if err := ViewAnswerFits(answer, key, second); err == nil || !strings.Contains(err.Error(), AskingID(key, 0)) || !strings.Contains(err.Error(), AskingID(key, 2)) { - t.Errorf("a stale answer fits the later asking, or the refusal names neither: %v", err) - } -} diff --git a/go/delivery/want_composed_test.go b/go/delivery/want_composed_test.go deleted file mode 100644 index 701022c..0000000 --- a/go/delivery/want_composed_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package delivery - -import ( - "strings" - "testing" -) - -func hashOf(c byte) string { return "sha256:" + digestOf(c) } - -// 1943/AC3: a want naming its fingerprint and the digest of the composition it allows round-trips, and so does one -// carrying the answer to a failed backup, and from ADR 0547's second milestone one naming the build alone or the yes -// a person gave; a digest not of the sha256 -// form, a removed want naming a digest, an unknown backup answer and a rule past the eighth are refused. -func TestAWantNamesItsComposition(t *testing.T) { - for _, w := range []Want{ - {FP: fp2of('a'), Composed: hashOf('1')}, - {FP: fp2of('a'), Composed: hashOf('1'), Backup: BackupAgain}, - {FP: fp2of('a'), Composed: hashOf('1'), Backup: BackupSkip, Words: "the operator: go on without a backup"}, - {FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: RuleComposed, Words: "no composition of b for ace yet"}, - // ADR 0570 §4 (issue 1954 D1): held for a provision its new build requires, its words naming the key. - {FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: RuleProvisions, - Words: "the database of keycloak on novox is asked of postgres (novox.keycloak.postgres-database)"}, - {Removed: true}, - // ADR 0547 §2, §3 (issue 1126): from the second milestone a want names the build alone, and the yes a person gave. - {FP: fp2of('a'), Self: true}, - {FP: fp2of('a'), Self: true, Yes: hashOf('3')}, - // ADR 0601 §1: the yes the release's Approve gave names the release. - {FP: fp2of('a'), Self: true, Yes: hashOf('3'), Release: "feat/l3b-bus"}, - } { - raw, err := EncodeWant(w) - if err != nil { - t.Fatalf("EncodeWant(%+v): %v", w, err) - } - back, err := DecodeWant(raw) - w.V = Version - if err != nil || !SameWant(back, w) { - t.Errorf("want %+v came back %+v, %v", w, back, err) - } - } - refusals := []struct { - w Want - why string - }{ - {Want{FP: fp2of('a')}, "names no composition"}, - {Want{FP: fp2of('a'), Self: true, Composed: hashOf('1')}, "names a composition"}, - {Want{FP: fp2of('a'), Self: true, Yes: "sha256:12"}, "a want's yes allows"}, - {Want{Removed: true, Self: true}, "removed want names no"}, - {Want{Removed: true, Release: "feat/l3b-bus"}, "removed want names no"}, - {Want{FP: fp2of('a'), Self: true, Release: "feat/l3b-bus"}, "and no yes it gave"}, - {Want{FP: fp2of('a'), Composed: "sha256:12"}, "not sha256"}, - {Want{Removed: true, Composed: hashOf('1')}, "removed want names no"}, - {Want{FP: fp2of('a'), Composed: hashOf('1'), Backup: "later"}, `backup answer "later"`}, - {Want{Removed: true, Backup: BackupAgain}, "removed want names no"}, - {Want{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 11}, "rule 11"}, - } - for _, r := range refusals { - if _, err := EncodeWant(r.w); err == nil || !strings.Contains(err.Error(), r.why) { - t.Errorf("EncodeWant(%+v) = %v, want a refusal saying %q", r.w, err, r.why) - } - } - if _, err := DecodeWant([]byte(`{"v":1,"fp":"` + fp2of('a') + `"}`)); err == nil || !strings.Contains(err.Error(), "names no composition") { - t.Errorf("a want of release 5, naming no composition and not the node's own, was read: %v", err) - } - if RuleComposed != 7 || RuleProvisions != 8 { - t.Errorf("RuleComposed = %d and RuleProvisions = %d, want the seventh and the eighth", RuleComposed, RuleProvisions) - } -} - -// 1943/AC3: the node's own want is `want..node` and round-trips through its parser, while a module named node is -// still refused a want key; its value names the digest of the node's own composition and no fingerprint. -func TestTheNodesOwnWant(t *testing.T) { - key, err := WantNodeKey("ace") - if err != nil || key != "want.ace.node" { - t.Fatalf("WantNodeKey = %q, %v", key, err) - } - if node, err := ParseWantNodeKey(key); err != nil || node != "ace" { - t.Errorf("ParseWantNodeKey(%q) = %q, %v", key, node, err) - } - if _, err := WantKey("ace", "node"); err == nil { - t.Errorf(`WantKey(ace, "node") accepted`) - } - if _, _, err := ParseWantKey(key); err == nil { - t.Errorf("ParseWantKey read the node's own want as a module's") - } - for _, bad := range []string{"want.ace.mesh-mail", "want.node", "ace.node", "want.a.b.node", ""} { - if _, err := ParseWantNodeKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { - t.Errorf("ParseWantNodeKey(%q) = %v, want a refusal naming it", bad, err) - } - } - if _, err := WantNodeKey(""); err == nil { - t.Errorf("WantNodeKey(\"\") accepted") - } - w := NodeWant{Composed: hashOf('2'), Words: "the node's own composition"} - raw, err := EncodeNodeWant(w) - if err != nil { - t.Fatal(err) - } - back, err := DecodeNodeWant(raw) - w.V = Version - if err != nil || back != w { - t.Errorf("node want came back %+v, %v", back, err) - } - for _, bad := range []NodeWant{{}, {Composed: "sha256:1"}, {Self: true, Composed: hashOf('2')}} { - if _, err := EncodeNodeWant(bad); err == nil { - t.Errorf("EncodeNodeWant(%+v) accepted", bad) - } - } - // ADR 0547 §2 (issue 1126): from the second milestone the node composes its own composition, and its want names none. - if raw, err := EncodeNodeWant(NodeWant{Self: true, Words: "the node composes its own"}); err != nil { - t.Errorf("a node want naming no composition: %v", err) - } else if back, err := DecodeNodeWant(raw); err != nil || back.Composed != "" { - t.Errorf("a node want naming no composition came back %+v, %v", back, err) - } - if _, err := DecodeNodeWant([]byte(`{"v":1,"composed":"` + hashOf('2') + `","fp":"` + fp2of('a') + `"}`)); err == nil { - t.Errorf("a node want naming a fingerprint was read") - } -} - -// 1943/AC3: a have held after a failed backup, naming the composition it runs, round-trips; so does the node's own have -// naming its composition; a have's digest not of the sha256 form is refused. -func TestAHaveNamesItsComposition(t *testing.T) { - for _, h := range []Have{ - {FP: fp2of('a'), Composed: hashOf('3'), Phase: PhaseHeld, Health: HealthOK, Detail: "the backup failed: no space left"}, - {FP: fp2of('a'), Composed: hashOf('3'), Phase: PhaseRunning, Health: HealthOK}, - } { - raw, err := EncodeHave(h) - if err != nil { - t.Fatalf("EncodeHave(%+v): %v", h, err) - } - back, err := DecodeHave(raw) - h.V = Version - if err != nil || !SameHave(back, h) { - t.Errorf("have %+v came back %+v, %v", h, back, err) - } - } - if _, err := EncodeHave(Have{Composed: "sha256:x", Phase: PhaseRunning, Health: HealthOK}); err == nil || !strings.Contains(err.Error(), "not sha256") { - t.Errorf("a have naming a bad digest = %v", err) - } - n := HaveNode{Composed: hashOf('4')} - raw, err := EncodeHaveNode(n) - if err != nil { - t.Fatal(err) - } - back, err := DecodeHaveNode(raw) - n.V = Version - if err != nil || !SameHaveNode(back, n) { - t.Errorf("node have came back %+v, %v", back, err) - } - if SameHaveNode(n, HaveNode{V: Version, Composed: hashOf('5')}) { - t.Errorf("node haves naming two compositions are the same") - } -} - -// 1943/AC3: the assigner's backup ask (back up again, or go on without one, ADR 0539 clause 16) and its record ask (a -// move a person must allow, clause 14) key by node, module and fingerprint or composition, round-trip through -// ParseAnyAskKey, and the view may answer both. -func TestTheBackupAndRecordAsks(t *testing.T) { - fp, digest := fp2of('a'), hashOf('6') - backup, err := BackupAskKey("ace", "mesh-mail", fp) - if err != nil || backup != "backup.ace.mesh-mail."+strings.Repeat("a", 64) { - t.Fatalf("BackupAskKey = %q, %v", backup, err) - } - record, err := RecordAskKey("ace", "mesh-mail", digest) - if err != nil || record != "record.ace.mesh-mail."+strings.Repeat("6", 64) { - t.Fatalf("RecordAskKey = %q, %v", record, err) - } - if got, err := ParseAnyAskKey(backup); err != nil || got != (AskKey{Kind: AskKindBackup, Node: "ace", Module: "mesh-mail", FP: fp}) { - t.Errorf("ParseAnyAskKey(%q) = %+v, %v", backup, got, err) - } - if got, err := ParseAnyAskKey(record); err != nil || got != (AskKey{Kind: AskKindRecord, Node: "ace", Module: "mesh-mail", Composed: digest}) { - t.Errorf("ParseAnyAskKey(%q) = %+v, %v", record, got, err) - } - for _, kind := range []string{AskKindBackup, AskKindRecord} { - if proof, err := AskNeedsProof(kind); err != nil || proof { - t.Errorf("AskNeedsProof(%s) = %v, %v; the view may answer it", kind, proof, err) - } - } - for _, k := range []string{backup, record} { - if _, err := ViewAnswerKey(k); err != nil { - t.Errorf("the view may not answer %s: %v", k, err) - } - } - for _, bad := range [][3]string{{"", "mesh-mail", fp}, {"ace", "node", fp}, {"ace", "mesh-mail", "src2:1"}} { - if _, err := BackupAskKey(bad[0], bad[1], bad[2]); err == nil { - t.Errorf("BackupAskKey(%v) accepted", bad) - } - } - if _, err := RecordAskKey("ace", "mesh-mail", "sha256:1"); err == nil { - t.Errorf("a record ask naming a bad digest was keyed") - } - for _, bad := range []string{"backup.ace.mesh-mail", "record.ace.mesh-mail.12", "record.ace." + strings.Repeat("6", 64)} { - if _, err := ParseAnyAskKey(bad); err == nil { - t.Errorf("ParseAnyAskKey(%q) accepted", bad) - } - } -} diff --git a/go/delivery/window.go b/go/delivery/window.go deleted file mode 100644 index d5c7a00..0000000 --- a/go/delivery/window.go +++ /dev/null @@ -1,64 +0,0 @@ -package delivery - -import ( - "slices" - "sort" - "time" -) - -// WindowHeld is the part of a window every window keeper shares (design 51: distribution's old seat name and direct -// writes, the builder's old reference form): whether it is open, the nodes holding it and since when, and when it last -// closed and who held it then. A keeper embeds it in its own record, so its JSON is the record's. -type WindowHeld struct { - Open bool `json:"open"` - Holding []string `json:"holding,omitempty"` - Since map[string]time.Time `json:"since,omitempty"` - Closed time.Time `json:"closed,omitzero"` - // LastHolding are the nodes that held it last, said when it closes. - LastHolding []string `json:"last_holding,omitempty"` -} - -// StepWindow is the one rule every window keeper steps its window by (security-upgrade review F3, the second of a -// kind): named is every node the read named, dropped ones included, and holding those of them that hold the window at -// now. A read that names no node judges nothing, as a first view's empty answer at revision 0 is no empty state (ADR -// 0615): an empty node_haves or a placement not yet read never closes a window, and the window stays as it was. A read -// naming nodes, none holding, closes an open window (closed true, once); one naming a holder opens it, each holder's -// since kept from w. overdue are the holders whose since is at least findingAfter before now (the keeper's finding, -// cleared for every other node); a read naming no node keeps w's holders' overdue as they stand at now. -func StepWindow(w WindowHeld, named, holding []string, now time.Time, findingAfter time.Duration) (next WindowHeld, closed bool, overdue []string) { - now = now.UTC() - if len(named) == 0 { - next = w - for _, n := range w.Holding { - if since, ok := w.Since[n]; ok && now.Sub(since) >= findingAfter { - overdue = append(overdue, n) - } - } - return next, false, overdue - } - if len(holding) == 0 { - next = WindowHeld{Closed: w.Closed, LastHolding: w.LastHolding} - if w.Open { - next.Closed, next.LastHolding = now, slices.Clone(w.Holding) - if next.LastHolding == nil { - next.LastHolding = []string{} - } - return next, true, nil - } - return next, false, nil - } - held := slices.Clone(holding) - sort.Strings(held) - next = WindowHeld{Open: true, Holding: held, Since: map[string]time.Time{}, Closed: w.Closed, LastHolding: w.LastHolding} - for _, n := range held { - since, had := w.Since[n] - if !had { - since = now - } - next.Since[n] = since - if now.Sub(since) >= findingAfter { - overdue = append(overdue, n) - } - } - return next, false, overdue -} diff --git a/go/delivery/window_test.go b/go/delivery/window_test.go deleted file mode 100644 index 8fd1ade..0000000 --- a/go/delivery/window_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package delivery - -import ( - "slices" - "testing" - "time" -) - -// The one rule every window keeper steps by (design 51; security-upgrade review F3): a read naming no node judges -// nothing, so an empty node_haves or an unread placement never closes a window. -func TestStepWindow(t *testing.T) { - now := time.Date(2026, 11, 4, 17, 0, 0, 0, time.UTC) - week := 7 * 24 * time.Hour - long := now.Add(-8 * 24 * time.Hour) - open := WindowHeld{Open: true, Holding: []string{"n1"}, Since: map[string]time.Time{"n1": long}} - for _, c := range []struct { - name string - w WindowHeld - named, holding []string - open, closed bool - holds, overdue []string - }{ - {"a read naming no node leaves an open window open, its holder still overdue", open, nil, nil, true, false, []string{"n1"}, []string{"n1"}}, - {"a read naming no node leaves a closed window closed", WindowHeld{Closed: long}, nil, nil, false, false, nil, nil}, - {"a read naming nodes, none holding, closes an open window once", open, []string{"n1", "n2"}, nil, false, true, nil, nil}, - {"a closed window a read finds unheld stays closed, said no more", WindowHeld{Closed: long, LastHolding: []string{"n1"}}, []string{"n1"}, nil, false, false, nil, nil}, - {"a new holder opens it, since now, not overdue", WindowHeld{}, []string{"n1", "n2"}, []string{"n2"}, true, false, []string{"n2"}, nil}, - {"a holder keeps its since and is overdue past the bound", open, []string{"n1", "n2"}, []string{"n2", "n1"}, true, false, []string{"n1", "n2"}, []string{"n1"}}, - } { - next, closed, overdue := StepWindow(c.w, c.named, c.holding, now, week) - if next.Open != c.open || closed != c.closed || !slices.Equal(next.Holding, c.holds) || !slices.Equal(overdue, c.overdue) { - t.Errorf("%s: open %v closed %v holding %v overdue %v", c.name, next.Open, closed, next.Holding, overdue) - } - if closed && (!next.Closed.Equal(now) || !slices.Equal(next.LastHolding, c.w.Holding)) { - t.Errorf("%s: closed at %v, last held by %v", c.name, next.Closed, next.LastHolding) - } - if !c.open && !closed && !next.Closed.Equal(c.w.Closed) { - t.Errorf("%s: its last close moved to %v", c.name, next.Closed) - } - } -} diff --git a/go/packages_list_test.go b/go/packages_list_test.go index b9a8675..bc19c12 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -24,7 +24,7 @@ import ( // request: does every module share it, or is it a shape more than one part reads? var meshSDKPackages = []string{ ".", "announce", "announcetest", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", - "declaration", "delivery", "forgekey", "health", "internal/beside", "keys", "kvlimit", "membership", "motion", + "declaration", "forgekey", "health", "internal/beside", "keys", "kvlimit", "membership", "motion", "objectlink", "outward", "overlay", "pernode", "process", "sdkversion", "seal", "seatdef", "seatlist", "seatlist/seatlisttest", "testbus", "views", "views/viewstest", } @@ -39,16 +39,13 @@ var clientSeats = []string{"operator-channel", "mesh-vault", "node-engine", "npm var moduleWord = regexp.MustCompile(`\bmesh-(?:delivery|grants|vault|host|config|provisioner|broker|knowledge|enrolment)\b[-a-z]*|\b(?:indexer|assigner|teller|watchman|checker|builder)(?:-views|_read_)`) // notInSDK are the packages that moved to a seat's client or contract (ADR 0627 §1, §4) and may not come back. -var notInSDK = []string{"asks", "asker", "publish", "composer", "parts", "provider", "handacts", "channel", "provisions"} +var notInSDK = []string{"asks", "asker", "publish", "composer", "parts", "provider", "handacts", "channel", "provisions", "buildstep", "delivery"} // untilTheJoin is what ADR 0627 still moves at the group's join, each `: `, naming what the checks below // would refuse; it only shrinks. The vault module's name in delivery stays past this milestone's pin // (issue 1966 note 28) and goes in the next milestone, when the vault's answers are its contract's. var untilTheJoin = []string{ - "delivery/signers.go: mesh-vault", // Names of whole seats in packages that leave in S-7 (delivery, buscompose's grants-part names), with their packages. - "delivery/builder.go: container-image-registry", - "delivery/builder.go: oci-artifact-registry", } // computesNot are the functions whose computation is one owner's alone (ADR 0593 §2, §7): the indexer computes a diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index 086c3da..ed9586a 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -28,15 +28,6 @@ catalogue/seat_dependencies.go: node-package-manager catalogue/seat_dependencies.go: node-service-manager catalogue/shares.go: node-mounts catalogue/shares.go: node-nfs-server -delivery/builder.go: container-image-registry -delivery/builder.go: oci-artifact-registry -delivery/checker.go: mesh-delivery-checker -delivery/composed.go: mesh-provisioner -delivery/held.go: mesh.seat.mesh-artifact-sweep.event. -delivery/placement.go: mesh-self-check -delivery/provided.go: the-private-network -delivery/signers.go: mesh-vault -delivery/stepasks.go: node-build-node health/findings.go: mesh-conditions health/findings.go: mesh-healers health/findings.go: mesh-self-check