diff --git a/contracts/go/clients/meshdeliverycheckerclient/client.go b/contracts/go/clients/meshdeliverycheckerclient/client.go index c7d75a2e..5263a5c8 100644 --- a/contracts/go/clients/meshdeliverycheckerclient/client.go +++ b/contracts/go/clients/meshdeliverycheckerclient/client.go @@ -278,3 +278,35 @@ func CheckPreview(p checker.PreviewsByRepositoryHeadTrunkValue) error { } return nil } + +// VerdictKey is the key, in the state verdicts, of a group's check at its heads: `.`, the +// group escaped so a branch such as `fix/1.2` keys, the heads hash as the mesh-delivery client's HeadsHash gives it +// (12 lowercase hex digits). +func VerdictKey(group, headsHash string) (string, error) { + if group == "" { + return "", fmt.Errorf("meshdeliverycheckerclient: the group name is empty") + } + if !isHex12(headsHash) { + return "", fmt.Errorf("meshdeliverycheckerclient: %q is not a heads hash, 12 lowercase hex digits", headsHash) + } + return keys.EscapeName(group) + "." + headsHash, nil +} + +// ParseVerdictKey is the inverse of VerdictKey. +func ParseVerdictKey(key string) (group, headsHash string, err error) { + i := strings.LastIndex(key, ".") + if i < 0 { + return "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a verdict key, .", key) + } + part, headsHash := key[:i], key[i+1:] + if !isHex12(headsHash) { + return "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a verdict key: %q is not a heads hash, 12 lowercase hex digits", key, headsHash) + } + if part == "" || strings.Contains(part, ".") { + return "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a verdict key: the group part is empty or holds a dot", key) + } + if group, err = keys.UnescapeName(part); err != nil { + return "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a verdict key: %w", key, err) + } + return group, headsHash, nil +} diff --git a/contracts/go/clients/meshdeliverycheckerclient/client_test.go b/contracts/go/clients/meshdeliverycheckerclient/client_test.go index 04191ae5..c9ef2850 100644 --- a/contracts/go/clients/meshdeliverycheckerclient/client_test.go +++ b/contracts/go/clients/meshdeliverycheckerclient/client_test.go @@ -125,3 +125,27 @@ func previewIsRefused(t *testing.T) { t.Errorf("a head in conflict is refused: %v", err) } } + +// A verdict's key as go/delivery's CheckerVerdictKey wrote it (golden): the group escaped, the heads hash after the +// last dot. +func TestVerdictKeyAsGoDeliveryWroteIt(t *testing.T) { + key, err := VerdictKey("fix/1.2", "0123456789ab") + if err != nil || key != "fix/1=2e2.0123456789ab" { + t.Fatalf("%q %v", key, err) + } + group, heads, err := ParseVerdictKey(key) + if err != nil || group != "fix/1.2" || heads != "0123456789ab" { + t.Fatalf("%q %q %v", group, heads, err) + } + for _, bad := range []string{"nodot", "g.XYZ", ".0123456789ab", "a.b.0123456789ab", "g=zz.0123456789ab"} { + if _, _, err := ParseVerdictKey(bad); err == nil { + t.Errorf("%q parses", bad) + } + } + if _, err := VerdictKey("", "0123456789ab"); err == nil { + t.Error("an empty group keys") + } + if _, err := VerdictKey("g", "0123"); err == nil { + t.Error("a short hash keys") + } +} diff --git a/contracts/go/clients/meshdeliveryclient/gate.go b/contracts/go/clients/meshdeliveryclient/gate.go new file mode 100644 index 00000000..de79bd28 --- /dev/null +++ b/contracts/go/clients/meshdeliveryclient/gate.go @@ -0,0 +1,407 @@ +package meshdeliveryclient + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + "time" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The values of the seat's states `source`, `merges`, `asks`, `owed` and `turns`, in the seat's types: each is stamped +// with Version when encoded, read strictly (an unknown field refused) and its version checked when decoded, and its rules +// held on both sides, so the gate that writes them and the readers (the forge bridge, the teller, the assigner, the view) +// agree. What the schema cannot say is written here once (ADR 0625 §4). + +// Version is the layout version of the seat's values. +const Version = 1 + +// MergeState is the state 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" +) + +// SourceMainKey is the one key of the state source. +const SourceMainKey = "main" + +func encodeValue(v any, check error, stamp func()) ([]byte, error) { + if check != nil { + return nil, check + } + stamp() + return json.Marshal(v) +} + +// ApprovedKey is the key, in a merge's approved heads, of a pull request: `@`. +func ApprovedKey(repository string, number int) string { + return repository + "@" + strconv.Itoa(number) +} + +// TurnsKey is the key, in the state turns, of a repository: `.`, lower-case and escaped. +func TurnsKey(repository string) (string, error) { + repository = strings.ToLower(repository) + part, err := escapeRepository(repository) + if err != nil { + return "", err + } + return strings.Replace(part, "/", ".", 1), nil +} + +// ParseTurnsKey is the inverse of TurnsKey; a key that does not name a lower-case / is refused. +func ParseTurnsKey(key string) (string, error) { + owner, name, ok := strings.Cut(key, ".") + if !ok || owner == "" || name == "" || strings.Contains(name, ".") { + return "", fmt.Errorf("meshdeliveryclient: %q is not a turns key, .", key) + } + repository, err := unescapeRepository(owner + "/" + name) + if err != nil { + return "", fmt.Errorf("meshdeliveryclient: %q is not a turns key: %w", key, err) + } + if again, err := TurnsKey(repository); err != nil || again != key { + return "", fmt.Errorf("meshdeliveryclient: %q is not a turns key: it does not name a lowercase /", key) + } + return repository, nil +} + +// ----- source + +// CheckSource refuses a source naming no repository, a repository with no name or an empty head. +func CheckSource(s seat.SourceByMainValue) error { + if len(s.Heads) == 0 { + return fmt.Errorf("meshdeliveryclient: a source names no repository") + } + for repository, head := range s.Heads { + if repository == "" { + return fmt.Errorf("meshdeliveryclient: a source names a repository with no name") + } + if head == "" { + return fmt.Errorf("meshdeliveryclient: the source's head of %s is empty", repository) + } + } + return nil +} + +// EncodeSource is s as the value of the key main. +func EncodeSource(s seat.SourceByMainValue) ([]byte, error) { + return encodeValue(&s, CheckSource(s), func() { s.V = Version }) +} + +// DecodeSource reads the value of the key main. +func DecodeSource(data []byte) (seat.SourceByMainValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.SourceByMainValue{}, err + } + s, err := seat.DecodeSourceByMainValue(data) + if err != nil { + return s, fmt.Errorf("meshdeliveryclient: a source does not fit its shape: %w", err) + } + return s, CheckSource(s) +} + +// SameSource says whether two sources are the same. +func SameSource(a, b seat.SourceByMainValue) bool { return reflect.DeepEqual(a, b) } + +// ----- merge + +// CheckMerge refuses a merge naming no group, ask or pull request, a pull request outside the approved heads or at +// another head than the Approve was for, and a state that lacks what it needs: merged its commit, refused the forge's words. +func CheckMerge(m seat.MergesByGroupValue) error { + if m.Group == "" { + return fmt.Errorf("meshdeliveryclient: a merge names no group") + } + if m.Ask == "" { + return fmt.Errorf("meshdeliveryclient: a merge names no ask") + } + if len(m.Pulls) == 0 { + return fmt.Errorf("meshdeliveryclient: a merge names no pull request") + } + for key, sha := range m.Approved { + if sha == "" { + return fmt.Errorf("meshdeliveryclient: the approved head of %s is empty", key) + } + } + for _, p := range m.Pulls { + who := fmt.Sprintf("%s#%d", p.Repository, p.Number) + if len(m.Approved) > 0 { + sha, ok := m.Approved[ApprovedKey(p.Repository, int(p.Number))] + if !ok { + return fmt.Errorf("meshdeliveryclient: the pull request %s is handed to the merge but is not among the approved heads", who) + } + if sha != p.Head { + return fmt.Errorf("meshdeliveryclient: the pull request %s is handed at head %q but the Approve was for %q", who, p.Head, sha) + } + } + switch MergeState(p.State) { + case MergeMerging: + case MergeMerged: + if p.Commit == "" { + return fmt.Errorf("meshdeliveryclient: the merged pull request %s has no merge commit", who) + } + case MergeRefused: + if p.Words == "" { + return fmt.Errorf("meshdeliveryclient: the refused pull request %s has no words of the forge", who) + } + default: + return fmt.Errorf("meshdeliveryclient: the state %q of %s is not merging, merged or refused", p.State, who) + } + } + return nil +} + +// EncodeMerge is m as the value of a group's key. +func EncodeMerge(m seat.MergesByGroupValue) ([]byte, error) { + return encodeValue(&m, CheckMerge(m), func() { m.V = Version }) +} + +// DecodeMerge reads the value of a group's key. +func DecodeMerge(data []byte) (seat.MergesByGroupValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.MergesByGroupValue{}, err + } + m, err := seat.DecodeMergesByGroupValue(data) + if err != nil { + return m, fmt.Errorf("meshdeliveryclient: a merge does not fit its shape: %w", err) + } + return m, CheckMerge(m) +} + +// SameMerge says whether two merges are the same. +func SameMerge(a, b seat.MergesByGroupValue) bool { return reflect.DeepEqual(a, b) } + +// ----- ask + +// HeadsOf are an ask's heads as Heads (for HeadsHash and MergeAskKey). +func HeadsOf(items []seat.AsksByMergeGroupHeadsValueHeadsItem) []Head { + out := make([]Head, len(items)) + for i, h := range items { + out[i] = Head{Repository: h.Repository, Number: int(h.Number), Head: h.Head} + } + return out +} + +// CheckAsk refuses an ask with no group or heads, a negative count, or an ask that is no object. +func CheckAsk(a seat.AsksByMergeGroupHeadsValue) error { + if a.Asking < 0 { + return fmt.Errorf("meshdeliveryclient: the ask's asking %d is negative", a.Asking) + } + if _, err := MergeAskKey(a.Group, HeadsOf(a.Heads)); err != nil { + return fmt.Errorf("meshdeliveryclient: an ask needs its group and at least one pull request's head: %w", err) + } + raw, err := json.Marshal(a.Ask) + var fields map[string]json.RawMessage + if err != nil || len(bytes.TrimSpace(raw)) == 0 || json.Unmarshal(raw, &fields) != nil || fields == nil { + return fmt.Errorf("meshdeliveryclient: the value carries no router's ask") + } + return nil +} + +// EncodeAsk is a as the value of a merge ask's key; a.Ask may be a json.RawMessage. +func EncodeAsk(a seat.AsksByMergeGroupHeadsValue) ([]byte, error) { + return encodeValue(&a, CheckAsk(a), func() { a.V = Version }) +} + +// DecodeAsk reads the value of a merge ask's key; its Ask is the router's ask as a decoded object. +func DecodeAsk(data []byte) (seat.AsksByMergeGroupHeadsValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.AsksByMergeGroupHeadsValue{}, err + } + a, err := seat.DecodeAsksByMergeGroupHeadsValue(data) + if err != nil { + return a, fmt.Errorf("meshdeliveryclient: an ask does not fit its shape: %w", err) + } + return a, CheckAsk(a) +} + +// SameAsk says whether two asks are the same (the router's ask by its content). +func SameAsk(a, b seat.AsksByMergeGroupHeadsValue) bool { return reflect.DeepEqual(a, b) } + +// ----- owed + +// EncodeOwed is o as the value of an owed key, after CheckOwed. +func EncodeOwed(o seat.OwedByRepositoryNumberHeadActValue) ([]byte, error) { + return encodeValue(&o, CheckOwed(o), func() { o.V = Version }) +} + +// DecodeOwed reads the value of an owed key. +func DecodeOwed(data []byte) (seat.OwedByRepositoryNumberHeadActValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.OwedByRepositoryNumberHeadActValue{}, err + } + o, err := seat.DecodeOwedByRepositoryNumberHeadActValue(data) + if err != nil { + return o, fmt.Errorf("meshdeliveryclient: an owed value does not fit its shape: %w", err) + } + return o, CheckOwed(o) +} + +// SameOwed says whether two owed values are the same. +func SameOwed(a, b seat.OwedByRepositoryNumberHeadActValue) bool { return a == b } + +// ----- turns + +// CheckTurn refuses a turn whose repository is not lower-case, that names no delivery, or whose deliveries repeat a heads +// hash, lack their times, are out of the order of the Approve, or name a wait without a verdict key. +func CheckTurn(t seat.TurnsByRepositoryValue) error { + repository := strings.ToLower(t.Repository) + if repository != t.Repository { + return fmt.Errorf("meshdeliveryclient: the turn's repository %q is not lowercase", t.Repository) + } + if _, err := escapeRepository(repository); err != nil { + return err + } + if len(t.Waiting) == 0 { + return fmt.Errorf("meshdeliveryclient: the turn of %s names no delivery: its key is deleted instead", t.Repository) + } + seen := map[string]bool{} + for i, w := range t.Waiting { + if w.Group == "" { + return fmt.Errorf("meshdeliveryclient: a delivery in the turn of %s names no group", t.Repository) + } + if !hashOK.MatchString(w.Heads) { + return fmt.Errorf("meshdeliveryclient: the heads hash %q in the turn of %s is not 12 lowercase hex digits", w.Heads, t.Repository) + } + if seen[w.Heads] { + return fmt.Errorf("meshdeliveryclient: the turn of %s names the heads %s twice", t.Repository, w.Heads) + } + seen[w.Heads] = true + if w.Approved.IsZero() || w.Since.IsZero() { + return fmt.Errorf("meshdeliveryclient: the delivery %s in the turn of %s says no time of its Approve or of its wait", w.Group, t.Repository) + } + if i > 0 && w.Approved.Before(t.Waiting[i-1].Approved) { + return fmt.Errorf("meshdeliveryclient: the turn of %s is not in the order of the Approve: %s was approved before %s", t.Repository, w.Group, t.Waiting[i-1].Group) + } + if w.WaitsFor != "" { + if _, _, err := parseVerdictKey(w.WaitsFor); err != nil { + return fmt.Errorf("meshdeliveryclient: the delivery %s in the turn of %s waits for what is not a verdict key: %w", w.Group, t.Repository, err) + } + } else if (w.WaitsSince != nil && !w.WaitsSince.IsZero()) || w.Step != "" { + return fmt.Errorf("meshdeliveryclient: the delivery %s in the turn of %s says when and where a check runs, and names no check", w.Group, t.Repository) + } + } + return nil +} + +// parseVerdictKey is the checker's verdict key `.` (the checker's client has the same rule over its +// own seat; a client uses its own seat's contract alone). +func parseVerdictKey(key string) (group, heads string, err error) { + i := strings.LastIndex(key, ".") + if i < 0 || !hashOK.MatchString(key[i+1:]) || i == 0 || strings.Contains(key[:i], ".") { + return "", "", fmt.Errorf("%q is not a verdict key, .", key) + } + if group, err = keys.UnescapeName(key[:i]); err != nil { + return "", "", err + } + return group, key[i+1:], nil +} + +// TurnHolds says whether a delivery at these heads is not in the turn while the turn names others: the delivery's +// check or merge holds back for the one before it. +func TurnHolds(t seat.TurnsByRepositoryValue, heads string) bool { + if len(t.Waiting) == 0 { + return false + } + for _, w := range t.Waiting { + if w.Heads == heads { + return false + } + } + return true +} + +// EncodeTurn is t as the value of a repository's key. +func EncodeTurn(t seat.TurnsByRepositoryValue) ([]byte, error) { + if err := CheckTurn(t); err != nil { + return nil, err + } + t.V = Version + return json.Marshal(t) +} + +// DecodeTurn reads the value of a repository's key. +func DecodeTurn(data []byte) (seat.TurnsByRepositoryValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.TurnsByRepositoryValue{}, err + } + t, err := seat.DecodeTurnsByRepositoryValue(data) + if err != nil { + return t, fmt.Errorf("meshdeliveryclient: a turn does not fit its shape: %w", err) + } + return t, CheckTurn(t) +} + +// SameTurn says whether two turns are the same, a time by the instant it names. +func SameTurn(a, b seat.TurnsByRepositoryValue) bool { + if len(a.Waiting) != len(b.Waiting) || a.Repository != b.Repository || a.V != b.V { + return false + } + for i := range a.Waiting { + x, y := a.Waiting[i], b.Waiting[i] + if !x.Approved.Equal(y.Approved) || !x.Since.Equal(y.Since) || !sameTime(x.WaitsSince, y.WaitsSince) { + return false + } + x.Approved, y.Approved, x.Since, y.Since, x.WaitsSince, y.WaitsSince = time.Time{}, time.Time{}, time.Time{}, time.Time{}, nil, nil + if !reflect.DeepEqual(x, y) { + return false + } + } + return true +} + +func sameTime(a, b *time.Time) bool { + if a == nil || a.IsZero() { + return b == nil || b.IsZero() + } + return b != nil && a.Equal(*b) +} + +// Answer is the operator's answer to a merge ask as the gate acts on it: the option chosen, the warrant that proves who +// answered, and the words said. It is the teller's state `answers` under the ask's key (the teller's own record; this is +// its layout for the gate that reads it). +type Answer struct { + V int `json:"v"` + Option string `json:"option"` + Warrant string `json:"warrant"` + Words string `json:"words"` +} + +// CheckAnswer refuses an answer lacking its option, warrant or words. +func CheckAnswer(a Answer) error { + for _, f := range []struct{ name, value string }{{"option", a.Option}, {"warrant", a.Warrant}, {"words", a.Words}} { + if f.value == "" { + return fmt.Errorf("meshdeliveryclient: an answer has no %s", f.name) + } + } + return nil +} + +// EncodeAnswer is a as the value of an answer's key. +func EncodeAnswer(a Answer) ([]byte, error) { + return encodeValue(&a, CheckAnswer(a), func() { a.V = Version }) +} + +// DecodeAnswer reads the value of an answer's key. +func DecodeAnswer(data []byte) (Answer, error) { + if err := keys.CheckVersion(data); err != nil { + return Answer{}, err + } + var a Answer + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&a); err != nil { + return Answer{}, fmt.Errorf("meshdeliveryclient: an answer does not fit its shape: %w", err) + } + return a, CheckAnswer(a) +} + +// SameAnswer says whether two answers are the same. +func SameAnswer(a, b Answer) bool { return a == b } diff --git a/contracts/go/clients/meshdeliveryclient/gate_test.go b/contracts/go/clients/meshdeliveryclient/gate_test.go new file mode 100644 index 00000000..4ec7b46e --- /dev/null +++ b/contracts/go/clients/meshdeliveryclient/gate_test.go @@ -0,0 +1,186 @@ +package meshdeliveryclient + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// sameJSON says whether two documents hold the same values (the generated types write their fields in alphabetical +// order, the old package's in declaration order: the bytes differ, the documents do not). +func sameJSON(t *testing.T, got []byte, want string) { + t.Helper() + var g, w any + if err := json.Unmarshal(got, &g); err != nil { + t.Fatalf("%s: %v", got, err) + } + if err := json.Unmarshal([]byte(want), &w); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(g, w) { + t.Errorf("got %s\nwant %s", got, want) + } +} + +// The goldens are the output of go/delivery's Encode* before it was deleted; each value must encode to the same +// document and decode back to what it was. +func TestGateValuesEncodeAsGoDeliveryDid(t *testing.T) { + ap := time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC) + wait := ap.Add(2 * time.Minute) + for _, c := range []struct { + name string + encode func() ([]byte, error) + decode func([]byte) (any, error) + value any + golden string + }{ + {"source", func() ([]byte, error) { + return EncodeSource(seat.SourceByMainValue{Group: "fix/1.2", Heads: map[string]string{"novox/a": "aaaa", "novox/b": "bbbb"}}) + }, func(b []byte) (any, error) { return DecodeSource(b) }, + seat.SourceByMainValue{V: 1, Group: "fix/1.2", Heads: map[string]string{"novox/a": "aaaa", "novox/b": "bbbb"}}, + `{"v":1,"group":"fix/1.2","heads":{"novox/a":"aaaa","novox/b":"bbbb"}}`}, + {"merge", func() ([]byte, error) { + return EncodeMerge(seat.MergesByGroupValue{Group: "g", Ask: "merge.g.0123456789ab", Approved: map[string]string{"novox/a@7": "h1"}, + Pulls: []seat.MergesByGroupValuePullsItem{{Repository: "novox/a", Number: 7, Head: "h1", State: "merged", Commit: "c1"}}}) + }, func(b []byte) (any, error) { return DecodeMerge(b) }, + seat.MergesByGroupValue{V: 1, Group: "g", Ask: "merge.g.0123456789ab", Approved: map[string]string{"novox/a@7": "h1"}, + Pulls: []seat.MergesByGroupValuePullsItem{{Repository: "novox/a", Number: 7, Head: "h1", State: "merged", Commit: "c1"}}}, + `{"v":1,"group":"g","ask":"merge.g.0123456789ab","approved":{"novox/a@7":"h1"},"pulls":[{"repository":"novox/a","number":7,"head":"h1","state":"merged","commit":"c1"}]}`}, + {"ask", func() ([]byte, error) { + return EncodeAsk(seat.AsksByMergeGroupHeadsValue{Group: "g", Asking: 2, Ask: json.RawMessage(`{"headline":"x"}`), + Heads: []seat.AsksByMergeGroupHeadsValueHeadsItem{{Repository: "novox/a", Number: 7, Head: "h1"}}}) + }, func(b []byte) (any, error) { return DecodeAsk(b) }, + seat.AsksByMergeGroupHeadsValue{V: 1, Group: "g", Asking: 2, Ask: map[string]any{"headline": "x"}, + Heads: []seat.AsksByMergeGroupHeadsValueHeadsItem{{Repository: "novox/a", Number: 7, Head: "h1"}}}, + `{"v":1,"group":"g","heads":[{"repository":"novox/a","number":7,"head":"h1"}],"ask":{"headline":"x"},"asking":2}`}, + {"owed", func() ([]byte, error) { + return EncodeOwed(seat.OwedByRepositoryNumberHeadActValue{Act: OwedStatus, Context: StatusContextCheck, Target: "t", State: "pending"}) + }, func(b []byte) (any, error) { return DecodeOwed(b) }, + seat.OwedByRepositoryNumberHeadActValue{V: 1, Act: OwedStatus, Context: StatusContextCheck, Target: "t", State: "pending"}, + `{"v":1,"act":"status","context":"mesh/check","target":"t","state":"pending"}`}, + {"turn", func() ([]byte, error) { + return EncodeTurn(seat.TurnsByRepositoryValue{Repository: "novox/a", Waiting: []seat.TurnsByRepositoryValueWaitingItem{{ + Group: "g", Heads: "0123456789ab", Ask: "k", Approved: ap, Since: ap.Add(time.Minute), WaitsFor: "g.0123456789ab", WaitsSince: &wait, Step: "s"}}}) + }, func(b []byte) (any, error) { return DecodeTurn(b) }, + seat.TurnsByRepositoryValue{V: 1, Repository: "novox/a", Waiting: []seat.TurnsByRepositoryValueWaitingItem{{ + Group: "g", Heads: "0123456789ab", Ask: "k", Approved: ap, Since: ap.Add(time.Minute), WaitsFor: "g.0123456789ab", WaitsSince: &wait, Step: "s"}}}, + `{"v":1,"repository":"novox/a","waiting":[{"group":"g","heads":"0123456789ab","ask":"k","approved":"2026-11-05T10:00:00Z","since":"2026-11-05T10:01:00Z","waits-for":"g.0123456789ab","waits-since":"2026-11-05T10:02:00Z","step":"s"}]}`}, + {"answer", func() ([]byte, error) { return EncodeAnswer(Answer{Option: "merge", Warrant: "w", Words: "ok"}) }, + func(b []byte) (any, error) { return DecodeAnswer(b) }, Answer{V: 1, Option: "merge", Warrant: "w", Words: "ok"}, + `{"v":1,"option":"merge","warrant":"w","words":"ok"}`}, + } { + raw, err := c.encode() + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + sameJSON(t, raw, c.golden) + back, err := c.decode([]byte(c.golden)) + if err != nil || !reflect.DeepEqual(back, c.value) { + t.Errorf("%s decoded %+v, %v; want %+v", c.name, back, err, c.value) + } + } +} + +func TestGateKeysAsGoDeliveryHadThem(t *testing.T) { + for in, want := range map[string]string{"novox/Mesh-sdk": "novox.mesh-sdk", "novox/mesh-sdk": "novox.mesh-sdk", "novox/mesh.x": "novox.mesh=2ex"} { + got, err := TurnsKey(in) + if err != nil || got != want { + t.Errorf("TurnsKey(%q) = %q, %v; want %q", in, got, err, want) + } + } + if r, err := ParseTurnsKey("novox.mesh=2ex"); err != nil || r != "novox/mesh.x" { + t.Errorf("%q %v", r, err) + } + for _, bad := range []string{"novox", "novox.Mesh", "a.b.c", ".x", "novox.mesh-sdk."} { + if _, err := ParseTurnsKey(bad); err == nil { + t.Errorf("%q parses as a turns key", bad) + } + } + if _, err := TurnsKey("novox"); err == nil { + t.Error("a repository with no slash keys") + } + if k := ApprovedKey("novox/a", 7); k != "novox/a@7" { + t.Error(k) + } + if SourceMainKey != seat.SourceByMainKey() { + t.Error("the source's key is the seat's") + } +} + +func TestGateValuesRefuseWhatTheirRulesRefuse(t *testing.T) { + ap := time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC) + pull := seat.MergesByGroupValuePullsItem{Repository: "novox/a", Number: 7, Head: "h1", State: "merging"} + merge := func(edit func(*seat.MergesByGroupValue)) error { + m := seat.MergesByGroupValue{Group: "g", Ask: "k", Pulls: []seat.MergesByGroupValuePullsItem{pull}} + edit(&m) + _, err := EncodeMerge(m) + return err + } + turn := func(edit func(*seat.TurnsByRepositoryValue)) error { + tv := seat.TurnsByRepositoryValue{Repository: "novox/a", Waiting: []seat.TurnsByRepositoryValueWaitingItem{{Group: "g", Heads: "0123456789ab", Approved: ap, Since: ap}}} + edit(&tv) + _, err := EncodeTurn(tv) + return err + } + for name, c := range map[string]struct { + err error + want string + }{ + "merge: no group": {merge(func(m *seat.MergesByGroupValue) { m.Group = "" }), "no group"}, + "merge: no pull": {merge(func(m *seat.MergesByGroupValue) { m.Pulls = nil }), "no pull request"}, + "merge: not approved": {merge(func(m *seat.MergesByGroupValue) { m.Approved = map[string]string{"novox/b@1": "x"} }), "not among the approved"}, + "merge: other head": {merge(func(m *seat.MergesByGroupValue) { m.Approved = map[string]string{"novox/a@7": "x"} }), "Approve was for"}, + "merge: merged no commit": {merge(func(m *seat.MergesByGroupValue) { m.Pulls[0].State = "merged" }), "no merge commit"}, + "merge: refused no words": {merge(func(m *seat.MergesByGroupValue) { m.Pulls[0].State = "refused" }), "no words"}, + "merge: unknown state": {merge(func(m *seat.MergesByGroupValue) { m.Pulls[0].State = "lost" }), "not merging, merged or refused"}, + "turn: upper case": {turn(func(v *seat.TurnsByRepositoryValue) { v.Repository = "Novox/a" }), "not lowercase"}, + "turn: empty": {turn(func(v *seat.TurnsByRepositoryValue) { v.Waiting = nil }), "names no delivery"}, + "turn: bad hash": {turn(func(v *seat.TurnsByRepositoryValue) { v.Waiting[0].Heads = "xyz" }), "12 lowercase hex"}, + "turn: no time": {turn(func(v *seat.TurnsByRepositoryValue) { v.Waiting[0].Since = time.Time{} }), "no time"}, + "turn: step with no wait": {turn(func(v *seat.TurnsByRepositoryValue) { v.Waiting[0].Step = "s" }), "names no check"}, + "turn: not a verdict key": {turn(func(v *seat.TurnsByRepositoryValue) { v.Waiting[0].WaitsFor = "nope" }), "not a verdict key"}, + } { + if c.err == nil || !strings.Contains(c.err.Error(), c.want) { + t.Errorf("%s: %v, want %q", name, c.err, c.want) + } + } + if _, err := EncodeSource(seat.SourceByMainValue{Group: "g"}); err == nil { + t.Error("an empty source encodes") + } + if _, err := EncodeAsk(seat.AsksByMergeGroupHeadsValue{Group: "g", Ask: "text", Heads: []seat.AsksByMergeGroupHeadsValueHeadsItem{{Repository: "novox/a", Number: 1, Head: "h"}}}); err == nil { + t.Error("an ask that is no object encodes") + } + if _, err := EncodeAnswer(Answer{Option: "merge"}); err == nil { + t.Error("an answer with no warrant encodes") + } + if _, err := DecodeSource([]byte(`{"v":2,"group":"g","heads":{"a":"b"}}`)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Errorf("another version: %v", err) + } + if _, err := DecodeMerge([]byte(`{"v":1,"group":"g","ask":"k","pulls":[],"extra":1}`)); err == nil { + t.Error("an unknown field decodes") + } +} + +func TestTurnHoldsAndSameTurn(t *testing.T) { + ap := time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC) + tv := seat.TurnsByRepositoryValue{V: 1, Repository: "novox/a", Waiting: []seat.TurnsByRepositoryValueWaitingItem{{Group: "g", Heads: "0123456789ab", Approved: ap, Since: ap}}} + if TurnHolds(tv, "0123456789ab") || !TurnHolds(tv, "ba9876543210") || TurnHolds(seat.TurnsByRepositoryValue{}, "x") { + t.Error("a turn holds a delivery that is not in it while others wait") + } + other := tv + other.Waiting = []seat.TurnsByRepositoryValueWaitingItem{tv.Waiting[0]} + other.Waiting[0].Approved = ap.In(time.FixedZone("x", 3600)) + if !SameTurn(tv, other) { + t.Error("the same instant in another zone is another turn") + } + other.Waiting[0].Since = ap.Add(time.Second) + if SameTurn(tv, other) { + t.Error("another instant is the same turn") + } +} diff --git a/contracts/go/seats/meshdelivery/contract.go b/contracts/go/seats/meshdelivery/contract.go index c70a8734..1adc3e86 100644 --- a/contracts/go/seats/meshdelivery/contract.go +++ b/contracts/go/seats/meshdelivery/contract.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "strings" + "time" ) // Seat is the seat's name. @@ -433,16 +434,16 @@ type OwedByRepositoryNumberHeadActValue struct { // TurnsByRepositoryValueWaitingItem is a type of the contract. type TurnsByRepositoryValueWaitingItem struct { // when (RFC 3339 time) - Approved string `json:"approved"` - Ask string `json:"ask,omitempty"` - Group string `json:"group"` - Heads string `json:"heads"` + Approved time.Time `json:"approved"` + Ask string `json:"ask,omitempty"` + Group string `json:"group"` + Heads string `json:"heads"` // when (RFC 3339 time) - Since string `json:"since"` - Step string `json:"step,omitempty"` - WaitsFor string `json:"waits-for,omitempty"` + Since time.Time `json:"since"` + Step string `json:"step,omitempty"` + WaitsFor string `json:"waits-for,omitempty"` // when (RFC 3339 time) - WaitsSince string `json:"waits-since,omitempty"` + WaitsSince *time.Time `json:"waits-since,omitempty"` } // TurnsByRepositoryValue is one value of the state turns under the key . diff --git a/contracts/seats/mesh-delivery/seat.json b/contracts/seats/mesh-delivery/seat.json index 8365118a..dd617cc4 100644 --- a/contracts/seats/mesh-delivery/seat.json +++ b/contracts/seats/mesh-delivery/seat.json @@ -529,10 +529,12 @@ }, "approved": { "type": "string", + "format": "date-time", "description": "when (RFC 3339 time)" }, "since": { "type": "string", + "format": "date-time", "description": "when (RFC 3339 time)" }, "waits-for": { @@ -540,6 +542,7 @@ }, "waits-since": { "type": "string", + "format": "date-time", "description": "when (RFC 3339 time)" }, "step": { diff --git a/modules/mesh-delivery-builder/builds/busfree_test.go b/modules/mesh-delivery-builder/builds/busfree_test.go new file mode 100644 index 00000000..6d4af86b --- /dev/null +++ b/modules/mesh-delivery-builder/builds/busfree_test.go @@ -0,0 +1,21 @@ +package builds + +import ( + "os/exec" + "strings" + "testing" +) + +// The builder's published value is read by modules that never touch the bus (mesh-host's composer tool): nothing it +// imports reaches the bus library. +func TestThePackageReachesNoBus(t *testing.T) { + out, err := exec.Command("go", "list", "-deps", ".").Output() + if err != nil { + t.Fatalf("go list: %v", err) + } + for _, p := range strings.Fields(string(out)) { + if strings.HasPrefix(p, "github.com/nats-io/") { + t.Errorf("builds depends on %s", p) + } + } +} diff --git a/sdk/buildrun/keys_test.go b/sdk/buildrun/keys_test.go new file mode 100644 index 00000000..8ce937d1 --- /dev/null +++ b/sdk/buildrun/keys_test.go @@ -0,0 +1,52 @@ +package buildrun + +import ( + "encoding/json" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// keys is a steps state in memory: every value put, in order, per key, and a signal for each end. +type keys struct { + mu sync.Mutex + puts map[string][]buildstep.Step + ended chan string +} + +func newKeys() *keys { return &keys{puts: map[string][]buildstep.Step{}, ended: make(chan string, 16)} } + +func (k *keys) put(key string, value any) (uint64, error) { + step, err := buildstep.DecodeStep(value.(json.RawMessage)) + if err != nil { + return 0, err + } + k.mu.Lock() + k.puts[key] = append(k.puts[key], step) + n := uint64(len(k.puts[key])) + k.mu.Unlock() + if step.State != buildstep.StepRunning { + k.ended <- key + } + return n, nil +} + +func (k *keys) last(key string) buildstep.Step { + k.mu.Lock() + defer k.mu.Unlock() + s := k.puts[key] + return s[len(s)-1] +} + +func (k *keys) waitEnd(t *testing.T) string { + t.Helper() + select { + case key := <-k.ended: + return key + case <-time.After(3 * time.Second): + t.Fatal("the step never ended on its key") + return "" + } +} diff --git a/sdk/buildstep/stale.go b/sdk/buildrun/stale.go similarity index 83% rename from sdk/buildstep/stale.go rename to sdk/buildrun/stale.go index 7e0de98e..6e0b6075 100644 --- a/sdk/buildstep/stale.go +++ b/sdk/buildrun/stale.go @@ -1,10 +1,11 @@ -package buildstep +package buildrun import ( "context" "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" "os" "strconv" "strings" @@ -20,7 +21,7 @@ const RestartReason = "the build module restarted while it ran" // for this process's, which may still run it, so EndOne refuses it. var Boot = time.Now() -// Owner is this process as a step's owner (Step.Owner): `..`, the node's boot id +// Owner is this process as a step's owner (buildstep.Step.Owner): `..`, the node's boot id // (/proc/sys/kernel/random/boot_id), the pid and Boot in milliseconds, base 36. The start keeps two processes apart // that a reboot or a pid namespace gave the same pid. A running step whose owner is another is a previous process's. var Owner = ownerOf(bootID, os.Getpid(), Boot) @@ -84,15 +85,15 @@ func ScanOf(s stdio.KeptState) Scan { // has the tool's name). An ask's key is the stream's to settle, never a restart's: the puller leaves an ask it was // running unacknowledged so the stream redelivers it, and the asker's wait takes the first end it reads on the key. // A key a gone process owns is deleted, never ended (EndStale); one with no owner is ended only once the asker's bound -// has passed (StepBound), when it is nobody's. -func isAsk(key string, s Step) bool { - _, _, err := ParseAskStepKey(key) +// has passed (buildstep.StepBound), when it is nobody's. +func isAsk(key string, s buildstep.Step) bool { + _, _, err := buildstep.ParseAskStepKey(key) return err == nil && strings.HasSuffix(s.Tool, " ask") } // AskWhat says what an ask is, in one line: its kind, and its repository, head and levels, or its module and // fingerprint. -func AskWhat(a Ask) string { +func AskWhat(a buildstep.Ask) string { short := func(s string) string { if len(s) > 12 { return s[:12] @@ -122,9 +123,9 @@ func AskWhat(a Ask) string { } // endRunning writes step ended failed with why, only while its key is still at revision. -func endRunning(scan Scan, key string, step Step, revision uint64, why string) error { - step.State, step.Reason, step.Ended, step.Result = StepFailed, why, now().UTC(), nil - raw, err := EncodeStep(step) +func endRunning(scan Scan, key string, step buildstep.Step, revision uint64, why string) error { + step.State, step.Reason, step.Ended, step.Result = buildstep.StepFailed, why, now().UTC(), nil + raw, err := buildstep.EncodeStep(step) if err != nil { return err } @@ -134,7 +135,7 @@ func endRunning(scan Scan, key string, step Step, revision uint64, why string) e // EndStale ends this node's running steps that no process of this module runs any more, and answers their ids. Only keys // of node are read; a value that is no step is left alone. -// - A hand-started step with an owner (Step.Owner) that is not this process (Owner) is ended failed with +// - A hand-started step with an owner (buildstep.Step.Owner) that is not this process (Owner) is ended failed with // RestartReason at once, whatever its age: its process is gone and nothing redelivers it, so nothing else would // write its end (issue 1890, note 21). // - A pulled ask's step with an owner that is not this process is deleted, not ended (novox/hq ADR 0525 §6): the @@ -146,7 +147,7 @@ func endRunning(scan Scan, key string, step Step, revision uint64, why string) e // - A step owned by this process is left: it may run now. A redelivered ask begun on this node is owned by it. // - A step with no owner (written by an older build) keeps the earlier rule: a hand-started step started before // `before` (the start of this process) is ended; a pulled ask is left to the stream's redelivery (isAsk) until its -// start is older than StepBound, and then ended "past the step bound: its asker stopped waiting at