From cf9c73606148ce8ed182f9184712bb2acaf335dc Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:33:04 +0100 Subject: [PATCH 1/4] =?UTF-8?q?mesh-delivery-view:=20the=20viewstate=20pac?= =?UTF-8?q?kage=20(reach=20and=20the=20view's=20answer=20layout)=20is=20th?= =?UTF-8?q?e=20module's=20own,=20a=20package=20artifact;=20the=20indexer's?= =?UTF-8?q?=20taken=20value=20joins=20its=20take-in=20package;=20trunk=20k?= =?UTF-8?q?eys=20a=20repository=20(D-10,=20ADR=200634=20=C2=A71;=20issue?= =?UTF-8?q?=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/mesh-delivery-indexer/takein/go.mod | 7 +- modules/mesh-delivery-indexer/takein/taken.go | 43 ++++ .../takein/taken_test.go | 33 ++++ modules/mesh-delivery-view/module.json | 9 + modules/mesh-delivery-view/viewstate/go.mod | 10 + modules/mesh-delivery-view/viewstate/reach.go | 184 ++++++++++++++++++ .../viewstate/viewstate_test.go | 91 +++++++++ sdk/trunk/trunk.go | 18 +- sdk/trunk/trunk_test.go | 11 ++ 9 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 modules/mesh-delivery-indexer/takein/taken.go create mode 100644 modules/mesh-delivery-indexer/takein/taken_test.go create mode 100644 modules/mesh-delivery-view/viewstate/go.mod create mode 100644 modules/mesh-delivery-view/viewstate/reach.go create mode 100644 modules/mesh-delivery-view/viewstate/viewstate_test.go diff --git a/modules/mesh-delivery-indexer/takein/go.mod b/modules/mesh-delivery-indexer/takein/go.mod index 48ab0844..b221739e 100644 --- a/modules/mesh-delivery-indexer/takein/go.mod +++ b/modules/mesh-delivery-indexer/takein/go.mod @@ -2,4 +2,9 @@ module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659 +require ( + git.novox.be/novox/mesh-delivery/sdk v0.0.0 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659 +) + +replace git.novox.be/novox/mesh-delivery/sdk => ../../../sdk diff --git a/modules/mesh-delivery-indexer/takein/taken.go b/modules/mesh-delivery-indexer/takein/taken.go new file mode 100644 index 00000000..e7cd3ea8 --- /dev/null +++ b/modules/mesh-delivery-indexer/takein/taken.go @@ -0,0 +1,43 @@ +package takein + +import ( + "encoding/json" + "fmt" + "time" + + "git.novox.be/novox/mesh-delivery/sdk/kept" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// TakenState is the indexer's state `taken`: one key per repository (trunk.RepositoryKey), the last merge it took in. +const TakenState = "taken" + +// Taken is the value of a taken key: the repository, the merge commit the indexer last took in (applied its message +// whole), and when. +type Taken struct { + V int `json:"v"` + Repository string `json:"repository"` + Merge string `json:"merge"` + At time.Time `json:"at"` +} + +// EncodeTaken is t as JSON at Version; a value with no repository or merge is refused. +func EncodeTaken(t Taken) ([]byte, error) { + if t.Repository == "" || t.Merge == "" { + return nil, fmt.Errorf("takein: a taken value names its repository and merge") + } + t.V = keys.Version + return json.Marshal(t) +} + +// DecodeTaken reads a taken value; it refuses an unknown version (ErrUnknownVersion) and one with no merge. +func DecodeTaken(data []byte) (Taken, error) { + var t Taken + if err := kept.Decode(data, &t); err != nil { + return t, err + } + if t.Repository == "" || t.Merge == "" { + return t, fmt.Errorf("takein: a taken value names no repository or merge") + } + return t, nil +} diff --git a/modules/mesh-delivery-indexer/takein/taken_test.go b/modules/mesh-delivery-indexer/takein/taken_test.go new file mode 100644 index 00000000..7a0aca61 --- /dev/null +++ b/modules/mesh-delivery-indexer/takein/taken_test.go @@ -0,0 +1,33 @@ +package takein + +import ( + "errors" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-delivery/sdk/kept" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// 1280: the taken value (ADR 0556) round-trips at its version, and one with no repository or merge, or of another version, +// is refused. +func TestATakenValueRoundTripsAndRefusesWhatLacksARepositoryOrAMerge(t *testing.T) { + tk := Taken{Repository: "novox/mesh-sdk", Merge: "cccc", At: time.Date(2026, 10, 26, 20, 0, 0, 0, time.UTC)} + data, err := EncodeTaken(tk) + if err != nil { + t.Fatal(err) + } + tk.V = keys.Version + if got, err := DecodeTaken(data); err != nil || got != tk { + t.Errorf("taken round trip: %+v %v", got, err) + } + if _, err := EncodeTaken(Taken{Repository: "a/b"}); err == nil || !strings.Contains(err.Error(), "merge") { + t.Errorf("taken without merge: %v", err) + } + for _, in := range []string{`{"v":2,"repository":"a/b","merge":"c"}`, `{"repository":"a/b","merge":"c"}`} { + if _, err := DecodeTaken([]byte(in)); !errors.Is(err, kept.ErrUnknownVersion) { + t.Errorf("decode of %s = %v, want ErrUnknownVersion", in, err) + } + } +} diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index 8608e49d..dccb6fa0 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -188,6 +188,15 @@ "name": "files", "kind": "archive", "from": "files" + }, + { + "name": "viewstate", + "kind": "package", + "language": "go", + "paths": [ + "viewstate" + ], + "series": "0.1" } ] } diff --git a/modules/mesh-delivery-view/viewstate/go.mod b/modules/mesh-delivery-view/viewstate/go.mod new file mode 100644 index 00000000..5a17cb64 --- /dev/null +++ b/modules/mesh-delivery-view/viewstate/go.mod @@ -0,0 +1,10 @@ +module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-delivery/sdk v0.0.0 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f +) + +replace git.novox.be/novox/mesh-delivery/sdk => ../../../sdk diff --git a/modules/mesh-delivery-view/viewstate/reach.go b/modules/mesh-delivery-view/viewstate/reach.go new file mode 100644 index 00000000..4cad0487 --- /dev/null +++ b/modules/mesh-delivery-view/viewstate/reach.go @@ -0,0 +1,184 @@ +// Package viewstate holds what the delivery view owns and no seat carries (novox/hq ADR 0634 §1): the layout of its state +// `reach` (how far a repository's modules reached on the nodes that want them) and of its answers to asks, and the rules over +// them. Other modules import it from the view by its published version (ADR 0573 §1). +package viewstate + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + "strings" + + "git.novox.be/novox/mesh-delivery/sdk/kept" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// ViewModule is the module of the delivery view. +const ViewModule = "mesh-delivery-view" + +// modulesHistory is the revisions the indexer's state `modules` keeps per key, the most changes a module's reach keeps. +const modulesHistory = 20 + +// How far a repository reached (novox/hq ADR 0556, issue 1280). The indexer says, per repository, the last merge it took in +// (the indexer's state `taken`); the delivery view keeps, per repository, how far each of its modules reached on the nodes that want it, and +// derives from it the repository's reached head (ReachBucket): the newest head of the repository at which every wanted +// module runs, on every node, a build at or after its last change. A merge at or before the reached head is on the +// machines. Nothing records a merge as delivered (ADR 0429 §1): the gate and the tracker derive it from this state. + +// ReachBucket is the delivery view's state `reach`: one key per repository (RepositoryKey), how far its modules reached. +const ReachBucket = ViewModule + "_reach" + +// Reach is the value of a reach key: how far the modules of one repository reached on the nodes. Indexed is the last +// merge the indexer took in for it (Taken.Merge), empty while it took in none. A repository the indexer holds no module +// of (a library) has no Modules. Reached is the reached head: the newest head of the repository in the source's history, +// not past Indexed, at which every module with a node that wants it has its floor at or after its last change at or before +// that head; ReachedSrc is the source revision that named it. Holds says, in words, what keeps the next head of the +// repository from being reached (a module on a node, the indexer's lag); empty when the repository's newest head is reached. +type Reach struct { + V int `json:"v"` + Repository string `json:"repository"` + Indexed string `json:"indexed,omitempty"` + Modules map[string]ModuleReach `json:"modules,omitempty"` + Reached string `json:"reached,omitempty"` + ReachedSrc uint64 `json:"reached_src,omitempty"` + Holds []string `json:"holds,omitempty"` +} + +// ModuleReach is how far one module reached. FP is its fingerprint at the indexer now; Nodes how many nodes want it +// (a want not removed); Changes its fingerprints the view knows of, each with the merge commit it first appeared at, +// newest first. Floor is the oldest first-appearance commit among the fingerprints its nodes' haves run, a running have +// counting whether its health check passes or not; it is empty when a node that wants the module has no running have, or +// runs a fingerprint not among Changes. Waiting names, in words, every node where the module is not settled (its have +// different from its want, not running, or not healthy), with or without a floor: health is said here and never moves the +// floor (ADR 0556 clause 2). A module whose floor is empty while a node wants it holds no head of the repository back: a +// reader takes a merge among its Changes as not yet on the machines, with these words. A module no node wants has neither. +type ModuleReach struct { + FP string `json:"fp,omitempty"` + Nodes int `json:"nodes"` + Changes []Change `json:"changes,omitempty"` + Floor string `json:"floor,omitempty"` + Waiting string `json:"waiting,omitempty"` +} + +// Change is one fingerprint of a module, the merge commit it first appeared at (Module.Commit), and the source revision +// that named that commit as the repository's head (Module.Src, or the source's history), 0 when not known. +type Change struct { + Commit string `json:"commit"` + FP string `json:"fp"` + Src uint64 `json:"src,omitempty"` +} + +// MostChanges is how many changes of a module a reach value keeps: as many revisions as the indexer's modules bucket keeps. +const MostChanges = modulesHistory + +func (r Reach) check() error { + if r.Repository == "" { + return fmt.Errorf("viewstate: a reach value names no repository") + } + if (r.Reached == "") != (r.ReachedSrc == 0) { + return fmt.Errorf("viewstate: the reach of %s names its reached head and its source revision together", r.Repository) + } + for name, m := range r.Modules { + if name == "" { + return fmt.Errorf("viewstate: a reach value names a module with no name") + } + if m.Nodes < 0 { + return fmt.Errorf("viewstate: the reach of %s counts %d nodes", name, m.Nodes) + } + if len(m.Changes) > MostChanges { + return fmt.Errorf("viewstate: the reach of %s keeps %d changes, more than %d", name, len(m.Changes), MostChanges) + } + for _, c := range m.Changes { + if c.Commit == "" || c.FP == "" { + return fmt.Errorf("viewstate: a change of %s names no commit or fingerprint", name) + } + } + } + return nil +} + +// EncodeReach is r as JSON at Version; a value that does not check is refused. +func EncodeReach(r Reach) ([]byte, error) { + if err := r.check(); err != nil { + return nil, err + } + r.V = keys.Version + return json.Marshal(r) +} + +// DecodeReach reads a reach value; it refuses an unknown version (ErrUnknownVersion) and one that does not check. +func DecodeReach(data []byte) (Reach, error) { + var r Reach + if err := kept.Decode(data, &r); err != nil { + return r, err + } + return r, r.check() +} + +// SameReach says whether two reach values are the same, so the view writes only when they differ. +func SameReach(a, b Reach) bool { + if a.Repository != b.Repository || a.Indexed != b.Indexed || len(a.Modules) != len(b.Modules) || + a.Reached != b.Reached || a.ReachedSrc != b.ReachedSrc || !slices.Equal(a.Holds, b.Holds) { + return false + } + for _, name := range slices.Sorted(maps.Keys(a.Modules)) { + x, ok := b.Modules[name] + y := a.Modules[name] + if !ok || x.FP != y.FP || x.Nodes != y.Nodes || x.Floor != y.Floor || x.Waiting != y.Waiting || + !slices.Equal(x.Changes, y.Changes) { + return false + } + } + return true +} + +// ViewAnswersBucket is the delivery view's state `answers`: one key per ask it answered, and ViewAnswersHistory the revisions it +// keeps per key. +const ( + ViewAnswersBucket = ViewModule + "_answers" + ViewAnswersHistory = 5 +) + +// ViewAnswer is the value of the view's answer to an ask of the assigner: the asking it answers, the option chosen and the words +// said with it. The key of an answer is the ask's key and ".view" (the assigner's package of asks holds the ask keys). +type ViewAnswer struct { + V int `json:"v"` + Asking string `json:"asking"` + Option string `json:"option"` + Words string `json:"words,omitempty"` +} + +func (a ViewAnswer) check() error { + if strings.TrimSpace(a.Asking) == "" { + return fmt.Errorf("viewstate: a view answer names no asking") + } + if strings.TrimSpace(a.Option) == "" { + return fmt.Errorf("viewstate: a view answer names no option") + } + return nil +} + +// EncodeViewAnswer is a as JSON at the version; an answer naming no asking or no option is refused. +func EncodeViewAnswer(a ViewAnswer) ([]byte, error) { + if err := a.check(); err != nil { + return nil, err + } + a.V = keys.Version + return json.Marshal(a) +} + +// DecodeViewAnswer reads an answer; it refuses an unknown version (kept.ErrUnknownVersion) and what EncodeViewAnswer refuses. +func DecodeViewAnswer(data []byte) (ViewAnswer, error) { + var a ViewAnswer + if err := kept.Decode(data, &a); err != nil { + return ViewAnswer{}, err + } + if err := a.check(); err != nil { + return ViewAnswer{}, err + } + return a, nil +} + +// SameViewAnswer says whether two answers are the same value, so the view writes only when they differ. +func SameViewAnswer(a, b ViewAnswer) bool { return a == b } diff --git a/modules/mesh-delivery-view/viewstate/viewstate_test.go b/modules/mesh-delivery-view/viewstate/viewstate_test.go new file mode 100644 index 00000000..c35726bb --- /dev/null +++ b/modules/mesh-delivery-view/viewstate/viewstate_test.go @@ -0,0 +1,91 @@ +package viewstate + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/keys" +) + +func sampleReach() Reach { + return Reach{V: keys.Version, Repository: "novox/mesh-catalog", Indexed: "cccc", Reached: "cccc", ReachedSrc: 9, Modules: map[string]ModuleReach{ + "gitea": {FP: "src2:a", Nodes: 1, Floor: "cccc", Waiting: "gitea on ace runs src2:a and its health check failed", Changes: []Change{{Commit: "cccc", FP: "src2:a", Src: 9}, {Commit: "bbbb", FP: "src2:b", Src: 7}}}, + "zsh": {FP: "src2:z", Changes: []Change{{Commit: "aaaa", FP: "src2:z"}}}, + }} +} + +// 1280: the reach value (ADR 0556) round-trips, and values that do not hold are refused, naming what is wrong. +func TestAReachValueRoundTripsAndRefusesWhatDoesNotHold(t *testing.T) { + wantErr := func(what string, err error, in string) { + t.Helper() + if err == nil || !strings.Contains(err.Error(), in) { + t.Errorf("%s: %v, want an error naming %q", what, err, in) + } + } + r := sampleReach() + data, err := EncodeReach(r) + if err != nil { + t.Fatal(err) + } + if got, err := DecodeReach(data); err != nil || !SameReach(got, r) { + t.Errorf("reach round trip: %+v %v", got, err) + } + r.Repository = "" + _, err = EncodeReach(r) + wantErr("reach without repository", err, "repository") + r = sampleReach() + m := r.Modules["gitea"] + m.Changes = append(m.Changes, Change{Commit: "dddd"}) + r.Modules["gitea"] = m + _, err = EncodeReach(r) + wantErr("reach with a change naming no fingerprint", err, "gitea") + r = sampleReach() + m = r.Modules["gitea"] + m.Changes = make([]Change, MostChanges+1) + r.Modules["gitea"] = m + _, err = EncodeReach(r) + wantErr("reach keeping too many changes", err, "more than") + r = sampleReach() + r.ReachedSrc = 0 + _, err = EncodeReach(r) + wantErr("reach with a head and no revision", err, "reached head") +} + +func TestReachValuesDifferingInAnyPartAreNotTheSame(t *testing.T) { + if !SameReach(sampleReach(), sampleReach()) { + t.Error("equal reach values differ") + } + for name, change := range map[string]func(*Reach){ + "indexed": func(r *Reach) { r.Indexed = "dddd" }, + "floor": func(r *Reach) { m := r.Modules["gitea"]; m.Floor = "bbbb"; r.Modules["gitea"] = m }, + "changes": func(r *Reach) { m := r.Modules["zsh"]; m.Changes = nil; r.Modules["zsh"] = m }, + "modules": func(r *Reach) { delete(r.Modules, "zsh") }, + "reached": func(r *Reach) { r.Reached, r.ReachedSrc = "bbbb", 7 }, + "holds": func(r *Reach) { r.Holds = []string{"gitea on ace does not yet run src2:a"} }, + } { + r := sampleReach() + change(&r) + if SameReach(r, sampleReach()) { + t.Errorf("reach values differing in %s are the same", name) + } + } +} + +func TestViewAnswerRoundTrip(t *testing.T) { + a := ViewAnswer{Asking: "hold-mesh-mail-aaaa-0123456789ab", Option: "release", Words: "fixed by #12"} + data, err := EncodeViewAnswer(a) + if err != nil { + t.Fatal(err) + } + got, err := DecodeViewAnswer(data) + a.V = keys.Version + if err != nil || !SameViewAnswer(got, a) { + t.Errorf("view answer came back %+v, %v", got, err) + } + if _, err := EncodeViewAnswer(ViewAnswer{Asking: "x", Option: " "}); err == nil { + t.Errorf("a view answer naming no option was accepted") + } + if _, err := EncodeViewAnswer(ViewAnswer{Option: "release"}); err == nil { + t.Errorf("a view answer naming no asking was accepted") + } +} diff --git a/sdk/trunk/trunk.go b/sdk/trunk/trunk.go index 44402a43..94459fba 100644 --- a/sdk/trunk/trunk.go +++ b/sdk/trunk/trunk.go @@ -1,6 +1,11 @@ package trunk -import "strings" +import ( + "fmt" + "strings" + + "git.novox.be/novox/mesh-sdk/go/keys" +) // RepositoryName is the repository as the forge names it, "owner/name", from either spelling the mesh holds: the name // itself, or the clone URL a build record carries ("http://host:port/owner/name.git", with or without ".git" or a @@ -23,3 +28,14 @@ func SameRepository(a, b string) bool { a, b = RepositoryName(a), RepositoryName(b) return a != "" && strings.EqualFold(a, b) } + +// RepositoryKey is a repository's key in the indexer's state `taken` and the view's state `reach`: its forge name `owner/name` (RepositoryName), in +// lower case as the forge compares it, escaped as a key part (keys.EscapeName). An empty spelling, or one with no owner, is +// refused. +func RepositoryKey(repository string) (string, error) { + name := strings.ToLower(RepositoryName(repository)) + if owner, repo, ok := strings.Cut(name, "/"); !ok || owner == "" || repo == "" { + return "", fmt.Errorf("trunk: %q names no repository as owner/name", repository) + } + return keys.EscapeName(name), nil +} diff --git a/sdk/trunk/trunk_test.go b/sdk/trunk/trunk_test.go index c0c6b53f..fd7c3e6e 100644 --- a/sdk/trunk/trunk_test.go +++ b/sdk/trunk/trunk_test.go @@ -45,3 +45,14 @@ func TestAnImportSetHoldsMeshPackagesSortedAndEachOnce(t *testing.T) { } } } + +// A repository keys the indexer's taken and the view's reach by its forge name, in lower case, from either spelling (ADR 0556). +func TestARepositoryIsKeyedByItsLowerCaseForgeName(t *testing.T) { + for in, want := range map[string]string{"novox/Mesh-SDK": "novox/mesh-sdk", "http://h:3000/novox/mesh-sdk.git": "novox/mesh-sdk", + "a/b.c": "a/b=2ec", "": "", "nope": "", "/x": ""} { + got, err := RepositoryKey(in) + if (want == "") != (err != nil) || got != want { + t.Errorf("RepositoryKey(%q) = %q, %v; want %q", in, got, err, want) + } + } +} From 830e265bd06343dd5e78b82ca511605c9f8366fc Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:34:05 +0100 Subject: [PATCH 2/4] =?UTF-8?q?mesh-delivery-contracts:=20mesh-catalog=20s?= =?UTF-8?q?erves=20the=20verb=20seat-definitions;=20every=20definition=20n?= =?UTF-8?q?ames=20its=20glossary=20domain,=20Delivery=20(ADR=200636=20?= =?UTF-8?q?=C2=A71,=20=C2=A75;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/go/seats/meshcatalog/contract.go | 46 +++++++++-- .../seats/container-image-registry/seat.json | 1 + contracts/seats/git/seat.json | 1 + contracts/seats/go-package-registry/seat.json | 1 + contracts/seats/mesh-artifact-store/seat.json | 1 + contracts/seats/mesh-artifact-sweep/seat.json | 1 + contracts/seats/mesh-catalog/seat.json | 81 +++++++++++++++++++ .../seats/mesh-delivery-checker/seat.json | 1 + contracts/seats/mesh-delivery/seat.json | 1 + contracts/seats/node-build-go/seat.json | 1 + contracts/seats/node-build-image/seat.json | 1 + contracts/seats/node-build-node/seat.json | 1 + .../seats/npm-package-registry/seat.json | 1 + .../seats/oci-artifact-registry/seat.json | 1 + contracts/ts/seats/meshcatalog/src/index.ts | 34 ++++++++ 15 files changed, 168 insertions(+), 5 deletions(-) diff --git a/contracts/go/seats/meshcatalog/contract.go b/contracts/go/seats/meshcatalog/contract.go index aeecc413..243a0d33 100644 --- a/contracts/go/seats/meshcatalog/contract.go +++ b/contracts/go/seats/meshcatalog/contract.go @@ -14,10 +14,11 @@ import ( const Seat = "mesh-catalog" const ( - VerbModules = "modules" - VerbModule = "module" - VerbProvides = "provides" - VerbDependents = "dependents" + VerbModules = "modules" + VerbModule = "module" + VerbProvides = "provides" + VerbDependents = "dependents" + VerbSeatDefinitions = "seat-definitions" ) const ( @@ -27,7 +28,7 @@ const ( // Verbs is every verb the seat serves, in its definition's order: a new copy each call. func Verbs() []string { - return []string{VerbModules, VerbModule, VerbProvides, VerbDependents} + return []string{VerbModules, VerbModule, VerbProvides, VerbDependents, VerbSeatDefinitions} } // Events is every event published on the seat's subject: a new copy each call. @@ -343,6 +344,41 @@ type DependentsInput struct { // DependentsAnswer is what dependents answers. type DependentsAnswer = map[string]any +// SeatDefinitionsInput is what seat-definitions takes. +type SeatDefinitionsInput struct { + // the seat's name; none lists every trunk seat + Seat string `json:"seat,omitempty"` +} + +// SeatDefinitionsAnswerEntry is a type of the contract. +type SeatDefinitionsAnswerEntry struct { + // the definition, resolved with the parts it includes + Definition map[string]any `json:"definition,omitempty"` + Digest string `json:"digest"` + Seat string `json:"seat"` + // trunk + Source string `json:"source"` + // why the definition could not be read, when it could not + Unparsed string `json:"unparsed,omitempty"` +} + +// SeatDefinitionsAnswerSeatsItem is a type of the contract. +type SeatDefinitionsAnswerSeatsItem struct { + Digest string `json:"digest"` + Seat string `json:"seat"` + // trunk + Source string `json:"source"` +} + +// SeatDefinitionsAnswer is what seat-definitions answers. +type SeatDefinitionsAnswer struct { + Entry *SeatDefinitionsAnswerEntry `json:"entry,omitempty"` + Found bool `json:"found,omitempty"` + Revision int64 `json:"revision,omitempty"` + Seat string `json:"seat,omitempty"` + Seats []SeatDefinitionsAnswerSeatsItem `json:"seats,omitempty"` +} + // SeatDefinitionChangedEvent is the body of the event seat-definition-changed. type SeatDefinitionChangedEvent struct { Digest string `json:"digest"` diff --git a/contracts/seats/container-image-registry/seat.json b/contracts/seats/container-image-registry/seat.json index 9f95f1e4..bc116b4b 100644 --- a/contracts/seats/container-image-registry/seat.json +++ b/contracts/seats/container-image-registry/seat.json @@ -3,6 +3,7 @@ "seat": "container-image-registry", "scope": "mesh", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0605", "description": "The mesh's container images, in the one artifact store: the OCI distribution protocol's read side, an image's tags and mirroring an outside image.", "includes": [ diff --git a/contracts/seats/git/seat.json b/contracts/seats/git/seat.json index 836285ab..b7c55a10 100644 --- a/contracts/seats/git/seat.json +++ b/contracts/seats/git/seat.json @@ -3,6 +3,7 @@ "seat": "git", "scope": "mesh", "grain": "seat-list", + "domain": "Delivery", "decision": "novox/hq ADR 0111", "delivers": "git" } diff --git a/contracts/seats/go-package-registry/seat.json b/contracts/seats/go-package-registry/seat.json index edf39986..9d426368 100644 --- a/contracts/seats/go-package-registry/seat.json +++ b/contracts/seats/go-package-registry/seat.json @@ -3,6 +3,7 @@ "seat": "go-package-registry", "scope": "mesh", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0605", "description": "The mesh's Go modules: Go's module proxy protocol's read side, and publishing over the bus.", "delivers": "go-package-registry", diff --git a/contracts/seats/mesh-artifact-store/seat.json b/contracts/seats/mesh-artifact-store/seat.json index 84c8a57e..d82ab333 100644 --- a/contracts/seats/mesh-artifact-store/seat.json +++ b/contracts/seats/mesh-artifact-store/seat.json @@ -3,5 +3,6 @@ "seat": "mesh-artifact-store", "scope": "mesh", "grain": "seat-list", + "domain": "Delivery", "decision": "novox/hq ADR 0075" } diff --git a/contracts/seats/mesh-artifact-sweep/seat.json b/contracts/seats/mesh-artifact-sweep/seat.json index 602a5f8d..2e5b673d 100644 --- a/contracts/seats/mesh-artifact-sweep/seat.json +++ b/contracts/seats/mesh-artifact-sweep/seat.json @@ -3,6 +3,7 @@ "seat": "mesh-artifact-sweep", "scope": "mesh", "grain": "seat-list", + "domain": "Delivery", "decision": "novox/hq ADR 0571", "verbs": [ { diff --git a/contracts/seats/mesh-catalog/seat.json b/contracts/seats/mesh-catalog/seat.json index 92a8307e..d036149a 100644 --- a/contracts/seats/mesh-catalog/seat.json +++ b/contracts/seats/mesh-catalog/seat.json @@ -3,6 +3,7 @@ "seat": "mesh-catalog", "scope": "mesh", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0604", "verbs": [ { @@ -80,6 +81,86 @@ "type": "object", "description": "what dependents answers, as its description says" } + }, + { + "name": "seat-definitions", + "description": "The seat list (ADR 0636 §4): with no seat, every trunk seat with its digest and the revision of the list; with a seat, that trunk definition resolved with the parts it includes. Reads only.", + "optional": true, + "input": { + "properties": { + "seat": { + "description": "the seat's name; none lists every trunk seat", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "with no seat: the revision of the list and every trunk seat with its digest; with a seat: whether it was found and its entry, the definition resolved", + "properties": { + "revision": { + "type": "integer" + }, + "seats": { + "type": "array", + "items": { + "type": "object", + "properties": { + "seat": { + "type": "string" + }, + "source": { + "type": "string", + "description": "trunk" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "seat", + "source", + "digest" + ] + } + }, + "seat": { + "type": "string" + }, + "found": { + "type": "boolean" + }, + "entry": { + "type": "object", + "properties": { + "seat": { + "type": "string" + }, + "source": { + "type": "string", + "description": "trunk" + }, + "digest": { + "type": "string" + }, + "definition": { + "type": "object", + "description": "the definition, resolved with the parts it includes" + }, + "unparsed": { + "type": "string", + "description": "why the definition could not be read, when it could not" + } + }, + "required": [ + "seat", + "source", + "digest" + ] + } + } + } } ], "description": "The catalog of the mesh: every module the trunk holds with its manifest, fingerprint and verdict, the packages published, and the seat list (ADR 0636 §1, §4).", diff --git a/contracts/seats/mesh-delivery-checker/seat.json b/contracts/seats/mesh-delivery-checker/seat.json index 2aaba6bf..0a9220d3 100644 --- a/contracts/seats/mesh-delivery-checker/seat.json +++ b/contracts/seats/mesh-delivery-checker/seat.json @@ -3,6 +3,7 @@ "seat": "mesh-delivery-checker", "scope": "mesh", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0547", "verbs": [ { diff --git a/contracts/seats/mesh-delivery/seat.json b/contracts/seats/mesh-delivery/seat.json index bc08d0ff..8365118a 100644 --- a/contracts/seats/mesh-delivery/seat.json +++ b/contracts/seats/mesh-delivery/seat.json @@ -3,6 +3,7 @@ "seat": "mesh-delivery", "scope": "mesh", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0239", "verbs": [ { diff --git a/contracts/seats/node-build-go/seat.json b/contracts/seats/node-build-go/seat.json index ff273bdb..a810a6bb 100644 --- a/contracts/seats/node-build-go/seat.json +++ b/contracts/seats/node-build-go/seat.json @@ -3,6 +3,7 @@ "seat": "node-build-go", "scope": "node", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0525, ADR 0536, ADR 0553", "pulled-by-kind": true, "yields": true, diff --git a/contracts/seats/node-build-image/seat.json b/contracts/seats/node-build-image/seat.json index f0ca605b..037a8907 100644 --- a/contracts/seats/node-build-image/seat.json +++ b/contracts/seats/node-build-image/seat.json @@ -3,6 +3,7 @@ "seat": "node-build-image", "scope": "node", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0525, ADR 0536, ADR 0553", "pulled-by-kind": true, "yields": true, diff --git a/contracts/seats/node-build-node/seat.json b/contracts/seats/node-build-node/seat.json index 34ee7310..6fce1468 100644 --- a/contracts/seats/node-build-node/seat.json +++ b/contracts/seats/node-build-node/seat.json @@ -3,6 +3,7 @@ "seat": "node-build-node", "scope": "node", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0525, ADR 0536, ADR 0553", "pulled-by-kind": true, "yields": true, diff --git a/contracts/seats/npm-package-registry/seat.json b/contracts/seats/npm-package-registry/seat.json index 61f77c32..ae2ab3db 100644 --- a/contracts/seats/npm-package-registry/seat.json +++ b/contracts/seats/npm-package-registry/seat.json @@ -3,6 +3,7 @@ "seat": "npm-package-registry", "scope": "mesh", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0109", "description": "The mesh's npm packages: the npm registry protocol's read side, and publishing over the bus.", "delivers": "npm-package-registry", diff --git a/contracts/seats/oci-artifact-registry/seat.json b/contracts/seats/oci-artifact-registry/seat.json index ae30e92c..ee6f5bf7 100644 --- a/contracts/seats/oci-artifact-registry/seat.json +++ b/contracts/seats/oci-artifact-registry/seat.json @@ -3,6 +3,7 @@ "seat": "oci-artifact-registry", "scope": "mesh", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0605", "description": "The mesh's bundles and archives, by digest, in the one artifact store: the OCI distribution protocol's read side.", "includes": [ diff --git a/contracts/ts/seats/meshcatalog/src/index.ts b/contracts/ts/seats/meshcatalog/src/index.ts index 10a89abb..91fdcf23 100644 --- a/contracts/ts/seats/meshcatalog/src/index.ts +++ b/contracts/ts/seats/meshcatalog/src/index.ts @@ -9,6 +9,7 @@ export const verbs = { module: "module", provides: "provides", dependents: "dependents", + seatDefinitions: "seat-definitions", } as const; export const events = { @@ -189,6 +190,39 @@ export interface DependentsInput { /** is what dependents answers. */ export type DependentsAnswer = Record; +/** is what seat-definitions takes. */ +export interface SeatDefinitionsInput { + /** the seat's name; none lists every trunk seat */ + "seat"?: string; +} + +export interface SeatDefinitionsAnswerEntry { + /** the definition, resolved with the parts it includes */ + "definition"?: Record; + "digest": string; + "seat": string; + /** trunk */ + "source": string; + /** why the definition could not be read, when it could not */ + "unparsed"?: string; +} + +export interface SeatDefinitionsAnswerSeatsItem { + "digest": string; + "seat": string; + /** trunk */ + "source": string; +} + +/** is what seat-definitions answers. */ +export interface SeatDefinitionsAnswer { + "entry"?: SeatDefinitionsAnswerEntry; + "found"?: boolean; + "revision"?: number; + "seat"?: string; + "seats"?: SeatDefinitionsAnswerSeatsItem[]; +} + /** is the body of the event seat-definition-changed. */ export interface SeatDefinitionChangedEvent { "digest": string; From 530a4ddfdee6347fe7e293ca6dc27015a323da53 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:35:45 +0100 Subject: [PATCH 3/4] Manifests of the builder, the view and the forge bridge: the package artifacts are read by their tests; the merge's duplicate keys (tools, invokes) joined into one (D-10, D-0; issue 1966) --- modules/mesh-delivery-builder/manifest_test.go | 10 ++++++++-- modules/mesh-delivery-forge-bridge/manifest_test.go | 10 ++++++++-- modules/mesh-delivery-forge-bridge/module.json | 4 +--- modules/mesh-delivery-view/manifest_test.go | 10 +++++++++- modules/mesh-delivery-view/module.json | 4 +--- 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/modules/mesh-delivery-builder/manifest_test.go b/modules/mesh-delivery-builder/manifest_test.go index 3a4ddfcb..ba7e67f1 100644 --- a/modules/mesh-delivery-builder/manifest_test.go +++ b/modules/mesh-delivery-builder/manifest_test.go @@ -33,6 +33,8 @@ type artifact struct { From string `json:"from"` Binary string `json:"binary"` Loads []string `json:"loads"` + Paths []string `json:"paths"` + Series string `json:"series"` } type manifest struct { @@ -196,8 +198,12 @@ func TestManifestProcess(t *testing.T) { // 1607/AC1 if _, bus := p["bus"]; bus { t.Errorf("the process declares a bus: it runs on its own broker credential") } - if len(m.Build.Artifacts) != 2 { - t.Fatalf("artifacts = %+v, want the program and the tools", m.Build.Artifacts) + if len(m.Build.Artifacts) != 3 { + t.Fatalf("artifacts = %+v, want the program, the tools and the package builds", m.Build.Artifacts) + } + // ADR 0634 §1, ADR 0606 §4: the module's own package is published, so a module of another repository imports it by version. + if a := m.Build.Artifacts[2]; a.Name != "builds" || a.Kind != "package" || a.Language != "go" || !reflect.DeepEqual(a.Paths, []string{"builds"}) || a.Series != "0.1" { + t.Errorf("package artifact = %+v", a) } if a := m.Build.Artifacts[1]; a.Name != "tools" || a.Kind != "bundle" || a.Binary != "mesh-delivery-builder-tools" || !reflect.DeepEqual(a.Loads, []string{"mesh-delivery-builder-tools"}) { t.Errorf("tools artifact = %+v", a) // 1945 diff --git a/modules/mesh-delivery-forge-bridge/manifest_test.go b/modules/mesh-delivery-forge-bridge/manifest_test.go index e6688c85..1647c605 100644 --- a/modules/mesh-delivery-forge-bridge/manifest_test.go +++ b/modules/mesh-delivery-forge-bridge/manifest_test.go @@ -35,6 +35,8 @@ type artifact struct { From string `json:"from"` Binary string `json:"binary"` Loads []string `json:"loads"` + Paths []string `json:"paths"` + Series string `json:"series"` } type manifest struct { @@ -150,8 +152,12 @@ func TestManifestProcess(t *testing.T) { // 1235/AC1 if _, bus := p["bus"]; bus { t.Errorf("the process declares a bus: it runs on its own broker credential") } - if len(m.Build.Artifacts) != 2 || m.Build.Artifacts[1].Name != "tools" || m.Build.Artifacts[1].From != "cmd/mesh-delivery-forge-bridge-tools" { - t.Fatalf("artifacts = %+v, want the program and the tools bundle", m.Build.Artifacts) + if len(m.Build.Artifacts) != 3 || m.Build.Artifacts[1].Name != "tools" || m.Build.Artifacts[1].From != "cmd/mesh-delivery-forge-bridge-tools" { + t.Fatalf("artifacts = %+v, want the program, the tools bundle and the package forgemsg", m.Build.Artifacts) + } + // ADR 0573 §1: the gate requires forgemsg by a published version, so the module declares it as a package. + if a := m.Build.Artifacts[2]; a.Name != "forgemsg" || a.Kind != "package" || a.Language != "go" || len(a.Paths) != 1 || a.Paths[0] != "forgemsg" || a.Series != "0.1" { + t.Errorf("package artifact = %+v", a) } a := m.Build.Artifacts[0] if st, err := os.Stat(a.From); err != nil || !st.IsDir() || a.Binary != "mesh-delivery-forge-bridge" || !strings.HasSuffix(a.From, a.Binary) { diff --git a/modules/mesh-delivery-forge-bridge/module.json b/modules/mesh-delivery-forge-bridge/module.json index b958aea9..15ad7ee0 100644 --- a/modules/mesh-delivery-forge-bridge/module.json +++ b/modules/mesh-delivery-forge-bridge/module.json @@ -2,9 +2,6 @@ "module": "mesh-delivery-forge-bridge", "version": "1", "slug": "forge-bridge", - "tools": [ - "bridge_tags" - ], "consumes": [ "gitea.pull.updated", "gitea.pull.merged", @@ -21,6 +18,7 @@ { "name": "end-asks", "history": 1 } ], "tools": [ + "bridge_tags", "bridge_groups", "bridge_group_end_again", "bridge_merged_message" diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index 8e32d668..7c6bd266 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -29,6 +29,8 @@ type artifact struct { From string `json:"from"` Binary string `json:"binary"` Loads []string `json:"loads"` + Paths []string `json:"paths"` + Series string `json:"series"` Context *struct { Repository string `json:"repository"` } `json:"context"` @@ -150,13 +152,19 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 func TestManifestIsWholeInItsRepository(t *testing.T) { // 1134/AC7 m, _ := load(t) - if len(m.Build.Artifacts) != 4 { + if len(m.Build.Artifacts) != 5 { t.Fatalf("artifacts = %+v", m.Build.Artifacts) } for _, a := range m.Build.Artifacts { if a.Name == "files" { continue } + if a.Name == "viewstate" { // ADR 0634 §1: the module's own package, published for the gate + if a.Kind != "package" || a.Language != "go" || len(a.Paths) != 1 || a.Paths[0] != "viewstate" || a.Series != "0.1" { + t.Errorf("package artifact = %+v", a) + } + continue + } if a.Context != nil { t.Errorf("artifact %s names a context of another repository", a.Name) } diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index dccb6fa0..35a95c3e 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -29,9 +29,6 @@ "view_first_view_again", "node_manifest_reads" ], - "invokes": [ - "mesh-delivery-indexer.indexer_pending" - ], "emits": [ "delivered", "moving", @@ -61,6 +58,7 @@ "mesh-delivery-indexer.indexer_read_config_candidates", "mesh-delivery-indexer.indexer_read_graph", "mesh-delivery-indexer.indexer_read_heard", + "mesh-delivery-indexer.indexer_pending", "mesh-delivery-indexer.indexer_read_modules", "mesh-delivery-indexer.indexer_read_packages", "mesh-delivery-indexer.indexer_read_predefined", From 719ddabb14a1d70f5c5ff967329d167d8d0b38e9 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:37:13 +0100 Subject: [PATCH 4/4] =?UTF-8?q?mesh-delivery-contracts:=20the=20client=20o?= =?UTF-8?q?f=20mesh-delivery-checker=20holds=20the=20rules=20of=20a=20verd?= =?UTF-8?q?ict=20and=20a=20kept=20verdict=20that=20its=20holder=20and=20re?= =?UTF-8?q?aders=20share=20(D-10,=20ADR=200634=20=C2=A72,=20ADR=200625=20?= =?UTF-8?q?=C2=A74;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meshdeliverycheckerclient/client.go | 222 ++++++++++++++++++ .../meshdeliverycheckerclient/client_test.go | 81 +++++++ .../clients/meshdeliverycheckerclient/go.mod | 9 + contracts/module.json | 9 + 4 files changed, 321 insertions(+) create mode 100644 contracts/go/clients/meshdeliverycheckerclient/client.go create mode 100644 contracts/go/clients/meshdeliverycheckerclient/client_test.go create mode 100644 contracts/go/clients/meshdeliverycheckerclient/go.mod diff --git a/contracts/go/clients/meshdeliverycheckerclient/client.go b/contracts/go/clients/meshdeliverycheckerclient/client.go new file mode 100644 index 00000000..c97e6ac3 --- /dev/null +++ b/contracts/go/clients/meshdeliverycheckerclient/client.go @@ -0,0 +1,222 @@ +// Package meshdeliverycheckerclient holds the logic the holders and callers of the seat mesh-delivery-checker share over its +// contract (novox/hq ADR 0625 §4, ADR 0634 §2): the rules a verdict and a kept verdict must keep to be written or read. The +// checker writes them, and the view, the gate and the indexer read them; the contract's decoding is strict by schema only, so +// these rules are written once here and no caller copies them. +package meshdeliverycheckerclient + +import ( + "fmt" + "strings" + + checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" + "git.novox.be/novox/mesh-delivery/sdk/kept" +) + +// The states of a verdict, the levels of a check and the compositions' states. +const ( + VerdictRunning = "running" + VerdictPass = "pass" + VerdictFail = "fail" + + LevelUnit = "unit" + LevelIntegration = "integration" + + CompositionNotRun = "not-run" + CompositionPass = "pass" + CompositionFail = "fail" +) + +// MaxFailures is the most failures a verdict names; it names the count of the rest beside them. +const MaxFailures = 50 + +func checkLevel(level string) error { + if level != LevelUnit && level != LevelIntegration { + return fmt.Errorf("meshdeliverycheckerclient: the level %q is not %s or %s", level, LevelUnit, LevelIntegration) + } + return nil +} + +func isHex12(s string) bool { return len(s) == 12 && strings.Trim(s, "0123456789abcdef") == "" } + +// CheckRepository refuses a repository that is not / with exactly one slash, each part a key part. +func CheckRepository(repository string) error { + owner, name, ok := strings.Cut(repository, "/") + if !ok || strings.Contains(name, "/") { + return fmt.Errorf("meshdeliverycheckerclient: the repository %q is not /, with exactly one slash", repository) + } + if err := kept.CheckName("repository owner", owner); err != nil { + return fmt.Errorf("meshdeliverycheckerclient: the repository %q: %w", repository, err) + } + if err := kept.CheckName("repository name", name); err != nil { + return fmt.Errorf("meshdeliverycheckerclient: the repository %q: %w", repository, err) + } + return nil +} + +func checkComposition(c *checker.VerdictsByGroupHeadsValueComposition) error { + if c == nil { + return nil + } + switch c.State { + case CompositionNotRun: + if c.Sdk != "" || len(c.Machines) > 0 { + return fmt.Errorf("meshdeliverycheckerclient: the verdict's composition is not run and names no mesh-sdk commit or machines") + } + case CompositionPass: + if c.Sdk == "" { + return fmt.Errorf("meshdeliverycheckerclient: the verdict's composition passed and names no mesh-sdk commit") + } + case CompositionFail: + if c.Words == "" { + return fmt.Errorf("meshdeliverycheckerclient: the verdict's composition failed and gives no words") + } + default: + return fmt.Errorf("meshdeliverycheckerclient: the verdict's composition state %q is not %s, %s or %s", c.State, CompositionNotRun, CompositionPass, CompositionFail) + } + return nil +} + +// CheckVerdict refuses a verdict a reader could not place: no group, heads that are not 12 lowercase hex digits, a repository +// both merged and read beside, a carried-from that carries nothing, a series change from a series to itself or named twice, a +// level that is neither unit nor integration or counts a negative number, a time below zero, a pass that says nothing of a +// level, a fail that names neither a test nor a machine, and failures on a verdict that did not fail. +func CheckVerdict(v checker.VerdictsByGroupHeadsValue) error { + const who = "meshdeliverycheckerclient: " + if v.Group == "" { + return fmt.Errorf(who + "a verdict names no group") + } + if !isHex12(v.Heads) { + return fmt.Errorf(who+"the verdict's heads hash %q is not 12 lowercase hex digits", v.Heads) + } + for repository, head := range v.Trunk { + if head == "" { + return fmt.Errorf(who+"the verdict's trunk head of %s is empty", repository) + } + } + for repository, commit := range v.Beside { + if commit == "" { + return fmt.Errorf(who+"the verdict's sibling commit of %s is empty", repository) + } + if _, ok := v.Trunk[repository]; ok { + return fmt.Errorf(who+"the verdict names %s as a trunk it merged and as a sibling it read", repository) + } + } + if len(v.CarriedFrom) > 0 { + if v.State == VerdictRunning { + return fmt.Errorf(who + "a running verdict is carried from nothing: carried-from is written with its end") + } + moved := false + for repository, commit := range v.CarriedFrom { + now, inTrunk := v.Trunk[repository] + if !inTrunk { + now = v.Beside[repository] + } + if commit == "" || now == "" { + return fmt.Errorf(who+"the verdict's carried-from names %s, which it judged on no commit of", repository) + } + moved = moved || commit != now + } + if !moved { + return fmt.Errorf(who + "the verdict's carried-from names the commits it is judged on: nothing moved, so nothing was carried") + } + } + changed := map[string]bool{} + for _, c := range v.Series { + if c.Module == "" || c.Artifact == "" || c.From == "" || c.To == "" { + return fmt.Errorf(who+"the verdict's series change %+v names no module, artifact, from or to", c) + } + if c.From == c.To { + return fmt.Errorf(who+"the verdict's series change of %s/%s is from %s to the same series", c.Module, c.Artifact, c.From) + } + if changed[c.Module+"/"+c.Artifact] { + return fmt.Errorf(who+"the verdict names the series change of %s/%s twice", c.Module, c.Artifact) + } + changed[c.Module+"/"+c.Artifact] = true + } + for level, r := range v.Levels { + if err := checkLevel(level); err != nil { + return err + } + if r.Run < 0 || r.Kept < 0 || r.Skipped < 0 { + return fmt.Errorf(who+"the verdict's %s level counts a negative number of tests", level) + } + } + if err := checkComposition(v.Composition); err != nil { + return err + } + seen := map[string]bool{} + for _, level := range v.OverBound { + if err := checkLevel(level); err != nil { + return fmt.Errorf(who+"the verdict's over-bound names: %w", err) + } + if seen[level] { + return fmt.Errorf(who+"the verdict's over-bound names the level %s twice", level) + } + seen[level] = true + } + if t := v.Took; t != nil { + for part, ms := range map[string]int64{"queue": t.Queue, "clone": t.Clone, "unit": t.Unit, "lint": t.Lint, + "integration-setup": t.IntegrationSetup, "integration-run": t.IntegrationRun, "composition": t.Composition} { + if ms < 0 { + return fmt.Errorf(who+"the verdict's time of the part %s is negative", part) + } + } + } + named := v.Failed != nil && (v.Failed.Test != "" || v.Failed.Machine != "") + if len(v.Failures) > 0 && v.State != VerdictFail { + return fmt.Errorf(who+"a %s verdict names no failures", v.State) + } + if len(v.Failures) > MaxFailures+1 { + return fmt.Errorf(who+"the verdict names %d failures, more than %d and the count of the rest", len(v.Failures), MaxFailures) + } + switch v.State { + case VerdictRunning: + if v.Failed != nil && *v.Failed != (checker.VerdictsByGroupHeadsValueFailed{}) { + return fmt.Errorf(who + "a running verdict names no failure") + } + case VerdictPass: + if v.Failed != nil && *v.Failed != (checker.VerdictsByGroupHeadsValueFailed{}) { + return fmt.Errorf(who + "a pass names no failure") + } + for _, level := range []string{LevelUnit, LevelIntegration} { + if _, ok := v.Levels[level]; !ok { + return fmt.Errorf(who+"a pass says nothing of the %s level", level) + } + } + case VerdictFail: + if !named { + return fmt.Errorf(who + "a fail names neither a test nor a machine") + } + default: + return fmt.Errorf(who+"the verdict state %q is not running, pass or fail", v.State) + } + return nil +} + +// CheckKept refuses a kept verdict that is not a pass of a repository's package at a level, with its toolchain and its time: +// a failure is never kept. +func CheckKept(k checker.KeptByLevelDigestValue) error { + const who = "meshdeliverycheckerclient: " + if k.State != VerdictPass { + return fmt.Errorf(who+"a kept verdict is a pass, never %q: a failure is never kept", k.State) + } + if err := CheckRepository(k.Repository); err != nil { + return err + } + if k.Package == "" { + return fmt.Errorf(who + "a kept verdict names no package") + } + if err := checkLevel(k.Level); err != nil { + return err + } + if k.Tests < 0 || k.Took < 0 { + return fmt.Errorf(who + "a kept verdict counts a negative number of tests or a negative time") + } + if k.Toolchain == "" { + return fmt.Errorf(who + "a kept verdict names no toolchain version") + } + if k.At == "" { + return fmt.Errorf(who + "a kept verdict says no time") + } + return nil +} diff --git a/contracts/go/clients/meshdeliverycheckerclient/client_test.go b/contracts/go/clients/meshdeliverycheckerclient/client_test.go new file mode 100644 index 00000000..062efc25 --- /dev/null +++ b/contracts/go/clients/meshdeliverycheckerclient/client_test.go @@ -0,0 +1,81 @@ +package meshdeliverycheckerclient + +import ( + "strings" + "testing" + + checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" +) + +type verdict = checker.VerdictsByGroupHeadsValue + +func passing() verdict { + return verdict{Group: "g", Heads: "0123456789ab", State: VerdictPass, Trunk: map[string]string{"novox/a": "c1"}, + Levels: map[string]checker.VerdictsByGroupHeadsValueLevelsValue{LevelUnit: {Run: 3}, LevelIntegration: {Run: 1}}} +} + +// The rules a verdict keeps to be written or read: each case edits a passing verdict and names what the refusal says. +func TestAVerdictIsRefusedForWhatAReaderCouldNotPlace(t *testing.T) { + if err := CheckVerdict(passing()); err != nil { + t.Fatalf("a passing verdict is refused: %v", err) + } + for name, c := range map[string]struct { + edit func(*verdict) + says string + }{ + "no group": {func(v *verdict) { v.Group = "" }, "no group"}, + "heads of 11 digits": {func(v *verdict) { v.Heads = "0123456789a" }, "12 lowercase hex"}, + "an empty trunk head": {func(v *verdict) { v.Trunk["novox/a"] = "" }, "trunk head"}, + "merged and read": {func(v *verdict) { v.Beside = map[string]string{"novox/a": "c2"} }, "trunk it merged and as a sibling"}, + "nothing carried": {func(v *verdict) { v.CarriedFrom = map[string]string{"novox/a": "c1"} }, "nothing moved"}, + "a running carry": {func(v *verdict) { v.State, v.CarriedFrom = VerdictRunning, map[string]string{"novox/a": "c0"} }, "carried from nothing"}, + "a series to itself": {func(v *verdict) { + v.Series = []checker.VerdictsByGroupHeadsValueSeriesItem{{Module: "m", Artifact: "a", From: "0.1", To: "0.1"}} + }, "same series"}, + "an unknown level": {func(v *verdict) { v.Levels["smoke"] = checker.VerdictsByGroupHeadsValueLevelsValue{} }, "smoke"}, + "a negative count": {func(v *verdict) { v.Levels[LevelUnit] = checker.VerdictsByGroupHeadsValueLevelsValue{Run: -1} }, "negative"}, + "a negative time": {func(v *verdict) { v.Took = &checker.VerdictsByGroupHeadsValueTook{Unit: -1} }, "negative"}, + "a pass without a level": {func(v *verdict) { delete(v.Levels, LevelIntegration) }, "integration level"}, + "a pass that failed": {func(v *verdict) { v.Failed = &checker.VerdictsByGroupHeadsValueFailed{Test: "T"} }, "names no failure"}, + "failures of a pass": {func(v *verdict) { v.Failures = []checker.VerdictsByGroupHeadsValueFailuresItem{{Test: "T"}} }, "names no failures"}, + "a fail naming nothing": {func(v *verdict) { v.State = VerdictFail }, "neither a test nor a machine"}, + "an unknown state": {func(v *verdict) { v.State = "maybe" }, "maybe"}, + "a composition passed without a commit": {func(v *verdict) { + v.Composition = &checker.VerdictsByGroupHeadsValueComposition{State: CompositionPass} + }, "mesh-sdk commit"}, + "the same level over bound twice": {func(v *verdict) { v.OverBound = []string{LevelUnit, LevelUnit} }, "twice"}, + } { + v := passing() + c.edit(&v) + if err := CheckVerdict(v); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v, want a refusal saying %q", name, err, c.says) + } + } + failed := passing() + failed.State, failed.Failed = VerdictFail, &checker.VerdictsByGroupHeadsValueFailed{Machine: "ace"} + if err := CheckVerdict(failed); err != nil { + t.Errorf("a fail naming a machine is refused: %v", err) + } +} + +func TestAKeptVerdictIsAPassOfAPackageAtALevel(t *testing.T) { + ok := checker.KeptByLevelDigestValue{State: VerdictPass, Repository: "novox/mesh-sdk", Package: "./go/keys", Level: LevelUnit, Toolchain: "1.26.0", At: "2026-11-05T10:00:00Z"} + if err := CheckKept(ok); err != nil { + t.Fatalf("a kept pass is refused: %v", err) + } + for name, edit := range map[string]func(*checker.KeptByLevelDigestValue){ + "a fail": func(k *checker.KeptByLevelDigestValue) { k.State = VerdictFail }, + "no owner": func(k *checker.KeptByLevelDigestValue) { k.Repository = "mesh-sdk" }, + "no package": func(k *checker.KeptByLevelDigestValue) { k.Package = "" }, + "an unknown level": func(k *checker.KeptByLevelDigestValue) { k.Level = "smoke" }, + "negative tests": func(k *checker.KeptByLevelDigestValue) { k.Tests = -1 }, + "no toolchain": func(k *checker.KeptByLevelDigestValue) { k.Toolchain = "" }, + "no time": func(k *checker.KeptByLevelDigestValue) { k.At = "" }, + } { + k := ok + edit(&k) + if CheckKept(k) == nil { + t.Errorf("%s was taken", name) + } + } +} diff --git a/contracts/go/clients/meshdeliverycheckerclient/go.mod b/contracts/go/clients/meshdeliverycheckerclient/go.mod new file mode 100644 index 00000000..27d39949 --- /dev/null +++ b/contracts/go/clients/meshdeliverycheckerclient/go.mod @@ -0,0 +1,9 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174118-f94abb95aaef + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker v0.0.0 + git.novox.be/novox/mesh-delivery/sdk v0.0.0 +) diff --git a/contracts/module.json b/contracts/module.json index 113c2fc6..4e537386 100644 --- a/contracts/module.json +++ b/contracts/module.json @@ -112,6 +112,15 @@ ], "series": "0.1" }, + { + "name": "go-meshdeliverycheckerclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/meshdeliverycheckerclient" + ], + "series": "0.1" + }, { "name": "go-gopackageregistryclient", "kind": "package",