diff --git a/go/delivery/assigner.go b/go/delivery/assigner.go index 4d6f5c3..46560f2 100644 --- a/go/delivery/assigner.go +++ b/go/delivery/assigner.go @@ -285,3 +285,124 @@ func DecodeDifference(data []byte) (Difference, error) { // 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 index 84379df..3a2d5cc 100644 --- a/go/delivery/assigner_test.go +++ b/go/delivery/assigner_test.go @@ -155,3 +155,111 @@ func TestAssignerValuesRefused(t *testing.T) { 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/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) }