From b9ec1919b8657ba3e5f1acc45c828bcc3bb7e07b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 22:47:04 +0200 Subject: [PATCH] Name plans-building among the controller's readable states and pin the SDK that does (issue 1687, ADR 0483 clause 2) --- go.mod | 2 +- go.sum | 2 + internal/broker/controller_buckets.go | 2 +- internal/broker/plans_building_mirror_test.go | 30 +++ internal/broker/writers.go | 2 +- internal/broker/writers_test.go | 2 +- testdata/beside/CAPTURED | 2 +- .../novox/mesh-sdk/go/catalogue/state.go | 4 +- .../novox/mesh-sdk/go/delivery/checker.go | 69 ++++++- .../novox/mesh-sdk/go/delivery/gate.go | 27 ++- .../novox/mesh-sdk/go/delivery/preview.go | 181 ++++++++++++++++++ vendor/modules.txt | 2 +- 12 files changed, 313 insertions(+), 12 deletions(-) create mode 100644 internal/broker/plans_building_mirror_test.go create mode 100644 vendor/git.novox.be/novox/mesh-sdk/go/delivery/preview.go diff --git a/go.mod b/go.mod index 8ff82d6e..8ce505e9 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-controller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021173436-07a65927c362 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021204452-580da61a9107 github.com/jackc/pgx/v5 v5.10.0 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 diff --git a/go.sum b/go.sum index 61194a3d..0657d020 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5 h1:E5stfIeCN6uYg git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5/go.mod h1:4wyoCr6IIIIarEUuROThWDj7DAvJvIidqMjWcpSnOwE= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021173436-07a65927c362 h1:Ud/YHmhlm0qdB45LyDvxq7l4x6Bg2Ye0siHK5JFE+kk= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021173436-07a65927c362/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021204452-580da61a9107 h1:29NSUkDw7cIEwNcE09JZ9ASSD+cwBDoWRN3vWHKvsoQ= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021204452-580da61a9107/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/broker/controller_buckets.go b/internal/broker/controller_buckets.go index 3cb81c58..fe588c01 100644 --- a/internal/broker/controller_buckets.go +++ b/internal/broker/controller_buckets.go @@ -153,7 +153,7 @@ func ChainBuckets() []string { return []string{BuildsBucket, RolloutsBucket, Ass // ControllerMirrors are the local names of the controller's buckets a module may read: the record mirrors and the // chain's buckets. func ControllerMirrors() []string { - return []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts, ChecksRunning} + return []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts, ChecksRunning, PlansBuilding} } // MirrorsWrittenByAnother are the controller's mirrors whose writer is not the controller, each with its reason: the rule diff --git a/internal/broker/plans_building_mirror_test.go b/internal/broker/plans_building_mirror_test.go new file mode 100644 index 00000000..43efd6db --- /dev/null +++ b/internal/broker/plans_building_mirror_test.go @@ -0,0 +1,30 @@ +package broker + +import ( + "slices" + "testing" +) + +// Issue 1687 (ADR 0483 clause 2): a module whose manifest reads mesh-controller.plans-building may name it in `reads`, and +// reads the bucket and writes none; the controller writes it, so it is no mirror written by another. +func TestAModuleReadingPlansBuildingReadsAndWritesNone(t *testing.T) { + if !slices.Contains(ControllerMirrors(), "plans-building") || BucketName(ControllerSeat, PlansBuilding) != PlansBuildingBucket { + t.Fatalf("a module cannot name %s in `reads`: %v", PlansBuildingBucket, ControllerMirrors()) + } + if _, other := MirrorsWrittenByAnother[PlansBuilding]; other { + t.Error("the controller writes the plans building itself") + } + perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-checker", PasswordHash: "x", + Reads: []string{"mesh-controller.plans-building"}}) + if err != nil { + t.Fatal(err) + } + stream := "KV_" + PlansBuildingBucket + has(t, perms.Publish, "$JS.API.STREAM.INFO."+stream) + has(t, perms.Publish, "$JS.API.DIRECT.GET."+stream+".>") + for _, p := range perms.Publish { + if SubjectsOverlap(p, "$KV."+PlansBuildingBucket+".plan-1") { + t.Errorf("the reading module may write %s through %q", PlansBuildingBucket, p) + } + } +} diff --git a/internal/broker/writers.go b/internal/broker/writers.go index af35bf19..9dd7ed3f 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -177,7 +177,7 @@ var WritersTable = []WriterRow{ KeptIn: "key-value " + CheckRunningBucket + ", aged out when not renewed", Others: "the build seat's holders read it to drop a duplicate request", Subjects: kvOf(CheckRunningBucket), Writes: holdsTheBuildSeat, Shared: "every machine holding the build seat puts the key of the check it runs and deletes it when the check ends"}, - {State: "a delivery plan in its build phase", Writer: "controller (lease holder), at the first build's ask and at each refresh", + {State: "the plans-building mirror", Writer: "controller (lease holder), at the first build's ask and at each refresh", KeptIn: "key-value " + PlansBuildingBucket + ", aged out when not refreshed", Others: "the build seat's holders read it and take no new check while a key is there (ADR 0483)", Subjects: kvOf(PlansBuildingBucket), Writes: isController}, diff --git a/internal/broker/writers_test.go b/internal/broker/writers_test.go index 78d3de1c..24128be7 100644 --- a/internal/broker/writers_test.go +++ b/internal/broker/writers_test.go @@ -30,7 +30,7 @@ var designRows = []string{ "a pull request's check asked", "a pull request's check running", // The plans in their build phase, a bucket the build seat's holders read (ADR 0483 clause 2, issue 1687). - "a delivery plan in its build phase", + "the plans-building mirror", "a merge announced", "a pull request's head announced", "a pull request's merge check", diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index 515d20d5..6cbd2e76 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself. mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile mesh-host 9d9e3a46c4f5943cfffb413d962ce48b75e2fd39 examples/foundation-first-node.lock -mesh-sdk 07a65927c3620390e191cb6ccc97d3770f284526 conformance/events/module-event.json +mesh-sdk 580da61a9107f74fbef33388ece982e1b9a2e483 conformance/events/module-event.json To move them, from this repository's root, with the two repositories checked out beside it: diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/state.go b/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/state.go index 14e3432d..87e402f1 100644 --- a/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/state.go +++ b/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/state.go @@ -133,7 +133,9 @@ func StateProblems(m Manifest) []string { // controller's account alone (the writers table); declared as a module's state, they would be asserted a // second way and granted to the module's own principal as well. Named here so a read of one is a read of // kept state. The bus's list is broker.ControllerMirrors; a test keeps the two agreeing. -var ControllerMirrors = []string{"assignments", "declared-config", "predefined", "builds", "rollouts", "checks-running"} +// plans-building is the plans in their build phase, read by the checker to hold checks while a delivery builds (ADR 0483 +// clause 2, issue 1687). +var ControllerMirrors = []string{"assignments", "declared-config", "predefined", "builds", "rollouts", "checks-running", "plans-building"} // StateReadsNothingDeclares is every read across a catalogue whose owner is present and declares no // such state. An absent owner says nothing — a module may be installed long before the one whose diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/delivery/checker.go b/vendor/git.novox.be/novox/mesh-sdk/go/delivery/checker.go index bdb57e96..81c0dc9f 100644 --- a/vendor/git.novox.be/novox/mesh-sdk/go/delivery/checker.go +++ b/vendor/git.novox.be/novox/mesh-sdk/go/delivery/checker.go @@ -9,9 +9,11 @@ import ( ) // The checker's keys (novox/hq ADR 0447 clauses 1 to 3, ADR 0444, ADR 0446 clause 5, ADR 0453 clause 4): the verdicts of -// its checks and the passing verdicts it keeps per package and level. The checker writes them from R4c, dark; the -// forge bridge writes the status `mesh/check` from a verdict (unrequired until R4f), the gate reads it from R4f, and -// the view and the watchman read them (a running check carries a pulse). +// its checks and the passing verdicts it keeps per package and level. The checker writes them from R4c, dark, and +// writes the status `mesh/check` it decides as an owed act in its own bucket (CheckerOwedBucket, the gate's owed shape); +// the forge bridge, the only part that calls the forge, performs that act (unrequired until R4f), once per revision of +// the key (ADR 0484 clause 2). The gate reads the status from R4f, and the view and the watchman read the verdicts (a +// running check carries a pulse). // // **The verdict's key is the group and the heads, not the fingerprints** (the lead's limit for R4a). Research 063 // section 01 drew `verdicts..check.`; ADR 0447 clause 1 judges a pull request's head merged onto its @@ -88,6 +90,25 @@ func ParseCheckerVerdictKey(key string) (group, headsHash string, err error) { type LevelResult struct { Run int `json:"run"` Kept int `json:"kept"` + // Skipped is the tests the run skipped, so a pass never hides them (issue 1251, ADR 0484 clause 7). + Skipped int `json:"skipped,omitempty"` +} + +// The states of a Composition (ADR 0484 clause 6): the composition check is the assigner's, so until it delivers a +// verdict says CompositionNotRun, and a line never says the composition passed on that. +const ( + CompositionNotRun = "not-run" + CompositionPass = "pass" + CompositionFail = "fail" +) + +// Composition is what the check of the machines' composition made of a verdict: its State (not-run, pass or fail), the +// Words of a refusal, the mesh-sdk commit it composed with and the machines composed (ADR 0482 clause 9, ADR 0484 clause 6). +type Composition struct { + State string `json:"state"` + Words string `json:"words,omitempty"` + SDK string `json:"sdk,omitempty"` + Machines []string `json:"machines,omitempty"` } // Took is the time of each part of a check in integer milliseconds (ADR 0447 clause 3): waiting in the queue, cloning, @@ -123,6 +144,33 @@ type Verdict struct { Took Took `json:"took,omitzero"` Machines []string `json:"machines,omitempty"` SDK string `json:"sdk,omitempty"` + // Composition is the composition check's result; absent in a value written before it (ADR 0484 clause 6). + Composition *Composition `json:"composition,omitempty"` + // OverBound names the levels whose running went over their bound; it does not change the State (ADR 0453 clause 9). + OverBound []string `json:"over-bound,omitempty"` +} + +func (c *Composition) check() error { + if c == nil { + return nil + } + switch c.State { + case CompositionNotRun: + if c.Words != "" || c.SDK != "" || len(c.Machines) > 0 { + return fmt.Errorf("delivery: the verdict's composition is not run and says nothing of words, the mesh-sdk commit or machines") + } + case CompositionPass: + if c.SDK == "" { + return fmt.Errorf("delivery: the verdict's composition passed and names no mesh-sdk commit") + } + case CompositionFail: + if c.Words == "" { + return fmt.Errorf("delivery: the verdict's composition failed and gives no words") + } + default: + return fmt.Errorf("delivery: the verdict's composition state %q is not %s, %s or %s", c.State, CompositionNotRun, CompositionPass, CompositionFail) + } + return nil } func (v Verdict) check() error { @@ -141,10 +189,23 @@ func (v Verdict) check() error { if err := checkLevel(level); err != nil { return err } - if r.Run < 0 || r.Kept < 0 { + if r.Run < 0 || r.Kept < 0 || r.Skipped < 0 { return fmt.Errorf("delivery: the verdict's %s level counts a negative number of tests", level) } } + if err := v.Composition.check(); err != nil { + return err + } + seen := map[string]bool{} + for _, level := range v.OverBound { + if err := checkLevel(level); err != nil { + return fmt.Errorf("delivery: the verdict's over-bound names: %w", err) + } + if seen[level] { + return fmt.Errorf("delivery: the verdict's over-bound names the level %s twice", level) + } + seen[level] = true + } for part, ms := range map[string]int64{"queue": v.Took.Queue, "clone": v.Took.Clone, "unit": v.Took.Unit, "integration-setup": v.Took.IntegrationSetup, "integration-run": v.Took.IntegrationRun, "composition": v.Took.Composition} { if ms < 0 { diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/delivery/gate.go b/vendor/git.novox.be/novox/mesh-sdk/go/delivery/gate.go index 61ec74d1..18444c57 100644 --- a/vendor/git.novox.be/novox/mesh-sdk/go/delivery/gate.go +++ b/vendor/git.novox.be/novox/mesh-sdk/go/delivery/gate.go @@ -28,6 +28,18 @@ const ( AnswersHistory = 5 ) +// The contexts of the statuses the mesh owes the forge: the gate's, the delivery's view of a head and of a group +// (design 47), and the checker's, which is not required until R4f (ADR 0484 clause 2, ADR 0453 clause 4). +const ( + StatusContextMergeGate = "mesh/merge-gate" + StatusContextDelivery = "mesh/delivery" + StatusContextDeliveryGroup = "mesh/delivery-group" + StatusContextRepoCheck = "mesh/repo-check" // the gate's pending status of a recheck (mesh-delivery.recheck) + StatusContextCheck = "mesh/check" +) + +var statusContexts = []string{StatusContextMergeGate, StatusContextDelivery, StatusContextDeliveryGroup, StatusContextRepoCheck, StatusContextCheck} + // AskKindMerge is the kind of the gate's ask for a group's merge, the first part of its key. const AskKindMerge = "merge" @@ -131,6 +143,16 @@ func AskingID(askKey string, asking int) string { return AskID(askKey + "." + strconv.Itoa(asking)) } +// checkStatusContext refuses a status context the mesh does not owe the forge. +func checkStatusContext(context string) error { + for _, c := range statusContexts { + if context == c { + return nil + } + } + return fmt.Errorf("delivery: the status context %q is not one of %s", context, strings.Join(statusContexts, ", ")) +} + func checkOwedAct(act string) (kind, context string, err error) { kind, context, hasContext := strings.Cut(act, "=") switch kind { @@ -138,7 +160,7 @@ func checkOwedAct(act string) (kind, context string, err error) { if !hasContext { return "", "", fmt.Errorf("delivery: the owed act %q is a status without its context, status=", act) } - if err := checkName("status context", context); err != nil { + if err := checkStatusContext(context); err != nil { return "", "", err } return kind, context, nil @@ -345,6 +367,9 @@ func (o Owed) check() error { if o.Context == "" { return fmt.Errorf("delivery: an owed status has no context") } + if err := checkStatusContext(o.Context); err != nil { + return err + } if o.Target == "" { return fmt.Errorf("delivery: an owed status has no target") } diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/delivery/preview.go b/vendor/git.novox.be/novox/mesh-sdk/go/delivery/preview.go new file mode 100644 index 00000000..dd90cb76 --- /dev/null +++ b/vendor/git.novox.be/novox/mesh-sdk/go/delivery/preview.go @@ -0,0 +1,181 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// The checker's other buckets (ADR 0484 clauses 2 and 3): owed, the acts it owes the forge, which the forge bridge +// performs once per revision of a key, and previews, what the fetch step answered for a head merged onto a trunk commit. +const ( + // CheckerOwedBucket is the checker's bucket of owed acts, in the gate's owed shape: a status with the context + // StatusContextCheck for each head it judged. One part writes each key: the checker. + CheckerOwedBucket = CheckerModule + "_owed" + // CheckerOwedHistory is the revisions the checker's owed bucket keeps per key. + CheckerOwedHistory = 5 + // PreviewsBucket is the checker's bucket of merge previews; the indexer reads it for the candidates' fingerprints. + PreviewsBucket = CheckerModule + "_previews" + // PreviewsHistory is the revisions the previews bucket keeps per key. + PreviewsHistory = 5 +) + +// CheckerOwedKey is the key, in CheckerOwedBucket, of the status the checker owes on a pull request at a head: the +// gate's OwedKey with the act `status=mesh/check`, the only act the checker owes. +func CheckerOwedKey(repository string, number int, head string) (string, error) { + return OwedKey(repository, number, head, OwedStatus+"="+StatusContextCheck) +} + +// ParseCheckerOwedKey is the inverse of CheckerOwedKey; it refuses an owed key of any other act. +func ParseCheckerOwedKey(key string) (repository string, number int, head string, err error) { + repository, number, head, act, err := ParseOwedKey(key) + if err != nil { + return "", 0, "", err + } + if act != OwedStatus+"="+StatusContextCheck { + return "", 0, "", fmt.Errorf("delivery: %q is not a checker owed key: the checker owes only status=%s, not %q", key, StatusContextCheck, act) + } + return repository, number, head, nil +} + +// EncodeCheckerOwed is o as JSON at Version; it refuses what EncodeOwed refuses and any act but a status with the +// context StatusContextCheck. +func EncodeCheckerOwed(o Owed) ([]byte, error) { + if err := checkCheckerOwed(o); err != nil { + return nil, err + } + return EncodeOwed(o) +} + +func checkCheckerOwed(o Owed) error { + if o.Act != OwedStatus || o.Context != StatusContextCheck { + return fmt.Errorf("delivery: the checker owes only a status with the context %s, not the act %q with the context %q", StatusContextCheck, o.Act, o.Context) + } + return nil +} + +// DecodeCheckerOwed reads an owed act of the checker's bucket. +func DecodeCheckerOwed(data []byte) (Owed, error) { + o, err := DecodeOwed(data) + if err != nil { + return Owed{}, err + } + if err := checkCheckerOwed(o); err != nil { + return Owed{}, err + } + return o, nil +} + +// PreviewKey is the key, in PreviewsBucket, of a head merged onto a trunk commit: `..`. +func PreviewKey(repository, head, trunk string) (string, error) { + repo, err := escapeRepository(repository) + if err != nil { + return "", err + } + if err := checkName("head", head); err != nil { + return "", err + } + if err := checkName("trunk commit", trunk); err != nil { + return "", err + } + return repo + "." + head + "." + trunk, nil +} + +// ParsePreviewKey is the inverse of PreviewKey. +func ParsePreviewKey(key string) (repository, head, trunk string, err error) { + parts := strings.Split(key, ".") + if len(parts) != 3 { + return "", "", "", fmt.Errorf("delivery: %q is not a preview key, ..", key) + } + repository, err = unescapeRepository(parts[0]) + if err != nil { + return "", "", "", fmt.Errorf("delivery: %q is not a preview key: %w", key, err) + } + if _, err := PreviewKey(repository, parts[1], parts[2]); err != nil { + return "", "", "", fmt.Errorf("delivery: %q is not a preview key: %w", key, err) + } + return repository, parts[1], parts[2], nil +} + +// PreviewModule is what the preview holds of one module directory: the tree sha of the directory, the digest of its +// manifest (the key in the manifests bucket) and the hex sha256 of its lock files by path relative to it. +type PreviewModule struct { + Tree string `json:"tree"` + Manifest string `json:"manifest"` + Locks map[string]string `json:"locks,omitempty"` +} + +// Preview is the value of a preview key: the repository, the head and the trunk commit it was merged onto, the merge +// commit and, for each module directory of the merge, a PreviewModule. A head that does not merge is a preview too: it +// names the files in Conflicts and has no merge commit and no modules (issue 1445). +type Preview struct { + V int `json:"v"` + Repository string `json:"repository"` + Head string `json:"head"` + Trunk string `json:"trunk"` + Merge string `json:"merge,omitempty"` + Conflicts []string `json:"conflicts,omitempty"` + Modules map[string]PreviewModule `json:"modules,omitempty"` +} + +func (p Preview) check() error { + if _, err := PreviewKey(p.Repository, p.Head, p.Trunk); err != nil { + return err + } + for _, f := range p.Conflicts { + if f == "" { + return fmt.Errorf("delivery: a preview names a conflicting file with an empty path") + } + } + if len(p.Conflicts) > 0 { + if p.Merge != "" || len(p.Modules) > 0 { + return fmt.Errorf("delivery: a preview of a head in conflict has no merge commit and no modules") + } + return nil + } + if p.Merge == "" { + return fmt.Errorf("delivery: a preview names no merge commit") + } + for dir, m := range p.Modules { + if dir == "" { + return fmt.Errorf("delivery: a preview names a module with an empty directory") + } + if m.Tree == "" { + return fmt.Errorf("delivery: the preview's module %q has no tree", dir) + } + if _, err := ManifestsKey(m.Manifest); err != nil { + return fmt.Errorf("delivery: the preview's module %q: %w", dir, err) + } + for path, hash := range m.Locks { + if path == "" || hash == "" { + return fmt.Errorf("delivery: the lock %q of the preview's module %q has an empty path or hash", path, dir) + } + } + } + return nil +} + +// EncodePreview is p as JSON at Version. +func EncodePreview(p Preview) ([]byte, error) { + if err := p.check(); err != nil { + return nil, err + } + p.V = Version + return json.Marshal(p) +} + +// DecodePreview reads a preview. +func DecodePreview(data []byte) (Preview, error) { + var p Preview + if err := decode(data, &p); err != nil { + return Preview{}, err + } + if err := p.check(); err != nil { + return Preview{}, err + } + return p, nil +} + +// SamePreview says whether two previews are the same value. +func SamePreview(a, b Preview) bool { return reflect.DeepEqual(a, b) } diff --git a/vendor/modules.txt b/vendor/modules.txt index 7973b78c..217d70c8 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,4 +1,4 @@ -# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021173436-07a65927c362 +# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021204452-580da61a9107 ## explicit; go 1.26.0 git.novox.be/novox/mesh-sdk/go/asks git.novox.be/novox/mesh-sdk/go/catalogue