diff --git a/go/delivery/assigner.go b/go/delivery/assigner.go new file mode 100644 index 0000000..46560f2 --- /dev/null +++ b/go/delivery/assigner.go @@ -0,0 +1,408 @@ +package delivery + +import ( + "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. +// +// **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 +} + +// ParseWantKey is the inverse of WantKey; it refuses a held key, a have 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 six rules of ADR 0429 §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..) +) + +func checkRule(rule int, of string) error { + if rule < RuleBuildDone || rule > RuleGrant { + return fmt.Errorf("delivery: %s names the rule %d, which is not one of the six (1 to 6)", of, rule) + } + return nil +} + +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, or Removed for a module that left the +// node's modules (then FP is 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. 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"` + Removed bool `json:"removed,omitempty"` + Newest string `json:"newest,omitempty"` + Rule int `json:"rule,omitempty"` + Words string `json:"words,omitempty"` +} + +func (w Want) check() error { + if w.Removed { + if w.FP != "" || w.Newest != "" || w.Rule != 0 { + return fmt.Errorf("delivery: a removed want names no fingerprint and no rule") + } + return nil + } + if err := checkFingerprint(w.FP, "a want's fingerprint"); err != nil { + return err + } + 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 } + +// 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 +} + +// 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 new file mode 100644 index 0000000..3a2d5cc --- /dev/null +++ b/go/delivery/assigner_test.go @@ -0,0 +1,265 @@ +package delivery + +import ( + "errors" + "strings" + "testing" +) + +// 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, _ := SourceFingerprint(SourceInputs{Module: "m", Tree: "t"}) + 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')}, + {FP: fp2of('a'), Newest: fp2of('b'), Rule: RuleCanary, Words: "the canary ace does not run it healthy yet"}, + {Removed: true, Words: "the module left g14"}, + } + 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) + } + } + 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, _ := SourceFingerprint(SourceInputs{Module: "m", Tree: "t"}) + 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'), Newest: fp2of('b'), Rule: 7}, + } { + 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: 9}, + {WantRemoved: true, Want: fp2of('a'), Rule: 1}, + } { + if _, err := EncodeDifference(d); err == nil { + t.Errorf("EncodeDifference(%+v) accepted", d) + } + } + 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 TestControllerGrant(t *testing.T) { + fp := fp2of('c') + key, err := ControllerGrantKey("mesh-delivery-assigner", fp) + if err != nil || key != "mesh-delivery-assigner."+strings.Repeat("c", 64) { + t.Fatalf("ControllerGrantKey = %q, %v", key, err) + } + module, back, err := ParseControllerGrantKey(key) + if err != nil || module != "mesh-delivery-assigner" || back != fp { + t.Errorf("ParseControllerGrantKey = %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 := ParseControllerGrantKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParseControllerGrantKey(%q) = %v, want a refusal naming it", bad, err) + } + } + for _, g := range []ControllerGrant{{}, {Accounts: []string{"UABC", "UDEF"}}} { + data, err := EncodeControllerGrant(g) + if err != nil { + t.Fatal(err) + } + got, err := DecodeControllerGrant(data) + g.V = Version + if err != nil || !SameControllerGrant(got, g) { + t.Errorf("grant %+v came back %+v, %v", g, got, err) + } + } + if _, err := EncodeControllerGrant(ControllerGrant{Accounts: []string{""}}); err == nil { + t.Errorf("a grant naming an empty account was accepted") + } +} diff --git a/go/delivery/checker.go b/go/delivery/checker.go index 81c0dc9..679985b 100644 --- a/go/delivery/checker.go +++ b/go/delivery/checker.go @@ -96,10 +96,16 @@ type LevelResult struct { // 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 @@ -156,8 +162,8 @@ func (c *Composition) check() error { } switch c.State { case CompositionNotRun: - if c.Words != "" || c.SDK != "" || len(c.Machines) > 0 { - return fmt.Errorf("delivery: the verdict's composition is not run and says nothing of words, the mesh-sdk commit or machines") + 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 == "" { diff --git a/go/delivery/checker_test.go b/go/delivery/checker_test.go index 296da52..22d4611 100644 --- a/go/delivery/checker_test.go +++ b/go/delivery/checker_test.go @@ -262,6 +262,16 @@ func TestVerdictCarriesSkippedCompositionAndOverBound(t *testing.T) { 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"}} diff --git a/go/delivery/grants.go b/go/delivery/grants.go new file mode 100644 index 0000000..39e602c --- /dev/null +++ b/go/delivery/grants.go @@ -0,0 +1,75 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// The controller's grants (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6): when the controller has issued a module's +// bus credential for a fingerprint, it writes `.` in its bucket grants. The assigner reads +// it: a node's want moves to a fingerprint only once its grant exists. Only the controller writes the bucket. +// +// The names say Controller, since the catalogue's GrantKey (catalogue/streams.go) is a stream's grant pattern, unrelated. +const ( + // ControllerGrantsBucket is the controller's bucket of issued credentials by fingerprint. + ControllerGrantsBucket = "mesh-controller_grants" + // ControllerGrantsHistory is the revisions the bucket keeps per key. + ControllerGrantsHistory = 5 +) + +// ControllerGrantKey is the key, in ControllerGrantsBucket, of a module's credential issued for a fingerprint: +// `.`, the hex being the fingerprint's key part (FingerprintKeyPart). +func ControllerGrantKey(module, fingerprint string) (string, error) { + return BuilderBuildKey(module, fingerprint) +} + +// ParseControllerGrantKey is the inverse of ControllerGrantKey; it refuses a want or held key, naming it. +func ParseControllerGrantKey(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 +} + +// ControllerGrant 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 ControllerGrant struct { + V int `json:"v"` + Accounts []string `json:"accounts,omitempty"` +} + +func (g ControllerGrant) 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 +} + +// EncodeControllerGrant is g as JSON at Version; it refuses an empty or spaced account. +func EncodeControllerGrant(g ControllerGrant) ([]byte, error) { + if err := g.check(); err != nil { + return nil, err + } + g.V = Version + return json.Marshal(g) +} + +// DecodeControllerGrant reads a grant; it refuses an unknown version (ErrUnknownVersion) and an empty or spaced account. +func DecodeControllerGrant(data []byte) (ControllerGrant, error) { + var g ControllerGrant + if err := decode(data, &g); err != nil { + return ControllerGrant{}, err + } + if err := g.check(); err != nil { + return ControllerGrant{}, err + } + return g, nil +} + +// SameControllerGrant says whether two grants are the same value. +func SameControllerGrant(a, b ControllerGrant) bool { return reflect.DeepEqual(a, b) } diff --git a/go/delivery/source.go b/go/delivery/source.go index 145b4e2..535f771 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -271,6 +271,10 @@ type CandidatePull struct { 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 { diff --git a/go/delivery/view_answers.go b/go/delivery/view_answers.go new file mode 100644 index 0000000..d8f15a3 --- /dev/null +++ b/go/delivery/view_answers.go @@ -0,0 +1,261 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "strings" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// 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" + + 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 Node, Module and FP. +type AskKey struct { + Kind string + Group string + Hash string + Node string + Module string + FP 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: + return false, nil + } + return false, fmt.Errorf("delivery: %q is not a kind of ask, %s, %s or %s", kind, AskKindMerge, AskKindHold, AskKindRestore) +} + +// 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 +} + +// ParseAnyAskKey reads an ask key of any kind (MergeAskKey, HoldAskKey, RestoreAskKey); 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 + } + _, 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. +type AssignerAsk struct { + V int `json:"v"` + Ask asks.Ask `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) + } + if want := AskingID(key, a.Asking); a.Ask.ID != want { + return fmt.Errorf("delivery: the ask's id %q is not %q, the id its key and asking %d give", a.Ask.ID, want, a.Asking) + } + if len(a.Ask.Options) == 0 { + return fmt.Errorf("delivery: the ask has no option") + } + return nil +} + +// 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, an id other than the one the key and the asking give, and an ask with no option. +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. +func SameAssignerAsk(a, b AssignerAsk) bool { + return a.V == b.V && a.Asking == b.Asking && a.Ask.Digest() == b.Ask.Digest() +} + +// 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 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) and an option the ask does not offer, +// naming both. +func ViewAnswerFits(a ViewAnswer, ask AssignerAsk) error { + if a.Asking != ask.Ask.ID { + return fmt.Errorf("delivery: the view's answer is to the asking %s, and the ask stands at the asking %s", a.Asking, ask.Ask.ID) + } + if _, ok := ask.Ask.Option(a.Option); !ok { + return fmt.Errorf("delivery: the view's answer chose %q, which the asking %s does not offer", a.Option, ask.Ask.ID) + } + 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 new file mode 100644 index 0000000..c069e01 --- /dev/null +++ b/go/delivery/view_answers_test.go @@ -0,0 +1,161 @@ +package delivery + +import ( + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// 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) + } + 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}, + } { + 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", ""} { + 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) + } + return key, AssignerAsk{Asking: asking, Ask: asks.Ask{ID: AskingID(key, asking), Headline: "mesh-mail is held", Explanation: "its health failed", + Options: []asks.Option{{ID: "release", Label: "Release"}, {ID: "keep", Label: "Keep held"}}, Who: "operator", + Expires: time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC), OnExpiry: "it stays held"}} +} + +// 1297/B1c: the assigner's ask round-trips at its key; an id another key or asking gives, an ask of a kind that needs +// proof and one with no option are refused. +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) + } + other, _ := HoldAskKey("mesh-web", fp2of('f')) + if _, err := DecodeAssignerAsk(other, data); err == nil { + t.Errorf("an ask was read at another key") + } + stale := a + stale.Asking = 3 + if _, err := EncodeAssignerAsk(key, stale); err == nil { + t.Errorf("an ask whose id is another asking's was accepted") + } + merge, _ := MergeAskKey("g", []Head{{Repository: "novox/hq", Number: 2, Head: strings.Repeat("2", 40)}}) + if _, err := EncodeAssignerAsk(merge, AssignerAsk{Ask: asks.Ask{ID: AskingID(merge, 0), Options: a.Ask.Options}}); err == nil { + t.Errorf("the assigner was let write a merge's ask") + } + none := a + none.Ask.Options = nil + if _, err := EncodeAssignerAsk(key, none); err == nil { + t.Errorf("an ask with no option was accepted") + } +} + +// 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, and so is an option the ask does not offer. +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, first); err != nil { + t.Errorf("the answer to the first asking does not fit it: %v", err) + } + if err := ViewAnswerFits(answer, second); err == nil || !strings.Contains(err.Error(), first.Ask.ID) || !strings.Contains(err.Error(), second.Ask.ID) { + t.Errorf("a stale answer fits the later asking, or the refusal names neither: %v", err) + } + if err := ViewAnswerFits(ViewAnswer{Asking: first.Ask.ID, Option: "restore"}, first); err == nil { + t.Errorf("an option the ask does not offer fits") + } +}