Merge branch 'feat/r4g-assigner' into feat/r4g
This commit is contained in:
@@ -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.<node>.<module>` in its bucket wants, and beside it
|
||||
// `held.<module>.<fingerprint>` 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
|
||||
// `<node>.<module>` 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.<module>.<fp>` is the key
|
||||
// `<module>.<fp>` in its builds bucket): the differences bucket's keys are `<node>.<module>`. The wants bucket holds
|
||||
// two kinds of key, so each keeps its word: `want.<node>.<module>` and `held.<module>.<fingerprint hex>`.
|
||||
const (
|
||||
// AssignerModule is the module of the assigner part.
|
||||
AssignerModule = "mesh-delivery-assigner"
|
||||
// AssignerWantsBucket is the assigner's bucket of wants and holds, `<owner module>_<name>`.
|
||||
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.<node>.<module>`. A module named
|
||||
// `node` is refused, since `want.<node>.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.<node>.<module>", key)
|
||||
}
|
||||
node, module, isNode, err := ParseHaveKey(rest)
|
||||
if err != nil || isNode {
|
||||
return "", "", fmt.Errorf("delivery: %q is not a want key, want.<node>.<module>", key)
|
||||
}
|
||||
return node, module, nil
|
||||
}
|
||||
|
||||
// HeldKey is the key, in AssignerWantsBucket, of a module's fingerprint the assigner holds: `held.<module>.<hex>`,
|
||||
// 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.<module>.<fingerprint hex>", 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: `<node>.<module>`.
|
||||
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, <node>.<module>", 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.<module>.<fp>)
|
||||
)
|
||||
|
||||
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, `<escaped group>.<heads hash>` (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, <group>.<heads hash>: %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) }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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 `<module>.<fingerprint hex>` 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:
|
||||
// `<module>.<hex>`, 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, <module>.<fingerprint hex>: %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) }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.<group>.<heads hash>`, 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.<module>.<fingerprint hex>`: release it or
|
||||
// keep it held) and the restore of a data module set back (`restore.<node>.<module>.<fingerprint hex>`: 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 `<ask key>.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.<module>.<hex>`.
|
||||
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.<node>.<module>.<hex>`.
|
||||
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.<node>.<module>.<fingerprint hex>"))
|
||||
}
|
||||
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: `<ask key>.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, <ask 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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user