Delivery's release 3 keys and the source fingerprint, in one place the parts read (design 47 R3a: issues 1200-1203) #37
@@ -0,0 +1,149 @@
|
||||
package asks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ConditionSubject is where a module says a condition to the operator: a by-caller accept of the operator-channel
|
||||
// seat, `mesh.seat.operator-channel.accept.condition.<publisher>` (novox/hq ADR 0437 §9). The router reads the
|
||||
// verb at the fifth part and the caller at the sixth, so the publisher is one token, the module's own name; it is
|
||||
// refused when empty or when it holds a dot, a wildcard or whitespace.
|
||||
func ConditionSubject(publisher string) (string, error) {
|
||||
if publisher == "" {
|
||||
return "", errors.New("asks: the condition's publisher is empty")
|
||||
}
|
||||
if strings.ContainsAny(publisher, ".*> \t\r\n") || strings.ContainsFunc(publisher, func(r rune) bool { return r <= ' ' }) {
|
||||
return "", fmt.Errorf("asks: the condition's publisher %q is not one token: it holds a dot, a wildcard or whitespace", publisher)
|
||||
}
|
||||
return "mesh.seat." + Seat + ".accept.condition." + publisher, nil
|
||||
}
|
||||
|
||||
// ConditionVersion is the version of the condition value this package writes and the only one it reads.
|
||||
const ConditionVersion = 1
|
||||
|
||||
// ErrUnknownConditionVersion is what DecodeCondition returns for a value whose version is missing or not
|
||||
// ConditionVersion. Test it with errors.Is.
|
||||
var ErrUnknownConditionVersion = errors.New("asks: unknown condition version")
|
||||
|
||||
// The words a condition's explanation opens with (novox/hq ADR 0253 §1).
|
||||
const (
|
||||
NothingToDo = "Nothing for you to do."
|
||||
NeedsYou = "Needs you: "
|
||||
)
|
||||
|
||||
// The severities and states of a condition.
|
||||
const (
|
||||
SeverityUrgent = "urgent"
|
||||
SeverityWarning = "warning"
|
||||
StateRaised = "raised"
|
||||
StateCleared = "cleared"
|
||||
)
|
||||
|
||||
// Scope is what a condition is about, under the controller's names.
|
||||
type Scope struct {
|
||||
Scope string `json:"scope"`
|
||||
ID string `json:"id"`
|
||||
Machine string `json:"machine,omitempty"`
|
||||
}
|
||||
|
||||
// Action is one answer a notification offers, under the controller's names.
|
||||
type Action struct {
|
||||
Label string `json:"label"`
|
||||
Verb string `json:"verb"`
|
||||
Machine string `json:"machine,omitempty"`
|
||||
Arguments map[string]string `json:"arguments,omitempty"`
|
||||
Level Level `json:"level,omitempty"`
|
||||
}
|
||||
|
||||
// Condition is a condition a module says to the operator, under the names of the controller's conditions: its key,
|
||||
// severity, subject, state (raised or cleared), summary and the plain words of ADR 0253. A cleared condition needs
|
||||
// only its key, subject and state. The router alone chooses the channels.
|
||||
type Condition struct {
|
||||
V int `json:"v"`
|
||||
Key string `json:"key"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
Subject Scope `json:"subject"`
|
||||
State string `json:"state"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Needs string `json:"needs,omitempty"`
|
||||
Explanation string `json:"explanation,omitempty"`
|
||||
Resolved string `json:"resolved,omitempty"`
|
||||
Actions []Action `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
func (c Condition) check() error {
|
||||
if c.Key == "" {
|
||||
return errors.New("asks: a condition has no key")
|
||||
}
|
||||
if c.Subject.Scope == "" || c.Subject.ID == "" {
|
||||
return errors.New("asks: a condition's subject needs its scope and id")
|
||||
}
|
||||
switch c.State {
|
||||
case StateCleared:
|
||||
return nil
|
||||
case StateRaised:
|
||||
default:
|
||||
return fmt.Errorf("asks: the condition's state %q is not raised or cleared", c.State)
|
||||
}
|
||||
switch c.Severity {
|
||||
case SeverityUrgent, SeverityWarning:
|
||||
default:
|
||||
return fmt.Errorf("asks: the condition's severity %q is not urgent or warning", c.Severity)
|
||||
}
|
||||
if c.Headline == "" {
|
||||
return errors.New("asks: a raised condition has no headline")
|
||||
}
|
||||
if n := len([]rune(c.Headline)); n > HeadlineLength {
|
||||
return fmt.Errorf("asks: the condition's headline is %d characters, more than %d", n, HeadlineLength)
|
||||
}
|
||||
verdict := NothingToDo
|
||||
if c.Needs != "" {
|
||||
verdict = NeedsYou + c.Needs
|
||||
}
|
||||
if !strings.HasPrefix(c.Explanation, verdict) {
|
||||
return fmt.Errorf("asks: the condition's explanation must open with %q", verdict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeCondition is c as JSON at ConditionVersion; it refuses what ADR 0253 and ADR 0437 §9 refuse.
|
||||
func EncodeCondition(c Condition) ([]byte, error) {
|
||||
if err := c.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.V = ConditionVersion
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
// DecodeCondition reads a condition; it refuses an unknown version (ErrUnknownConditionVersion) and what
|
||||
// EncodeCondition refuses.
|
||||
func DecodeCondition(data []byte) (Condition, error) {
|
||||
var probe struct {
|
||||
V *int `json:"v"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &probe); err != nil {
|
||||
return Condition{}, fmt.Errorf("asks: the condition is not JSON: %w", err)
|
||||
}
|
||||
if probe.V == nil {
|
||||
return Condition{}, fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownConditionVersion, ConditionVersion)
|
||||
}
|
||||
if *probe.V != ConditionVersion {
|
||||
return Condition{}, fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownConditionVersion, *probe.V, ConditionVersion)
|
||||
}
|
||||
var c Condition
|
||||
if err := json.Unmarshal(data, &c); err != nil {
|
||||
return Condition{}, fmt.Errorf("asks: the condition does not fit its shape: %w", err)
|
||||
}
|
||||
if err := c.check(); err != nil {
|
||||
return Condition{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SameCondition says whether two conditions are the same value.
|
||||
func SameCondition(a, b Condition) bool { return reflect.DeepEqual(a, b) }
|
||||
@@ -0,0 +1,143 @@
|
||||
package asks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConditionSubject(t *testing.T) { // 1202/AC1
|
||||
s, err := ConditionSubject("mesh-delivery-teller")
|
||||
if err != nil || s != "mesh.seat.operator-channel.accept.condition.mesh-delivery-teller" {
|
||||
t.Fatalf("subject = %q, %v", s, err)
|
||||
}
|
||||
// The router splits an accept subject in six parts and reads the verb at 4 and the caller at 5.
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) != 6 || parts[4] != "condition" || parts[5] != "mesh-delivery-teller" {
|
||||
t.Errorf("subject %q does not keep the router's shape: %v", s, parts)
|
||||
}
|
||||
for _, bad := range []string{"", "a.b", "a*", "a>", "a b", "a\tb", "a\nb"} {
|
||||
if _, err := ConditionSubject(bad); err == nil {
|
||||
t.Errorf("publisher %q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func raisedCondition() Condition {
|
||||
return Condition{
|
||||
Key: "delivery.stuck.mesh-mail", Severity: "warning",
|
||||
Subject: Scope{Scope: "module", ID: "mesh-mail", Machine: "ace"}, State: "raised",
|
||||
Summary: "plan 12 stopped", Headline: "Delivery of mesh-mail is stuck",
|
||||
Needs: "approve the merge.", Explanation: "Needs you: approve the merge. The plan waits.",
|
||||
Resolved: "The delivery of mesh-mail moved on.",
|
||||
Actions: []Action{{Label: "Approve", Verb: "mesh-controller.assign", Machine: "ace",
|
||||
Arguments: map[string]string{"why": ""}, Level: Approve}},
|
||||
}
|
||||
}
|
||||
|
||||
func clearedCondition() Condition {
|
||||
return Condition{Key: "delivery.stuck.mesh-mail", Subject: Scope{Scope: "module", ID: "mesh-mail"}, State: "cleared"}
|
||||
}
|
||||
|
||||
func TestConditionRoundTrip(t *testing.T) { // 1202/AC2
|
||||
for _, c := range []Condition{raisedCondition(), clearedCondition()} {
|
||||
data, err := EncodeCondition(c)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.State, err)
|
||||
}
|
||||
var probe map[string]any
|
||||
if err := json.Unmarshal(data, &probe); err != nil || probe["v"] != float64(1) {
|
||||
t.Fatalf("%s: %s does not carry v:1", c.State, data)
|
||||
}
|
||||
c.V = ConditionVersion
|
||||
got, err := DecodeCondition(data)
|
||||
if err != nil || !SameCondition(got, c) {
|
||||
t.Errorf("%s round trip: %+v %v", c.State, got, err)
|
||||
}
|
||||
}
|
||||
data, _ := EncodeCondition(raisedCondition())
|
||||
var probe map[string]any
|
||||
_ = json.Unmarshal(data, &probe)
|
||||
for _, name := range []string{"key", "severity", "subject", "summary", "headline", "needs", "explanation", "resolved", "actions", "state"} {
|
||||
if _, ok := probe[name]; !ok {
|
||||
t.Errorf("the JSON has no %q: %s", name, data)
|
||||
}
|
||||
}
|
||||
subject := probe["subject"].(map[string]any)
|
||||
for _, name := range []string{"scope", "id", "machine"} {
|
||||
if _, ok := subject[name]; !ok {
|
||||
t.Errorf("the subject has no %q: %s", name, data)
|
||||
}
|
||||
}
|
||||
action := probe["actions"].([]any)[0].(map[string]any)
|
||||
for _, name := range []string{"label", "verb", "machine", "arguments", "level"} {
|
||||
if _, ok := action[name]; !ok {
|
||||
t.Errorf("the action has no %q: %s", name, data)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{`{"key":"k"}`, `{"v":2,"key":"k"}`} {
|
||||
if _, err := DecodeCondition([]byte(in)); !errors.Is(err, ErrUnknownConditionVersion) {
|
||||
t.Errorf("decode of %s = %v, want ErrUnknownConditionVersion", in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionVerdict(t *testing.T) { // 1202/AC3
|
||||
c := raisedCondition()
|
||||
c.Needs, c.Explanation = "", "The plan waits."
|
||||
_, err := EncodeCondition(c)
|
||||
if err == nil || !strings.Contains(err.Error(), "Nothing for you to do.") {
|
||||
t.Errorf("empty needs without its verdict: %v", err)
|
||||
}
|
||||
c.Explanation = "Nothing for you to do. The plan waits."
|
||||
if _, err := EncodeCondition(c); err != nil {
|
||||
t.Errorf("empty needs with its verdict refused: %v", err)
|
||||
}
|
||||
c = raisedCondition()
|
||||
c.Explanation = "Nothing for you to do. The plan waits."
|
||||
_, err = EncodeCondition(c)
|
||||
if err == nil || !strings.Contains(err.Error(), "Needs you: approve the merge.") {
|
||||
t.Errorf("needs set with the wrong verdict: %v", err)
|
||||
}
|
||||
c.Explanation = "Needs you: something else. The plan waits."
|
||||
if _, err = EncodeCondition(c); err == nil {
|
||||
t.Error("needs set with other needs in the verdict accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionRefused(t *testing.T) { // 1202/AC4
|
||||
mutate := func(f func(*Condition)) error {
|
||||
c := raisedCondition()
|
||||
f(&c)
|
||||
_, err := EncodeCondition(c)
|
||||
return err
|
||||
}
|
||||
for name, f := range map[string]func(*Condition){
|
||||
"severity": func(c *Condition) { c.Severity = "critical" },
|
||||
"state": func(c *Condition) { c.State = "open" },
|
||||
"key": func(c *Condition) { c.Key = "" },
|
||||
"headline": func(c *Condition) { c.Headline = "" },
|
||||
"long headline": func(c *Condition) { c.Headline = strings.Repeat("h", HeadlineLength+1) },
|
||||
} {
|
||||
if err := mutate(f); err == nil {
|
||||
t.Errorf("a condition with a bad %s accepted", name)
|
||||
}
|
||||
}
|
||||
if err := mutate(func(c *Condition) { c.Headline = strings.Repeat("h", HeadlineLength) }); err != nil {
|
||||
t.Errorf("a headline of the greatest length refused: %v", err)
|
||||
}
|
||||
if _, err := EncodeCondition(clearedCondition()); err != nil {
|
||||
t.Errorf("a cleared condition with only key, subject and state refused: %v", err)
|
||||
}
|
||||
c := clearedCondition()
|
||||
c.Key = ""
|
||||
if _, err := EncodeCondition(c); err == nil {
|
||||
t.Error("a cleared condition without its key accepted")
|
||||
}
|
||||
c = clearedCondition()
|
||||
c.State = "gone"
|
||||
if _, err := EncodeCondition(c); err == nil {
|
||||
t.Error("a condition with an unknown state accepted")
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -18,7 +18,19 @@
|
||||
// question 9 item 7): a writer writes a new version only once every reader's have shows a version that reads it. A
|
||||
// writer writes only when the new value differs from the one before (the Same functions).
|
||||
//
|
||||
// Standard library only.
|
||||
// **Source, candidates, modules and the forge** (issue 1200; ADR 0437 §5, release 3): the gate's bucket `source` with
|
||||
// its one key `main` (every repository's merged head and the group that moved it), the indexer's `candidates.<group>`
|
||||
// and `modules.<module>` (a module's source fingerprint, or none and why), and the forge bridge's messages named
|
||||
// `forge.<owner>.<name>.<kind>` (source.go).
|
||||
//
|
||||
// **Merges, asks, owed and answers** (issue 1201; ADR 0437 §6, §8): the gate's `merges.<group>`, its asks
|
||||
// `merge.<group>.<heads hash>` (the router's ask whole), its owed keys `<repository>.<number>.<head>.<act>` that the
|
||||
// forge bridge performs, and the teller's answer under the ask's own key (gate.go).
|
||||
//
|
||||
// **The source fingerprint** (issue 1203; ADR 0437 §5): one function of named inputs that the controller's builder and
|
||||
// the indexer both call, so they agree byte for byte (fingerprint.go).
|
||||
//
|
||||
// Standard library only, and this module's own asks package.
|
||||
package delivery
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SourceInputs are what a module's source fingerprint is of (novox/hq ADR 0437 §5): the module, the git tree of
|
||||
// its directory, its bases (references, of which only the digest counts), the root tree of each repository its
|
||||
// artifacts read (by artifact), each artifact's toolchain line, and the reason the source does not pin the build
|
||||
// when it does not (a build that resolves packages when built). A context is the value the builder records for it:
|
||||
// a repository's root tree, or for an image that compiles a Go program the `narrow:` hash of the build source it was
|
||||
// handed (mesh-controller internal/builder/builder.go:1146-1156).
|
||||
type SourceInputs struct {
|
||||
Module string
|
||||
Tree string
|
||||
Bases []string
|
||||
Contexts map[string]string
|
||||
Toolchains map[string]string
|
||||
Unpinned string
|
||||
}
|
||||
|
||||
// SourceFingerprint is the module's source fingerprint, `src1:` and the hex sha256 of its inputs' lines, or none
|
||||
// and the reason when the source does not pin the build: Unpinned when set, "no tree" when Tree is empty. It is the
|
||||
// one function the controller's builder and the indexer both call, so they agree byte for byte. The lines are
|
||||
// "module <m>" and "tree <t>", then "base <digest>" (once each), "context <artifact> <tree>" and "toolchain
|
||||
// <artifact> <line>" sorted, all joined by a newline.
|
||||
func SourceFingerprint(in SourceInputs) (fp, none string) {
|
||||
if in.Unpinned != "" {
|
||||
return "", in.Unpinned
|
||||
}
|
||||
if in.Tree == "" {
|
||||
return "", "no tree"
|
||||
}
|
||||
lines := []string{"module " + in.Module, "tree " + in.Tree}
|
||||
bases := map[string]bool{}
|
||||
for _, b := range in.Bases {
|
||||
bases[ReferenceDigest(b)] = true
|
||||
}
|
||||
for b := range bases {
|
||||
lines = append(lines, "base "+b)
|
||||
}
|
||||
for a, t := range in.Contexts {
|
||||
lines = append(lines, "context "+a+" "+t)
|
||||
}
|
||||
for a, t := range in.Toolchains {
|
||||
lines = append(lines, "toolchain "+a+" "+t)
|
||||
}
|
||||
sort.Strings(lines[2:])
|
||||
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
|
||||
return SourcePrefix + hex.EncodeToString(sum[:]), ""
|
||||
}
|
||||
|
||||
// ReferenceDigest is a reference's digest, `sha256:...`, so the registry address a base was copied into does not
|
||||
// enter the fingerprint; the reference itself when it carries none.
|
||||
func ReferenceDigest(reference string) string {
|
||||
if _, digest, pinned := strings.Cut(reference, "@"); pinned && digest != "" {
|
||||
return digest
|
||||
}
|
||||
return reference
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func inputs() SourceInputs {
|
||||
return SourceInputs{
|
||||
Module: "mesh-mail", Tree: "t-module",
|
||||
Bases: []string{"registry.example/base@sha256:aaa", "registry.example/other@sha256:bbb"},
|
||||
Contexts: map[string]string{"mail": "t-root", "tool": "t-root2"},
|
||||
Toolchains: map[string]string{"mail": "go sha256:ccc 0123456789abcdef", "tool": "go sha256:ccc fedcba9876543210"},
|
||||
}
|
||||
}
|
||||
|
||||
func mustFP(t *testing.T, in SourceInputs) string {
|
||||
t.Helper()
|
||||
fp, none := SourceFingerprint(in)
|
||||
if fp == "" || none != "" {
|
||||
t.Fatalf("fingerprint = %q, none = %q", fp, none)
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
func TestFingerprintForm(t *testing.T) {
|
||||
fp := mustFP(t, inputs())
|
||||
hex := strings.TrimPrefix(fp, SourcePrefix)
|
||||
if SourcePrefix != "src1:" || hex == fp || len(hex) != 64 || strings.Trim(hex, "0123456789abcdef") != "" {
|
||||
t.Errorf("fingerprint %q is not src1: and 64 hex digits", fp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintOrderAndDuplicates(t *testing.T) { // 1203/AC2
|
||||
want := mustFP(t, inputs())
|
||||
in := inputs()
|
||||
in.Bases = []string{in.Bases[1], in.Bases[0]}
|
||||
if got := mustFP(t, in); got != want {
|
||||
t.Errorf("the order of bases changes it: %s", got)
|
||||
}
|
||||
in = inputs()
|
||||
in.Bases = append(in.Bases, in.Bases[0])
|
||||
if got := mustFP(t, in); got != want {
|
||||
t.Errorf("a base given twice changes it: %s", got)
|
||||
}
|
||||
in = inputs()
|
||||
in.Bases = append(in.Bases, "other.registry.example/base@sha256:aaa")
|
||||
if got := mustFP(t, in); got != want {
|
||||
t.Errorf("two references to one digest change it: %s", got)
|
||||
}
|
||||
// Maps have no order, but build them in many insertion orders anyway.
|
||||
for i := 0; i < 20; i++ {
|
||||
in = inputs()
|
||||
in.Contexts = map[string]string{}
|
||||
in.Toolchains = map[string]string{}
|
||||
for _, k := range []string{"tool", "mail"} {
|
||||
in.Contexts[k] = inputs().Contexts[k]
|
||||
in.Toolchains[k] = inputs().Toolchains[k]
|
||||
}
|
||||
if got := mustFP(t, in); got != want {
|
||||
t.Fatalf("map order changes it: %s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintChanges(t *testing.T) { // 1203/AC3
|
||||
want := mustFP(t, inputs())
|
||||
for name, f := range map[string]func(*SourceInputs){
|
||||
"module": func(in *SourceInputs) { in.Module = "other" },
|
||||
"tree": func(in *SourceInputs) { in.Tree = "t-other" },
|
||||
"base": func(in *SourceInputs) { in.Bases[0] = "registry.example/base@sha256:zzz" },
|
||||
"a base added": func(in *SourceInputs) { in.Bases = append(in.Bases, "x@sha256:new") },
|
||||
"context": func(in *SourceInputs) { in.Contexts["mail"] = "t-changed" },
|
||||
"context artifact": func(in *SourceInputs) { in.Contexts["extra"] = "t-root" },
|
||||
"toolchain": func(in *SourceInputs) { in.Toolchains["tool"] = "go sha256:ccc changed" },
|
||||
} {
|
||||
in := inputs()
|
||||
f(&in)
|
||||
if got := mustFP(t, in); got == want {
|
||||
t.Errorf("a changed %s keeps the fingerprint", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintNone(t *testing.T) { // 1203/AC4
|
||||
in := inputs()
|
||||
in.Unpinned = "the build resolves packages when built"
|
||||
fp, none := SourceFingerprint(in)
|
||||
if fp != "" || none != in.Unpinned {
|
||||
t.Errorf("unpinned: fp = %q, none = %q", fp, none)
|
||||
}
|
||||
in = inputs()
|
||||
in.Tree = ""
|
||||
fp, none = SourceFingerprint(in)
|
||||
if fp != "" || none != "no tree" {
|
||||
t.Errorf("no tree: fp = %q, none = %q", fp, none)
|
||||
}
|
||||
in = inputs()
|
||||
in.Tree, in.Unpinned = "", "the build resolves packages when built"
|
||||
if _, none = SourceFingerprint(in); none != in.Unpinned {
|
||||
t.Errorf("the reason for unpinned stands over no tree: %q", none)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceDigest(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"registry.example/base@sha256:aaa": "sha256:aaa",
|
||||
"registry.example/base": "registry.example/base",
|
||||
"registry.example/base@": "registry.example/base@",
|
||||
"": "",
|
||||
} {
|
||||
if got := ReferenceDigest(in); got != want {
|
||||
t.Errorf("ReferenceDigest(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1203/AC1: three builds the live builder recorded a source fingerprint for. Each value is captured, never written
|
||||
// by hand: the recorded fingerprint was read with mesh-controller.builds (after issues 1204 and 1208 were
|
||||
// delivered); each tree is `git rev-parse <commit>:<dir>` in the repository's clone; the bases are from the
|
||||
// build's `[bases]` log lines; the toolchain lines are mesh-controller's toolchainOf over its toolchains values.
|
||||
func TestFingerprintLiveBuilds(t *testing.T) {
|
||||
const ts = "typescript sha256:4b81682df5665c1f461d3f720a3a0fd7593f4aa71e4fc272dbfdc4bbd235b489 20125bd590041690"
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
in SourceInputs
|
||||
recorded string
|
||||
}{
|
||||
{
|
||||
// mailu, build-1792391134620131370: mesh-catalog at b4527d9091e972f28cb365623d9280045e7741a3, dir modules/mailu.
|
||||
name: "mailu",
|
||||
in: SourceInputs{
|
||||
Module: "mailu",
|
||||
Tree: "cfc6fa8ff5fc1e220132e51ade653b9d3f387b3e",
|
||||
Bases: []string{"sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228"},
|
||||
Toolchains: map[string]string{"code": ts},
|
||||
},
|
||||
recorded: "src1:539f2e64000d436c572ef53b40e524012455945f8c5a1c6bb173ee0707e88ef4",
|
||||
},
|
||||
{
|
||||
// radarr, build-1792391147149753355: mesh-media-catalog at 197abb061d9b77d67c7f11e9e567528ea7a85436,
|
||||
// dir modules/radarr; no bases.
|
||||
name: "radarr",
|
||||
in: SourceInputs{
|
||||
Module: "radarr",
|
||||
Tree: "42b9c918b5af75cba2bbc75845012e7f64e16df9",
|
||||
Toolchains: map[string]string{
|
||||
"code": ts,
|
||||
"tools-go": "go sha256:2054591ca034b68323d87ec887ba34f56819cb3364034360c0777da4b314dd54 0995d85edc9a6a23",
|
||||
},
|
||||
},
|
||||
recorded: "src1:b3c150d39c395b99dd68974a48490e9a42467aa5d486d0c604d6ef1e8e9c910a",
|
||||
},
|
||||
{
|
||||
// build-agent, build-1792395447088936520: mesh-controller at 7a74cee83f88e6585a5511c3cc53bdf0f5303d20,
|
||||
// dir modules/build-agent; the context is narrowTree's hash of the 185 paths GoBuildSource gave for
|
||||
// cmd/mesh-builder at 7a74cee8.
|
||||
name: "build-agent",
|
||||
in: SourceInputs{
|
||||
Module: "build-agent",
|
||||
Tree: "11c0b4f90cae0d6e4ef9cc48cbfdf9f6dbe52bca",
|
||||
Bases: []string{
|
||||
"sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc",
|
||||
"sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c",
|
||||
},
|
||||
Contexts: map[string]string{"server": "narrow:d51ca744fd11af59d616c858f76c27b3e58e16e11fbdc0c720eb9d7d19d55fac"},
|
||||
},
|
||||
recorded: "src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7",
|
||||
},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
fp, none := SourceFingerprint(c.in)
|
||||
if none != "" || fp != c.recorded {
|
||||
t.Errorf("fingerprint = %q (none %q), the live builder recorded %q", fp, none, c.recorded)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// The gate's buckets and the teller's (ADR 0437 §6, §8). The gate writes merges (each pull request's expected
|
||||
// head, its state and each merge commit), its asks (`merge.<group>.<heads hash>`, the router's ask whole) and owed
|
||||
// (one key per act it owes the forge, which the forge bridge performs once per revision). The teller writes its
|
||||
// answer under the ask's own key in its bucket answers.
|
||||
const (
|
||||
MergesBucket = GateModule + "_merges"
|
||||
AsksBucket = GateModule + "_asks"
|
||||
OwedBucket = GateModule + "_owed"
|
||||
AnswersBucket = TellerModule + "_answers"
|
||||
MergesHistory = 10
|
||||
AsksHistory = 5
|
||||
OwedHistory = 5
|
||||
AnswersHistory = 5
|
||||
)
|
||||
|
||||
// AskKindMerge is the kind of the gate's ask for a group's merge, the first part of its key.
|
||||
const AskKindMerge = "merge"
|
||||
|
||||
// The acts the gate owes the forge, the last part of an owed key.
|
||||
const (
|
||||
OwedStatus = "status"
|
||||
OwedView = "view"
|
||||
OwedNote = "note"
|
||||
OwedComment = "comment"
|
||||
)
|
||||
|
||||
// The states of a pull request in a merge.
|
||||
type MergeState string
|
||||
|
||||
// The states a merge's pull request is in.
|
||||
const (
|
||||
MergeMerging MergeState = "merging"
|
||||
MergeMerged MergeState = "merged"
|
||||
MergeRefused MergeState = "refused"
|
||||
)
|
||||
|
||||
// The states a status the gate owes the forge may carry.
|
||||
var statusStates = []string{"pending", "success", "failure", "error", "warning"}
|
||||
|
||||
var hashOK = regexp.MustCompile(`^[0-9a-f]{12}$`)
|
||||
|
||||
// MergesKey is the key, in MergesBucket, of a group: `<group>`.
|
||||
func MergesKey(group string) (string, error) {
|
||||
if err := checkName("group", group); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
// MergeAskKey is the key, in AsksBucket, of the ask for merging a group at these heads: `merge.<group>.<heads
|
||||
// hash>`. The teller's answer has the same key in AnswersBucket (AnswerKey).
|
||||
func MergeAskKey(group string, heads []Head) (string, error) {
|
||||
if err := checkName("group", group); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash, err := HeadsHash(heads)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return AskKindMerge + "." + group + "." + hash, nil
|
||||
}
|
||||
|
||||
// ParseAskKey is the inverse of MergeAskKey: the kind, the group and the heads hash.
|
||||
func ParseAskKey(key string) (kind, group, hash string, err error) {
|
||||
parts := strings.Split(key, ".")
|
||||
if len(parts) != 3 || parts[0] != AskKindMerge {
|
||||
return "", "", "", fmt.Errorf("delivery: %q is not an ask key, merge.<group>.<heads hash>", key)
|
||||
}
|
||||
if err := checkName("group", parts[1]); err != nil {
|
||||
return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %w", key, err)
|
||||
}
|
||||
if !hashOK.MatchString(parts[2]) {
|
||||
return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %q is not 12 hex digits", key, parts[2])
|
||||
}
|
||||
return parts[0], parts[1], parts[2], nil
|
||||
}
|
||||
|
||||
// AnswerKey is the key, in AnswersBucket, of the teller's answer to the ask at askKey: the same string.
|
||||
func AnswerKey(askKey string) string { return askKey }
|
||||
|
||||
// AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots (asks.UsableID),
|
||||
// so in the readable part a dot becomes `-` and any other byte outside letters, digits, `_` and `-` becomes `_`; that part is cut to fit, and every id ends in `-` and
|
||||
// the first 12 hex digits of the sha256 of the whole key, so two keys never give one id. It cannot be read back:
|
||||
// the teller keeps the key beside it.
|
||||
func AskID(askKey string) string {
|
||||
const most = 64 // the router's bound on an id
|
||||
const tail = 13 // "-" and 12 hex digits
|
||||
readable := []byte(askKey)
|
||||
for i, c := range readable {
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9':
|
||||
case c == '.':
|
||||
readable[i] = '-'
|
||||
default: // a slash, an equals sign or any other byte the router's id refuses
|
||||
readable[i] = '_'
|
||||
}
|
||||
}
|
||||
if len(readable) > 0 && (readable[0] == '-' || readable[0] == '_') {
|
||||
readable[0] = 'k' // an id starts with a letter or digit
|
||||
}
|
||||
if len(readable) > most-tail {
|
||||
readable = readable[:most-tail]
|
||||
}
|
||||
sum := sha256.Sum256([]byte(askKey))
|
||||
return string(readable) + "-" + hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
func checkOwedAct(act string) (kind, context string, err error) {
|
||||
kind, context, hasContext := strings.Cut(act, "=")
|
||||
switch kind {
|
||||
case OwedStatus:
|
||||
if !hasContext {
|
||||
return "", "", fmt.Errorf("delivery: the owed act %q is a status without its context, status=<context>", act)
|
||||
}
|
||||
if err := checkName("status context", context); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return kind, context, nil
|
||||
case OwedView, OwedNote, OwedComment:
|
||||
if hasContext {
|
||||
return "", "", fmt.Errorf("delivery: the owed act %q takes no context", act)
|
||||
}
|
||||
return kind, "", nil
|
||||
}
|
||||
return "", "", fmt.Errorf("delivery: the owed act %q is not status=<context>, view, note or comment", act)
|
||||
}
|
||||
|
||||
// ParseOwedAct splits an owed act into its kind and, for a status, its context.
|
||||
func ParseOwedAct(act string) (kind, context string, err error) { return checkOwedAct(act) }
|
||||
|
||||
// OwedKey is the key, in OwedBucket, of an act the gate owes the forge on a pull request at a head:
|
||||
// `<repository>.<number>.<head>.<act>`, the act being `status=<context>` (mesh/merge-gate), `view`, `note` or
|
||||
// `comment`.
|
||||
func OwedKey(repository string, number int, head, act string) (string, error) {
|
||||
if err := checkRepository(repository); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if number <= 0 {
|
||||
return "", fmt.Errorf("delivery: an owed key needs the pull request number, not %d", number)
|
||||
}
|
||||
if err := checkName("head", head); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, _, err := checkOwedAct(act); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return repository + "." + strconv.Itoa(number) + "." + head + "." + act, nil
|
||||
}
|
||||
|
||||
// ParseOwedKey is the inverse of OwedKey; act is whole, as `status=<context>` for a status.
|
||||
func ParseOwedKey(key string) (repository string, number int, head, act string, err error) {
|
||||
parts := strings.Split(key, ".")
|
||||
if len(parts) != 4 {
|
||||
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key, <repository>.<number>.<head>.<act>", key)
|
||||
}
|
||||
number, convErr := strconv.Atoi(parts[1])
|
||||
if convErr != nil {
|
||||
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %q is not a pull request number", key, parts[1])
|
||||
}
|
||||
if _, err := OwedKey(parts[0], number, parts[2], parts[3]); err != nil {
|
||||
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %w", key, err)
|
||||
}
|
||||
return parts[0], number, parts[2], parts[3], nil
|
||||
}
|
||||
|
||||
// Merge is the value of a merges key: the ask approved and, for each pull request, the head the approval expects
|
||||
// and how far its merge has come.
|
||||
type Merge struct {
|
||||
V int `json:"v"`
|
||||
Group string `json:"group"`
|
||||
Ask string `json:"ask"`
|
||||
Pulls []MergePull `json:"pulls"`
|
||||
}
|
||||
|
||||
// MergePull is one pull request of a merge: its expected head, its state, the merge commit once merged, the
|
||||
// forge's words when refused.
|
||||
type MergePull struct {
|
||||
Head
|
||||
State MergeState `json:"state"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
}
|
||||
|
||||
func (m Merge) check() error {
|
||||
if err := checkName("group", m.Group); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Ask == "" {
|
||||
return fmt.Errorf("delivery: a merge names no ask")
|
||||
}
|
||||
if len(m.Pulls) == 0 {
|
||||
return fmt.Errorf("delivery: a merge names no pull request")
|
||||
}
|
||||
for _, p := range m.Pulls {
|
||||
who := fmt.Sprintf("%s#%d", p.Repository, p.Number)
|
||||
switch p.State {
|
||||
case MergeMerging:
|
||||
case MergeMerged:
|
||||
if p.Commit == "" {
|
||||
return fmt.Errorf("delivery: the merged pull request %s has no merge commit", who)
|
||||
}
|
||||
case MergeRefused:
|
||||
if p.Words == "" {
|
||||
return fmt.Errorf("delivery: the refused pull request %s has no words of the forge", who)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("delivery: the state %q of %s is not merging, merged or refused", p.State, who)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ask is the value of an asks key: the router's ask whole as the gate composes it, so the teller publishes it
|
||||
// unchanged, and the group and heads it was composed for.
|
||||
type Ask struct {
|
||||
V int `json:"v"`
|
||||
Group string `json:"group"`
|
||||
Heads []Head `json:"heads"`
|
||||
Ask asks.Ask `json:"ask"`
|
||||
}
|
||||
|
||||
func (a Ask) check() error {
|
||||
key, err := MergeAskKey(a.Group, a.Heads)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delivery: an ask needs its group and at least one pull request's head: %w", err)
|
||||
}
|
||||
if want := AskID(key); a.Ask.ID != want {
|
||||
return fmt.Errorf("delivery: the ask's id %q is not %q, the id its group and heads give", a.Ask.ID, want)
|
||||
}
|
||||
if len(a.Ask.Options) == 0 {
|
||||
return fmt.Errorf("delivery: the ask has no option")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Answer is the teller's answer to an ask: the option chosen, the warrant's id and its words.
|
||||
type Answer struct {
|
||||
V int `json:"v"`
|
||||
Option string `json:"option"`
|
||||
Warrant string `json:"warrant"`
|
||||
Words string `json:"words"`
|
||||
}
|
||||
|
||||
func (a Answer) check() error {
|
||||
for _, f := range []struct{ name, value string }{{"option", a.Option}, {"warrant", a.Warrant}, {"words", a.Words}} {
|
||||
if f.value == "" {
|
||||
return fmt.Errorf("delivery: an answer has no %s", f.name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Owed is the value of an owed key: what the forge bridge performs. A status needs Context and State, a view and a
|
||||
// comment need Text, a note needs Text and Ref (the notes ref, mesh-plan).
|
||||
type Owed struct {
|
||||
V int `json:"v"`
|
||||
Act string `json:"act"`
|
||||
Context string `json:"context,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
}
|
||||
|
||||
func (o Owed) check() error {
|
||||
switch o.Act {
|
||||
case OwedStatus:
|
||||
if o.Context == "" {
|
||||
return fmt.Errorf("delivery: an owed status has no context")
|
||||
}
|
||||
for _, s := range statusStates {
|
||||
if o.State == s {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("delivery: the owed status state %q is not %s", o.State, strings.Join(statusStates, ", "))
|
||||
case OwedView, OwedComment:
|
||||
if o.Text == "" {
|
||||
return fmt.Errorf("delivery: an owed %s has no text", o.Act)
|
||||
}
|
||||
case OwedNote:
|
||||
if o.Text == "" {
|
||||
return fmt.Errorf("delivery: an owed note has no text")
|
||||
}
|
||||
if o.Ref == "" {
|
||||
return fmt.Errorf("delivery: an owed note has no ref")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("delivery: the owed act %q is not status, view, note or comment", o.Act)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeMerge is m as JSON at Version; it refuses a merged pull request without its merge commit, a refused one
|
||||
// without the forge's words and a state outside the list.
|
||||
func EncodeMerge(m Merge) ([]byte, error) {
|
||||
if err := m.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.V = Version
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// DecodeMerge reads a merge; it refuses an unknown version (ErrUnknownVersion) and what EncodeMerge refuses.
|
||||
func DecodeMerge(data []byte) (Merge, error) {
|
||||
var m Merge
|
||||
if err := decode(data, &m); err != nil {
|
||||
return Merge{}, err
|
||||
}
|
||||
if err := m.check(); err != nil {
|
||||
return Merge{}, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// EncodeAsk is a as JSON at Version; it refuses an ask whose id is not the one its group and heads give, or with
|
||||
// no option.
|
||||
func EncodeAsk(a Ask) ([]byte, error) {
|
||||
if err := a.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.V = Version
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
// DecodeAsk reads an ask; it refuses an unknown version (ErrUnknownVersion) and what EncodeAsk refuses.
|
||||
func DecodeAsk(data []byte) (Ask, error) {
|
||||
var a Ask
|
||||
if err := decode(data, &a); err != nil {
|
||||
return Ask{}, err
|
||||
}
|
||||
if err := a.check(); err != nil {
|
||||
return Ask{}, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// EncodeAnswer is a as JSON at Version; it refuses an answer missing its option, warrant or words.
|
||||
func EncodeAnswer(a Answer) ([]byte, error) {
|
||||
if err := a.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.V = Version
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
// DecodeAnswer reads an answer; it refuses an unknown version (ErrUnknownVersion) and what EncodeAnswer refuses.
|
||||
func DecodeAnswer(data []byte) (Answer, error) {
|
||||
var a Answer
|
||||
if err := decode(data, &a); err != nil {
|
||||
return Answer{}, err
|
||||
}
|
||||
if err := a.check(); err != nil {
|
||||
return Answer{}, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// EncodeOwed is o as JSON at Version; it refuses an act missing what it needs.
|
||||
func EncodeOwed(o Owed) ([]byte, error) {
|
||||
if err := o.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.V = Version
|
||||
return json.Marshal(o)
|
||||
}
|
||||
|
||||
// DecodeOwed reads an owed act; it refuses an unknown version (ErrUnknownVersion) and what EncodeOwed refuses.
|
||||
func DecodeOwed(data []byte) (Owed, error) {
|
||||
var o Owed
|
||||
if err := decode(data, &o); err != nil {
|
||||
return Owed{}, err
|
||||
}
|
||||
if err := o.check(); err != nil {
|
||||
return Owed{}, err
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// SameMerge says whether two merges are the same value, so a writer writes only when they differ.
|
||||
func SameMerge(a, b Merge) bool { return reflect.DeepEqual(a, b) }
|
||||
|
||||
// SameAsk says whether two asks are the same value.
|
||||
func SameAsk(a, b Ask) bool { return reflect.DeepEqual(a, b) }
|
||||
|
||||
// SameAnswer says whether two answers are the same value.
|
||||
func SameAnswer(a, b Answer) bool { return a == b }
|
||||
|
||||
// SameOwed says whether two owed acts are the same value.
|
||||
func SameOwed(a, b Owed) bool { return a == b }
|
||||
@@ -0,0 +1,301 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
func gateHeads() []Head {
|
||||
return []Head{
|
||||
{Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"},
|
||||
{Repository: "novox/mesh-control", Number: 7, Head: "bbbb"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeKeys(t *testing.T) { // 1201/AC1
|
||||
if k, err := MergesKey("feat/x"); err != nil || k != "feat/x" {
|
||||
t.Fatalf("merges key = %q, %v", k, err)
|
||||
}
|
||||
if _, err := MergesKey("a.b"); err == nil {
|
||||
t.Error("a group with a dot accepted")
|
||||
}
|
||||
hh, _ := HeadsHash(gateHeads())
|
||||
key, err := MergeAskKey("feat/x", gateHeads())
|
||||
if err != nil || key != "merge.feat/x."+hh {
|
||||
t.Fatalf("ask key = %q, %v", key, err)
|
||||
}
|
||||
kind, group, hash, err := ParseAskKey(key)
|
||||
if err != nil || kind != AskKindMerge || group != "feat/x" || hash != hh {
|
||||
t.Fatalf("parse ask key = %q %q %q %v", kind, group, hash, err)
|
||||
}
|
||||
if AnswerKey(key) != key {
|
||||
t.Error("the answer's key is not the ask's key")
|
||||
}
|
||||
if _, err := MergeAskKey("feat/x", nil); err == nil {
|
||||
t.Error("an ask key without heads accepted")
|
||||
}
|
||||
if _, err := MergeAskKey("", gateHeads()); err == nil {
|
||||
t.Error("an ask key without a group accepted")
|
||||
}
|
||||
for _, bad := range []string{"", "merge", "merge.g", "merge.g.h.x", "other.g.0123456789ab", "merge..0123456789ab", "merge.g.nothex"} {
|
||||
if _, _, _, err := ParseAskKey(bad); err == nil {
|
||||
t.Errorf("parse ask key %q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskID(t *testing.T) { // 1201/AC2
|
||||
k1, _ := MergeAskKey("feat/x", gateHeads())
|
||||
k2, _ := MergeAskKey("feat/y", gateHeads())
|
||||
k3, _ := MergeAskKey("feat/x", gateHeads()[:1])
|
||||
long, _ := MergeAskKey("feat/"+strings.Repeat("long-", 20), gateHeads())
|
||||
long2, _ := MergeAskKey("feat/"+strings.Repeat("long-", 20)+"2", gateHeads())
|
||||
slash, _ := MergeAskKey("a/b", gateHeads())
|
||||
under, _ := MergeAskKey("a_b", gateHeads())
|
||||
eq, _ := MergeAskKey("feat=x", gateHeads())
|
||||
eqs, _ := MergeAskKey("feat_x", gateHeads())
|
||||
seen := map[string]string{}
|
||||
for _, k := range []string{k1, k2, k3, long, long2, slash, under, eq, eqs, "=odd.key"} {
|
||||
id := AskID(k)
|
||||
if strings.Contains(id, ".") {
|
||||
t.Errorf("id %q holds a dot", id)
|
||||
}
|
||||
if !asks.UsableID(id) {
|
||||
t.Errorf("id %q is not usable by the router", id)
|
||||
}
|
||||
if other, dup := seen[id]; dup {
|
||||
t.Errorf("keys %q and %q give the id %q", k, other, id)
|
||||
}
|
||||
seen[id] = k
|
||||
}
|
||||
if AskID(k1) != AskID(k1) {
|
||||
t.Error("the id is not stable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwedKey(t *testing.T) { // 1201/AC3
|
||||
for _, act := range []string{"status=mesh/merge-gate", "view", "note", "comment"} {
|
||||
key, err := OwedKey("novox/mesh-sdk", 36, "aaaa", act)
|
||||
if err != nil || key != "novox/mesh-sdk.36.aaaa."+act {
|
||||
t.Fatalf("owed key for %s = %q, %v", act, key, err)
|
||||
}
|
||||
repo, number, head, gotAct, err := ParseOwedKey(key)
|
||||
if err != nil || repo != "novox/mesh-sdk" || number != 36 || head != "aaaa" || gotAct != act {
|
||||
t.Fatalf("parse %q = %q %d %q %q %v", key, repo, number, head, gotAct, err)
|
||||
}
|
||||
}
|
||||
kind, context, err := ParseOwedAct("status=mesh/merge-gate")
|
||||
if err != nil || kind != OwedStatus || context != "mesh/merge-gate" {
|
||||
t.Errorf("owed act = %q %q %v", kind, context, err)
|
||||
}
|
||||
for _, bad := range []string{"", "merge", "status", "status=", "view=x", "status=a.b", "statuses=x"} {
|
||||
if _, err := OwedKey("novox/mesh-sdk", 36, "aaaa", bad); err == nil {
|
||||
t.Errorf("owed act %q accepted", bad)
|
||||
}
|
||||
}
|
||||
if _, err := OwedKey("nope", 36, "aaaa", "view"); err == nil {
|
||||
t.Error("a bad repository accepted")
|
||||
}
|
||||
if _, err := OwedKey("novox/mesh-sdk", 0, "aaaa", "view"); err == nil {
|
||||
t.Error("pull request number 0 accepted")
|
||||
}
|
||||
if _, err := OwedKey("novox/mesh-sdk", 36, "", "view"); err == nil {
|
||||
t.Error("an empty head accepted")
|
||||
}
|
||||
for _, bad := range []string{"", "novox/mesh-sdk.36.aaaa", "novox/mesh-sdk.x.aaaa.view", "novox/mesh-sdk.36.aaaa.merge", "a.b.36.aaaa.view"} {
|
||||
if _, _, _, _, err := ParseOwedKey(bad); err == nil {
|
||||
t.Errorf("parse owed key %q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sampleMerge() Merge {
|
||||
return Merge{Group: "feat/x", Ask: "merge.feat/x.0123456789ab", Pulls: []MergePull{
|
||||
{Head: gateHeads()[0], State: MergeMerged, Commit: "cccc"},
|
||||
{Head: gateHeads()[1], State: MergeRefused, Words: "the forge says no"},
|
||||
{Head: Head{Repository: "novox/other", Number: 1, Head: "dddd"}, State: MergeMerging},
|
||||
}}
|
||||
}
|
||||
|
||||
func sampleAsk() Ask {
|
||||
key, _ := MergeAskKey("feat/x", gateHeads())
|
||||
return Ask{Group: "feat/x", Heads: gateHeads(), Ask: asks.Ask{
|
||||
ID: AskID(key), Headline: "Merge into main: group feat/x", Explanation: "The group passed its checks.",
|
||||
Who: asks.Operator, Expires: time.Date(2026, 10, 20, 8, 0, 0, 0, time.UTC), OnExpiry: "nothing is merged",
|
||||
Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}},
|
||||
}}
|
||||
}
|
||||
|
||||
func sampleOwed() []Owed {
|
||||
return []Owed{
|
||||
{Act: OwedStatus, Context: "mesh/merge-gate", State: "success"},
|
||||
{Act: OwedView, Text: "the delivery reaches ace"},
|
||||
{Act: OwedNote, Text: "plan: mesh-mail to ace", Ref: "mesh-plan"},
|
||||
{Act: OwedComment, Text: "merged"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateValuesRoundTrip(t *testing.T) { // 1201/AC4
|
||||
hasV := func(name string, data []byte, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
var probe map[string]any
|
||||
if json.Unmarshal(data, &probe) != nil || probe["v"] != float64(1) {
|
||||
t.Fatalf("%s: %s does not carry v:1", name, data)
|
||||
}
|
||||
}
|
||||
m := sampleMerge()
|
||||
data, err := EncodeMerge(m)
|
||||
hasV("merge", data, err)
|
||||
m.V = Version
|
||||
if got, err := DecodeMerge(data); err != nil || !SameMerge(got, m) {
|
||||
t.Errorf("merge round trip: %+v %v", got, err)
|
||||
}
|
||||
a := sampleAsk()
|
||||
data, err = EncodeAsk(a)
|
||||
hasV("ask", data, err)
|
||||
a.V = Version
|
||||
if got, err := DecodeAsk(data); err != nil || !SameAsk(got, a) {
|
||||
t.Errorf("ask round trip: %+v %v", got, err)
|
||||
}
|
||||
an := Answer{Option: "approve", Warrant: "w-1", Words: "approved on telegram"}
|
||||
data, err = EncodeAnswer(an)
|
||||
hasV("answer", data, err)
|
||||
an.V = Version
|
||||
if got, err := DecodeAnswer(data); err != nil || !SameAnswer(got, an) {
|
||||
t.Errorf("answer round trip: %+v %v", got, err)
|
||||
}
|
||||
for _, o := range sampleOwed() {
|
||||
data, err = EncodeOwed(o)
|
||||
hasV("owed "+o.Act, data, err)
|
||||
o.V = Version
|
||||
if got, err := DecodeOwed(data); err != nil || !SameOwed(got, o) {
|
||||
t.Errorf("owed %s round trip: %+v %v", o.Act, got, err)
|
||||
}
|
||||
}
|
||||
for name, dec := range map[string]func([]byte) error{
|
||||
"merge": func(b []byte) error { _, err := DecodeMerge(b); return err },
|
||||
"ask": func(b []byte) error { _, err := DecodeAsk(b); return err },
|
||||
"answer": func(b []byte) error { _, err := DecodeAnswer(b); return err },
|
||||
"owed": func(b []byte) error { _, err := DecodeOwed(b); return err },
|
||||
} {
|
||||
for _, in := range []string{`{"group":"g"}`, `{"v":2}`} {
|
||||
if err := dec([]byte(in)); !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("%s decode of %s = %v, want ErrUnknownVersion", name, in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateValuesRefused(t *testing.T) { // 1201/AC5
|
||||
wantErr := func(name string, err error, words ...string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Errorf("%s accepted", name)
|
||||
return
|
||||
}
|
||||
for _, w := range words {
|
||||
if !strings.Contains(err.Error(), w) {
|
||||
t.Errorf("%s: error %q does not name %q", name, err, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
m := sampleMerge()
|
||||
m.Pulls[0].Commit = ""
|
||||
_, err := EncodeMerge(m)
|
||||
wantErr("merged without a merge commit", err, "commit")
|
||||
m = sampleMerge()
|
||||
m.Pulls[1].Words = ""
|
||||
_, err = EncodeMerge(m)
|
||||
wantErr("refused without words", err, "words")
|
||||
m = sampleMerge()
|
||||
m.Pulls[2].State = "done"
|
||||
_, err = EncodeMerge(m)
|
||||
wantErr("unknown state", err, "done")
|
||||
m = sampleMerge()
|
||||
m.Pulls = nil
|
||||
_, err = EncodeMerge(m)
|
||||
wantErr("merge without pulls", err, "pull")
|
||||
m = sampleMerge()
|
||||
m.Ask = ""
|
||||
_, err = EncodeMerge(m)
|
||||
wantErr("merge without its ask", err, "ask")
|
||||
|
||||
a := sampleAsk()
|
||||
a.Ask.ID = "something-else"
|
||||
_, err = EncodeAsk(a)
|
||||
wantErr("ask with another id", err, "something-else", AskID("merge.feat/x."+mustHash(gateHeads())))
|
||||
a = sampleAsk()
|
||||
a.Ask.Options = nil
|
||||
_, err = EncodeAsk(a)
|
||||
wantErr("ask without options", err, "option")
|
||||
a = sampleAsk()
|
||||
a.Heads = nil
|
||||
_, err = EncodeAsk(a)
|
||||
wantErr("ask without heads", err, "pull request")
|
||||
|
||||
for name, an := range map[string]Answer{
|
||||
"option": {Warrant: "w", Words: "x"},
|
||||
"warrant": {Option: "approve", Words: "x"},
|
||||
"words": {Option: "approve", Warrant: "w"},
|
||||
} {
|
||||
_, err = EncodeAnswer(an)
|
||||
wantErr("answer without "+name, err, name)
|
||||
}
|
||||
|
||||
for name, o := range map[string]Owed{
|
||||
"context": {Act: OwedStatus, State: "success"},
|
||||
"state": {Act: OwedStatus, Context: "mesh/merge-gate", State: "green"},
|
||||
"text": {Act: OwedView},
|
||||
"ref": {Act: OwedNote, Text: "line"},
|
||||
"comment": {Act: OwedComment},
|
||||
"act": {Act: "merge"},
|
||||
} {
|
||||
_, err = EncodeOwed(o)
|
||||
wantErr("owed missing "+name, err)
|
||||
}
|
||||
_, err = EncodeOwed(Owed{Act: OwedNote, Text: "line"})
|
||||
wantErr("note without ref", err, "ref")
|
||||
_, err = EncodeOwed(Owed{Act: OwedStatus, State: "success"})
|
||||
wantErr("status without context", err, "context")
|
||||
}
|
||||
|
||||
func mustHash(h []Head) string { s, _ := HeadsHash(h); return s }
|
||||
|
||||
func TestGateSame(t *testing.T) { // 1201/AC6
|
||||
if !SameMerge(sampleMerge(), sampleMerge()) || !SameAsk(sampleAsk(), sampleAsk()) {
|
||||
t.Error("equal values differ")
|
||||
}
|
||||
m := sampleMerge()
|
||||
m.Pulls[0].Commit = "other"
|
||||
if SameMerge(m, sampleMerge()) {
|
||||
t.Error("different merges are the same")
|
||||
}
|
||||
a := sampleAsk()
|
||||
a.Ask.Headline = "other"
|
||||
if SameAsk(a, sampleAsk()) {
|
||||
t.Error("different asks are the same")
|
||||
}
|
||||
if !SameAnswer(Answer{Option: "a", Warrant: "w", Words: "x"}, Answer{Option: "a", Warrant: "w", Words: "x"}) ||
|
||||
SameAnswer(Answer{Option: "a", Warrant: "w", Words: "x"}, Answer{Option: "b", Warrant: "w", Words: "x"}) {
|
||||
t.Error("SameAnswer wrong")
|
||||
}
|
||||
if !SameOwed(sampleOwed()[0], sampleOwed()[0]) || SameOwed(sampleOwed()[0], sampleOwed()[1]) {
|
||||
t.Error("SameOwed wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateBuckets(t *testing.T) {
|
||||
if MergesBucket != "mesh-delivery-gate_merges" || AsksBucket != "mesh-delivery-gate_asks" ||
|
||||
OwedBucket != "mesh-delivery-gate_owed" || AnswersBucket != "mesh-delivery-teller_answers" ||
|
||||
MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 {
|
||||
t.Error("a bucket or history differs from the decided layout")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The modules of the parts release 3 adds (novox/hq ADR 0437): their buckets are their own state, `<module>_<name>`.
|
||||
const (
|
||||
GateModule = "mesh-delivery-gate"
|
||||
IndexerModule = "mesh-delivery-indexer"
|
||||
ForgeBridgeModule = "mesh-delivery-forge-bridge"
|
||||
TellerModule = "mesh-delivery-teller"
|
||||
)
|
||||
|
||||
// The gate's source bucket and the indexer's candidates and modules buckets (ADR 0437 §5). The source bucket has
|
||||
// one key, SourceMainKey: every repository's merged head and the group that moved it, written by the gate alone by
|
||||
// compare-and-set; its revision is the mesh's source revision. The indexer writes the other two.
|
||||
const (
|
||||
SourceBucket = GateModule + "_source"
|
||||
SourceMainKey = "main"
|
||||
SourceHistory = 64
|
||||
CandidatesBucket = IndexerModule + "_candidates"
|
||||
// CandidatesHistory is the revisions the candidates bucket keeps per key.
|
||||
CandidatesHistory = 10
|
||||
ModulesBucket = IndexerModule + "_modules"
|
||||
// ModulesHistory is the revisions the modules bucket keeps per key.
|
||||
ModulesHistory = 20
|
||||
)
|
||||
|
||||
// ForgeStream is the forge bridge's stream as its manifest declares it; the broker derives the real names.
|
||||
const ForgeStream = "forge"
|
||||
|
||||
// The kinds of forge message, the last part of a forge event's name.
|
||||
const (
|
||||
ForgePull = "pull"
|
||||
ForgeMerged = "merged"
|
||||
ForgeRefused = "refused"
|
||||
)
|
||||
|
||||
// The actions of a pull message.
|
||||
const (
|
||||
PullOpened = "opened"
|
||||
PullUpdated = "updated"
|
||||
PullClosed = "closed"
|
||||
)
|
||||
|
||||
// SourcePrefix opens every source fingerprint.
|
||||
const SourcePrefix = "src1:"
|
||||
|
||||
// CandidatesKey is the key, in CandidatesBucket, of a group: `<group>`, the branch name the group's pull requests
|
||||
// share (a slash is allowed); a lone pull request is a group of one under its branch name.
|
||||
func CandidatesKey(group string) (string, error) {
|
||||
if err := checkName("group", group); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
// ParseCandidatesKey is the inverse of CandidatesKey.
|
||||
func ParseCandidatesKey(key string) (group string, err error) {
|
||||
if group, err = CandidatesKey(key); err != nil {
|
||||
return "", fmt.Errorf("delivery: %q is not a candidates key: %w", key, err)
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
// ModulesKey is the key, in ModulesBucket, of a module: `<module>`.
|
||||
func ModulesKey(module string) (string, error) {
|
||||
if err := checkName("module", module); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return module, nil
|
||||
}
|
||||
|
||||
// ParseModulesKey is the inverse of ModulesKey.
|
||||
func ParseModulesKey(key string) (module string, err error) {
|
||||
if module, err = ModulesKey(key); err != nil {
|
||||
return "", fmt.Errorf("delivery: %q is not a modules key: %w", key, err)
|
||||
}
|
||||
return module, nil
|
||||
}
|
||||
|
||||
// checkRepository refuses a repository that is not `<owner>/<name>`: exactly one slash, each part a valid name.
|
||||
func checkRepository(repository string) error {
|
||||
owner, name, ok := strings.Cut(repository, "/")
|
||||
if !ok || strings.Contains(name, "/") {
|
||||
return fmt.Errorf("delivery: the repository %q is not <owner>/<name>, with exactly one slash", repository)
|
||||
}
|
||||
if err := checkName("repository owner", owner); err != nil {
|
||||
return fmt.Errorf("delivery: the repository %q: %w", repository, err)
|
||||
}
|
||||
if err := checkName("repository name", name); err != nil {
|
||||
return fmt.Errorf("delivery: the repository %q: %w", repository, err)
|
||||
}
|
||||
if strings.Contains(owner, "/") {
|
||||
return fmt.Errorf("delivery: the repository %q is not <owner>/<name>, with exactly one slash", repository)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkForgeKind(kind string) error {
|
||||
switch kind {
|
||||
case ForgePull, ForgeMerged, ForgeRefused:
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("delivery: the forge kind %q is not pull, merged or refused", kind)
|
||||
}
|
||||
|
||||
// ForgeEvent is the name of a forge message of the given kind: `forge.<owner>.<name>.<kind>`.
|
||||
func ForgeEvent(repository, kind string) (string, error) {
|
||||
if err := checkRepository(repository); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := checkForgeKind(kind); err != nil {
|
||||
return "", err
|
||||
}
|
||||
owner, name, _ := strings.Cut(repository, "/")
|
||||
return "forge." + owner + "." + name + "." + kind, nil
|
||||
}
|
||||
|
||||
// ParseForgeEvent is the inverse of ForgeEvent.
|
||||
func ParseForgeEvent(event string) (repository, kind string, err error) {
|
||||
parts := strings.Split(event, ".")
|
||||
if len(parts) != 4 || parts[0] != "forge" {
|
||||
return "", "", fmt.Errorf("delivery: %q is not a forge event, forge.<owner>.<name>.<kind>", event)
|
||||
}
|
||||
repository = parts[1] + "/" + parts[2]
|
||||
if _, err := ForgeEvent(repository, parts[3]); err != nil {
|
||||
return "", "", fmt.Errorf("delivery: %q is not a forge event: %w", event, err)
|
||||
}
|
||||
return repository, parts[3], nil
|
||||
}
|
||||
|
||||
// Head is one pull request at one commit: the repository as `<owner>/<name>` and the full sha.
|
||||
type Head struct {
|
||||
Repository string `json:"repository"`
|
||||
Number int `json:"number"`
|
||||
Head string `json:"head"`
|
||||
}
|
||||
|
||||
// HeadsHash is the first 12 hex digits of the sha256 over the lines `<repository>#<number>=<head>`, sorted and
|
||||
// joined by a newline: the same for every order of heads, different when any head changes. An empty list is refused.
|
||||
func HeadsHash(heads []Head) (string, error) {
|
||||
if len(heads) == 0 {
|
||||
return "", fmt.Errorf("delivery: a heads hash needs at least one pull request")
|
||||
}
|
||||
lines := make([]string, len(heads))
|
||||
for i, h := range heads {
|
||||
lines[i] = fmt.Sprintf("%s#%d=%s", h.Repository, h.Number, h.Head)
|
||||
}
|
||||
sort.Strings(lines)
|
||||
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
|
||||
return hex.EncodeToString(sum[:])[:12], nil
|
||||
}
|
||||
|
||||
// Source is the value of SourceMainKey: every repository's merged head (repository to full sha, not only the
|
||||
// group's) and the group that moved it.
|
||||
type Source struct {
|
||||
V int `json:"v"`
|
||||
Group string `json:"group"`
|
||||
Heads map[string]string `json:"heads"`
|
||||
}
|
||||
|
||||
func (s Source) check() error {
|
||||
if len(s.Heads) == 0 {
|
||||
return fmt.Errorf("delivery: a source names no repository")
|
||||
}
|
||||
for repository, head := range s.Heads {
|
||||
if repository == "" {
|
||||
return fmt.Errorf("delivery: a source names a repository with no name")
|
||||
}
|
||||
if head == "" {
|
||||
return fmt.Errorf("delivery: the source's head of %s is empty", repository)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Candidate is the value of a candidates key: the group's pull requests and heads, the heads hash, the base (the
|
||||
// source revision it was previewed onto) and the modules each pull request touches. It carries no fingerprint
|
||||
// until release 4 (ADR 0437 §5).
|
||||
type Candidate struct {
|
||||
V int `json:"v"`
|
||||
Group string `json:"group"`
|
||||
Base uint64 `json:"base"`
|
||||
HeadsHash string `json:"heads"`
|
||||
Pulls []CandidatePull `json:"pulls"`
|
||||
}
|
||||
|
||||
// CandidatePull is one pull request of a candidate and the modules it touches.
|
||||
type CandidatePull struct {
|
||||
Head
|
||||
Modules []string `json:"modules"`
|
||||
}
|
||||
|
||||
func (c Candidate) check() error {
|
||||
if len(c.Pulls) == 0 {
|
||||
return fmt.Errorf("delivery: a candidate names no pull request")
|
||||
}
|
||||
heads := make([]Head, len(c.Pulls))
|
||||
for i, p := range c.Pulls {
|
||||
heads[i] = p.Head
|
||||
}
|
||||
want, _ := HeadsHash(heads)
|
||||
if c.HeadsHash != want {
|
||||
return fmt.Errorf("delivery: the candidate's heads hash %q is not the hash %q of its pull requests' heads", c.HeadsHash, want)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Module is the value of a modules key: the module's source fingerprint, or none and why (a build that resolves
|
||||
// packages when built gets none, said here), and where it was computed: Repository and Commit are the trunk merge,
|
||||
// Src the source revision then (0 before the gate writes source.main). Exactly one of FP and None is set.
|
||||
type Module struct {
|
||||
V int `json:"v"`
|
||||
FP string `json:"fp,omitempty"`
|
||||
None string `json:"none,omitempty"`
|
||||
Src uint64 `json:"src,omitempty"`
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
func (m Module) check() error {
|
||||
switch {
|
||||
case m.FP != "" && m.None != "":
|
||||
return fmt.Errorf("delivery: a module has both a fingerprint and a reason for none")
|
||||
case m.FP == "" && m.None == "":
|
||||
return fmt.Errorf("delivery: a module has neither a fingerprint nor a reason for none")
|
||||
case m.FP != "" && !strings.HasPrefix(m.FP, SourcePrefix):
|
||||
return fmt.Errorf("delivery: the module's fingerprint %q does not start with %s", m.FP, SourcePrefix)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Forge is a message of the forge bridge's stream, trimmed to repository, branch, pull request and head. A pull
|
||||
// message says Action; a merged message carries the merge commit, the tree of every directory holding a
|
||||
// module.json and the repository's root tree; a refused message names the owed act it answers and the forge's own
|
||||
// words.
|
||||
type Forge struct {
|
||||
V int `json:"v"`
|
||||
Kind string `json:"kind"`
|
||||
Repository string `json:"repository"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Number int `json:"number,omitempty"`
|
||||
Head string `json:"head,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Merge string `json:"merge,omitempty"`
|
||||
Trees map[string]string `json:"trees,omitempty"`
|
||||
Root string `json:"root,omitempty"`
|
||||
Act string `json:"act,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
}
|
||||
|
||||
func (f Forge) check() error {
|
||||
if err := checkRepository(f.Repository); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkForgeKind(f.Kind); err != nil {
|
||||
return err
|
||||
}
|
||||
switch f.Kind {
|
||||
case ForgePull:
|
||||
if f.Number <= 0 {
|
||||
return fmt.Errorf("delivery: a pull message needs its pull request number")
|
||||
}
|
||||
if f.Head == "" {
|
||||
return fmt.Errorf("delivery: a pull message needs its head")
|
||||
}
|
||||
switch f.Action {
|
||||
case PullOpened, PullUpdated, PullClosed:
|
||||
default:
|
||||
return fmt.Errorf("delivery: the pull action %q is not opened, updated or closed", f.Action)
|
||||
}
|
||||
case ForgeMerged:
|
||||
if f.Number <= 0 {
|
||||
return fmt.Errorf("delivery: a merged message needs its pull request number")
|
||||
}
|
||||
if f.Head == "" {
|
||||
return fmt.Errorf("delivery: a merged message needs its head")
|
||||
}
|
||||
if f.Merge == "" {
|
||||
return fmt.Errorf("delivery: a merged message needs its merge commit")
|
||||
}
|
||||
if f.Root == "" {
|
||||
return fmt.Errorf("delivery: a merged message needs the repository's root tree")
|
||||
}
|
||||
if len(f.Trees) == 0 {
|
||||
return fmt.Errorf("delivery: a merged message needs the tree of at least one directory holding a module.json")
|
||||
}
|
||||
for dir, tree := range f.Trees {
|
||||
if tree == "" {
|
||||
return fmt.Errorf("delivery: a merged message's tree of the directory %q is empty", dir)
|
||||
}
|
||||
}
|
||||
case ForgeRefused:
|
||||
if f.Act == "" {
|
||||
return fmt.Errorf("delivery: a refused message needs the act it answers")
|
||||
}
|
||||
if f.Words == "" {
|
||||
return fmt.Errorf("delivery: a refused message needs the forge's words")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeSource is s as JSON at Version; it refuses a source with no repository or an empty head.
|
||||
func EncodeSource(s Source) ([]byte, error) {
|
||||
if err := s.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.V = Version
|
||||
return json.Marshal(s)
|
||||
}
|
||||
|
||||
// DecodeSource reads a source; it refuses an unknown version (ErrUnknownVersion) and what EncodeSource refuses.
|
||||
func DecodeSource(data []byte) (Source, error) {
|
||||
var s Source
|
||||
if err := decode(data, &s); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
if err := s.check(); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// EncodeCandidate is c as JSON at Version; it refuses a heads hash that is not the hash of its pull requests' heads.
|
||||
func EncodeCandidate(c Candidate) ([]byte, error) {
|
||||
if err := c.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.V = Version
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
// DecodeCandidate reads a candidate; it refuses an unknown version (ErrUnknownVersion) and what EncodeCandidate refuses.
|
||||
func DecodeCandidate(data []byte) (Candidate, error) {
|
||||
var c Candidate
|
||||
if err := decode(data, &c); err != nil {
|
||||
return Candidate{}, err
|
||||
}
|
||||
if err := c.check(); err != nil {
|
||||
return Candidate{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// EncodeModule is m as JSON at Version; it refuses both or neither of a fingerprint and a reason, and a
|
||||
// fingerprint not of the form src1:.
|
||||
func EncodeModule(m Module) ([]byte, error) {
|
||||
if err := m.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.V = Version
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// DecodeModule reads a module; it refuses an unknown version (ErrUnknownVersion) and what EncodeModule refuses.
|
||||
func DecodeModule(data []byte) (Module, error) {
|
||||
var m Module
|
||||
if err := decode(data, &m); err != nil {
|
||||
return Module{}, err
|
||||
}
|
||||
if err := m.check(); err != nil {
|
||||
return Module{}, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// EncodeForge is f as JSON at Version; it refuses a message missing what its kind needs.
|
||||
func EncodeForge(f Forge) ([]byte, error) {
|
||||
if err := f.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.V = Version
|
||||
return json.Marshal(f)
|
||||
}
|
||||
|
||||
// DecodeForge reads a forge message; it refuses an unknown version (ErrUnknownVersion) and what EncodeForge refuses.
|
||||
func DecodeForge(data []byte) (Forge, error) {
|
||||
var f Forge
|
||||
if err := decode(data, &f); err != nil {
|
||||
return Forge{}, err
|
||||
}
|
||||
if err := f.check(); err != nil {
|
||||
return Forge{}, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// SameSource says whether two sources are the same value, so a writer writes only when they differ.
|
||||
func SameSource(a, b Source) bool { return reflect.DeepEqual(a, b) }
|
||||
|
||||
// SameCandidate says whether two candidates are the same value.
|
||||
func SameCandidate(a, b Candidate) bool { return reflect.DeepEqual(a, b) }
|
||||
|
||||
// SameModule says whether two modules are the same value.
|
||||
func SameModule(a, b Module) bool { return a == b }
|
||||
|
||||
// SameForge says whether two forge messages are the same value.
|
||||
func SameForge(a, b Forge) bool { return reflect.DeepEqual(a, b) }
|
||||
@@ -0,0 +1,288 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSourceKeys(t *testing.T) { // 1200/AC1
|
||||
if k, err := CandidatesKey("feat/delivery-source-keys"); err != nil || k != "feat/delivery-source-keys" {
|
||||
t.Fatalf("candidates key = %q, %v", k, err)
|
||||
}
|
||||
if g, err := ParseCandidatesKey("feat/x"); err != nil || g != "feat/x" {
|
||||
t.Fatalf("parse candidates = %q, %v", g, err)
|
||||
}
|
||||
if k, err := ModulesKey("mesh-mail"); err != nil || k != "mesh-mail" {
|
||||
t.Fatalf("modules key = %q, %v", k, err)
|
||||
}
|
||||
if m, err := ParseModulesKey("mesh-mail"); err != nil || m != "mesh-mail" {
|
||||
t.Fatalf("parse modules = %q, %v", m, err)
|
||||
}
|
||||
for _, bad := range []string{"", "a.b", "a*b", "a>b", "a b", "a\tb"} {
|
||||
if _, err := CandidatesKey(bad); err == nil {
|
||||
t.Errorf("candidates key %q accepted", bad)
|
||||
}
|
||||
if _, err := ModulesKey(bad); err == nil {
|
||||
t.Errorf("modules key %q accepted", bad)
|
||||
}
|
||||
if _, err := ParseCandidatesKey(bad); err == nil {
|
||||
t.Errorf("parse candidates %q accepted", bad)
|
||||
}
|
||||
if _, err := ParseModulesKey(bad); err == nil {
|
||||
t.Errorf("parse modules %q accepted", bad)
|
||||
}
|
||||
}
|
||||
ev, err := ForgeEvent("novox/mesh-sdk", ForgeMerged)
|
||||
if err != nil || ev != "forge.novox.mesh-sdk.merged" {
|
||||
t.Fatalf("forge event = %q, %v", ev, err)
|
||||
}
|
||||
repo, kind, err := ParseForgeEvent(ev)
|
||||
if err != nil || repo != "novox/mesh-sdk" || kind != ForgeMerged {
|
||||
t.Fatalf("parse forge event = %q %q %v", repo, kind, err)
|
||||
}
|
||||
for _, k := range []string{ForgePull, ForgeMerged, ForgeRefused} {
|
||||
if _, err := ForgeEvent("a/b", k); err != nil {
|
||||
t.Errorf("kind %s refused: %v", k, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []struct{ repo, kind string }{
|
||||
{"", "pull"}, {"novox", "pull"}, {"a/b/c", "pull"}, {"/b", "pull"}, {"a/", "pull"},
|
||||
{"a.b/c", "pull"}, {"a/b c", "pull"}, {"a/b", "closed"}, {"a/b", ""},
|
||||
} {
|
||||
if _, err := ForgeEvent(bad.repo, bad.kind); err == nil {
|
||||
t.Errorf("forge event %q %q accepted", bad.repo, bad.kind)
|
||||
} else if bad.kind == "closed" && !strings.Contains(err.Error(), "closed") {
|
||||
t.Errorf("error does not name the kind: %v", err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "forge.a.b", "forge.a.b.closed", "x.a.b.pull", "forge.a.b.pull.x", "forge..b.pull"} {
|
||||
if _, _, err := ParseForgeEvent(bad); err == nil {
|
||||
t.Errorf("parse forge event %q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadsHash(t *testing.T) { // 1200/AC2
|
||||
a := Head{Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"}
|
||||
b := Head{Repository: "novox/mesh-control", Number: 7, Head: "bbbb"}
|
||||
h1, err := HeadsHash([]Head{a, b})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h2, _ := HeadsHash([]Head{b, a})
|
||||
if h1 != h2 {
|
||||
t.Errorf("order changes the hash: %s %s", h1, h2)
|
||||
}
|
||||
if len(h1) != 12 || strings.Trim(h1, "0123456789abcdef") != "" {
|
||||
t.Errorf("hash %q is not 12 hex digits", h1)
|
||||
}
|
||||
for _, changed := range []Head{
|
||||
{Repository: "novox/mesh-sdk", Number: 36, Head: "aaab"},
|
||||
{Repository: "novox/mesh-sdk", Number: 37, Head: "aaaa"},
|
||||
{Repository: "novox/other", Number: 36, Head: "aaaa"},
|
||||
} {
|
||||
h3, _ := HeadsHash([]Head{changed, b})
|
||||
if h3 == h1 {
|
||||
t.Errorf("changing %v keeps the hash", changed)
|
||||
}
|
||||
}
|
||||
if _, err := HeadsHash(nil); err == nil {
|
||||
t.Error("an empty list is accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func sampleSource() Source {
|
||||
return Source{Group: "feat/x", Heads: map[string]string{"novox/mesh-sdk": "abc123", "novox/mesh-control": "def456"}}
|
||||
}
|
||||
|
||||
func sampleCandidate() Candidate {
|
||||
pulls := []CandidatePull{
|
||||
{Head: Head{Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"}, Modules: []string{"mesh-mail"}},
|
||||
{Head: Head{Repository: "novox/mesh-control", Number: 7, Head: "bbbb"}, Modules: []string{"mesh-controller"}},
|
||||
}
|
||||
hh, _ := HeadsHash([]Head{pulls[0].Head, pulls[1].Head})
|
||||
return Candidate{Group: "feat/x", Base: 12, HeadsHash: hh, Pulls: pulls}
|
||||
}
|
||||
|
||||
func sampleForges() []Forge {
|
||||
return []Forge{
|
||||
{Kind: ForgePull, Repository: "novox/mesh-sdk", Branch: "feat/x", Number: 36, Head: "aaaa", Action: "opened"},
|
||||
{Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa", Merge: "cccc",
|
||||
Trees: map[string]string{"modules/a": "t1"}, Root: "t0"},
|
||||
{Kind: ForgeRefused, Repository: "novox/mesh-sdk", Act: "novox/mesh-sdk.36.aaaa.view", Words: "403 forbidden"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceValuesRoundTrip(t *testing.T) { // 1200/AC3
|
||||
check := func(name string, data []byte, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
var probe map[string]any
|
||||
if err := json.Unmarshal(data, &probe); err != nil || probe["v"] != float64(1) {
|
||||
t.Fatalf("%s: json %s does not carry v:1", name, data)
|
||||
}
|
||||
}
|
||||
s := sampleSource()
|
||||
data, err := EncodeSource(s)
|
||||
check("source", data, err)
|
||||
s.V = Version
|
||||
if got, err := DecodeSource(data); err != nil || !SameSource(got, s) {
|
||||
t.Errorf("source round trip: %+v %v", got, err)
|
||||
}
|
||||
c := sampleCandidate()
|
||||
data, err = EncodeCandidate(c)
|
||||
check("candidate", data, err)
|
||||
c.V = Version
|
||||
if got, err := DecodeCandidate(data); err != nil || !SameCandidate(got, c) {
|
||||
t.Errorf("candidate round trip: %+v %v", got, err)
|
||||
}
|
||||
for _, m := range []Module{
|
||||
{FP: "src1:abc", Src: 5, Repository: "novox/mesh-sdk", Commit: "cccc"},
|
||||
{None: "a build that resolves packages when built", Repository: "novox/mesh-sdk", Commit: "cccc"},
|
||||
} {
|
||||
data, err = EncodeModule(m)
|
||||
check("module", data, err)
|
||||
m.V = Version
|
||||
if got, err := DecodeModule(data); err != nil || !SameModule(got, m) {
|
||||
t.Errorf("module round trip: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
for _, f := range sampleForges() {
|
||||
data, err = EncodeForge(f)
|
||||
check("forge "+f.Kind, data, err)
|
||||
f.V = Version
|
||||
if got, err := DecodeForge(data); err != nil || !SameForge(got, f) {
|
||||
t.Errorf("forge %s round trip: %+v %v", f.Kind, got, err)
|
||||
}
|
||||
}
|
||||
for name, dec := range map[string]func([]byte) error{
|
||||
"source": func(b []byte) error { _, err := DecodeSource(b); return err },
|
||||
"candidate": func(b []byte) error { _, err := DecodeCandidate(b); return err },
|
||||
"module": func(b []byte) error { _, err := DecodeModule(b); return err },
|
||||
"forge": func(b []byte) error { _, err := DecodeForge(b); return err },
|
||||
} {
|
||||
for _, in := range []string{`{"group":"g"}`, `{"v":2,"group":"g"}`} {
|
||||
if err := dec([]byte(in)); !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("%s decode of %s = %v, want ErrUnknownVersion", name, in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceValuesRefused(t *testing.T) { // 1200/AC4
|
||||
wantErr := func(name string, err error, words ...string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Errorf("%s accepted", name)
|
||||
return
|
||||
}
|
||||
for _, w := range words {
|
||||
if !strings.Contains(err.Error(), w) {
|
||||
t.Errorf("%s: error %q does not name %q", name, err, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err := EncodeSource(Source{Group: "g"})
|
||||
wantErr("source without repositories", err, "repositor")
|
||||
_, err = EncodeSource(Source{Group: "g", Heads: map[string]string{"a/b": ""}})
|
||||
wantErr("source with an empty head", err, "a/b")
|
||||
_, err = EncodeSource(Source{Group: "g", Heads: map[string]string{"": "x"}})
|
||||
wantErr("source with an empty repository", err, "repositor")
|
||||
|
||||
c := sampleCandidate()
|
||||
c.HeadsHash = "000000000000"
|
||||
_, err = EncodeCandidate(c)
|
||||
wantErr("candidate with a stale hash", err, "heads hash")
|
||||
c = sampleCandidate()
|
||||
c.Pulls = nil
|
||||
_, err = EncodeCandidate(c)
|
||||
wantErr("candidate without pulls", err, "pull")
|
||||
|
||||
_, err = EncodeModule(Module{FP: "src1:x", None: "why", Repository: "a/b", Commit: "c"})
|
||||
wantErr("module with both", err, "both")
|
||||
_, err = EncodeModule(Module{Repository: "a/b", Commit: "c"})
|
||||
wantErr("module with neither", err, "fingerprint", "none")
|
||||
_, err = EncodeModule(Module{FP: "abc", Repository: "a/b", Commit: "c"})
|
||||
wantErr("module with a bad fingerprint", err, "src1:")
|
||||
|
||||
f := sampleForges()[1]
|
||||
f.Merge = ""
|
||||
_, err = EncodeForge(f)
|
||||
wantErr("merged without merge commit", err, "merge")
|
||||
f = sampleForges()[1]
|
||||
f.Root = ""
|
||||
_, err = EncodeForge(f)
|
||||
wantErr("merged without root", err, "root")
|
||||
f = sampleForges()[1]
|
||||
f.Trees = nil
|
||||
_, err = EncodeForge(f)
|
||||
wantErr("merged without trees", err, "tree")
|
||||
f = sampleForges()[1]
|
||||
f.Trees = map[string]string{"modules/a": ""}
|
||||
_, err = EncodeForge(f)
|
||||
wantErr("merged with an empty tree", err, "modules/a")
|
||||
f = sampleForges()[0]
|
||||
f.Action = "reopened"
|
||||
_, err = EncodeForge(f)
|
||||
wantErr("pull with an unknown action", err, "reopened")
|
||||
f = sampleForges()[0]
|
||||
f.Head = ""
|
||||
_, err = EncodeForge(f)
|
||||
wantErr("pull without head", err, "head")
|
||||
f = sampleForges()[2]
|
||||
f.Words = ""
|
||||
_, err = EncodeForge(f)
|
||||
wantErr("refused without words", err, "words")
|
||||
f = sampleForges()[2]
|
||||
f.Act = ""
|
||||
_, err = EncodeForge(f)
|
||||
wantErr("refused without act", err, "act")
|
||||
_, err = EncodeForge(Forge{Kind: "closed", Repository: "a/b"})
|
||||
wantErr("unknown kind", err, "closed")
|
||||
_, err = EncodeForge(Forge{Kind: ForgePull, Repository: "nope", Number: 1, Head: "h", Action: "opened"})
|
||||
wantErr("bad repository", err, "nope")
|
||||
}
|
||||
|
||||
func TestSourceSame(t *testing.T) { // 1200/AC5
|
||||
if !SameSource(sampleSource(), sampleSource()) {
|
||||
t.Error("equal sources differ")
|
||||
}
|
||||
s := sampleSource()
|
||||
s.Heads["novox/mesh-sdk"] = "zzz"
|
||||
if SameSource(s, sampleSource()) {
|
||||
t.Error("different sources are the same")
|
||||
}
|
||||
if !SameCandidate(sampleCandidate(), sampleCandidate()) {
|
||||
t.Error("equal candidates differ")
|
||||
}
|
||||
c := sampleCandidate()
|
||||
c.Pulls[0].Modules = []string{"other"}
|
||||
if SameCandidate(c, sampleCandidate()) {
|
||||
t.Error("different candidates are the same")
|
||||
}
|
||||
m := Module{FP: "src1:a", Repository: "a/b", Commit: "c"}
|
||||
if !SameModule(m, m) || SameModule(m, Module{FP: "src1:b", Repository: "a/b", Commit: "c"}) {
|
||||
t.Error("SameModule wrong")
|
||||
}
|
||||
f := sampleForges()[1]
|
||||
g := sampleForges()[1]
|
||||
if !SameForge(f, g) {
|
||||
t.Error("equal forge messages differ")
|
||||
}
|
||||
g.Trees["modules/a"] = "other"
|
||||
if SameForge(f, g) {
|
||||
t.Error("different forge messages are the same")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceBuckets(t *testing.T) {
|
||||
if SourceBucket != "mesh-delivery-gate_source" || CandidatesBucket != "mesh-delivery-indexer_candidates" ||
|
||||
ModulesBucket != "mesh-delivery-indexer_modules" || SourceMainKey != "main" ||
|
||||
SourceHistory != 64 || CandidatesHistory != 10 || ModulesHistory != 20 {
|
||||
t.Error("a bucket or history differs from the decided layout")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user