Author SHA1 Message Date
jschoubben d83156fb21 Add checker_previews and checker_status read tools so the live acceptance of issues 1680 and 1685 can be read (C1, C2) 2026-10-22 17:25:11 +02:00
jschoubben 16ca51dc1f Bound builder_builds by key prefix, limit and read budget, list unreadable keys, refuse fp2 with commit, add candidate reads (issue 1827 V1-V3, 1818, 1681) 2026-10-22 17:23:46 +02:00
mesh-tools 0e372c87f1 Merge pull request 'Read a go.mod the head does not hold at the trunk, so a branch older than a new Go module passes the pin check (issue 1845)' (#21) from fix/1845-pin-read-at-trunk into main 2026-10-22 13:44:08 +00:00
jschoubben 4a0c771059 Match the forge's 404 by its status, not by digits a path's sha may hold (issue 1845)
mesh/repo-check head 4a0c77105943 merged into trunk 2b635e2138ae — pass: its merge-check.sh passed
mesh/check pass 4a0c77105943 on 2b635e2138ae | unit 371 run 0 kept | int 410 run 0 kept | clone 13s unit 206ms int 14s+45s | composition not yet run
mesh/merge-gate head 4a0c77105943 merged into trunk 2b635e2138ae — pass: builds mesh-delivery-checker → novox; no bus step; every machine composes with the…
mesh/delivery delivered
2026-10-22 15:09:25 +02:00
jschoubben 14c78e485f Read a Go module's go.mod at the trunk when the head does not hold it, so a branch cut before the module was added passes the pin check (issue 1845) 2026-10-22 15:00:58 +02:00
mesh-tools 2b635e2138 Merge pull request 'Send image artifacts to mesh-build-image, one node, publishing only from the trunk (issue 1705)' (#20) from feat/r4c9-image-build into main 2026-10-22 08:57:21 +00:00
jschoubben db62e82e26 Write a failed publish of a merged image to the key; read mesh-build-image's captured steps (review r2)
mesh/repo-check head db62e82e26d7 merged into trunk 47ab8cd3cf45 beside mesh-catalog#435@7c5d11e5 — pass: its merge-check.sh passed
mesh/merge-gate head db62e82e26d7 merged into trunk 47ab8cd3cf45 — pass: group feat/r4c9-image-build composed: every machine composes with the change as it…
mesh/check pass db62e82e26d7 on 2b635e2138ae | unit 366 run 0 kept | int 405 run 0 kept | clone 9.2s unit 191ms int 6.7s+44s | composition not yet run
mesh/delivery delivered
mesh/delivery-group group feat/r4c9-image-build delivered: every member is delivered
2026-10-22 10:37:21 +02:00
mesh-tools 47ab8cd3cf Merge pull request 'The checker moves a step that no build node answers to another node (issue 1820)' (#19) from fix/1820-checker-heard-nodes into main 2026-10-22 08:36:34 +00:00
jschoubben 12f61998c5 Publish a merged candidate's image once; a retaken key builds only what is missing (review r1) 2026-10-22 10:33:52 +02:00
mesh-tools 5b09d1ccb5 Merge pull request 'Read tools for the builder's builds in the view (issue 1723)' (#18) from fix/1723-builder-builds-tool into main 2026-10-22 08:30:42 +00:00
jschoubben bbc690539b Build an image on one node through mesh-build-image; only a trunk key publishes (issue 1705) 2026-10-22 10:19:26 +02:00
jschoubben bbb42d704c The checker moves a step that no build node answers to another node and fails naming every node tried (issue 1820)
mesh/check pass bbb42d704ce0 on 5b09d1ccb52d | unit 354 run 0 kept | int 392 run 0 kept | clone 8.7s unit 199ms int 7.1s+44s | composition not yet run
mesh/merge-gate head bbb42d704ce0 merged into trunk 5b09d1ccb52d — pass: builds mesh-delivery-checker → novox; no bus step; every machine composes with the…
mesh/repo-check head bbb42d704ce0 merged into trunk 5b09d1ccb52d — pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-22 10:04:29 +02:00
jschoubben f2379dfeea Add builder_build and builder_builds read tools to the view, so the builder's keys can be checked live (issue 1723)
mesh/check failed: g14: fetch of novox/mesh-delivery#18: build_go_fetch on g14: nats: no responders available for request | f2379dfeea2d on 113f7aa933…
mesh/merge-gate head f2379dfeea2d merged into trunk 113f7aa93385 — pass: builds mesh-delivery-view → shanks; no bus step; every machine composes with the c…
mesh/repo-check head f2379dfeea2d merged into trunk 113f7aa93385 — pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-22 09:54:46 +02:00
mesh-tools 113f7aa933 Merge pull request 'The checker runs beside today's check: the checker, the indexer's candidate fingerprints, the bridge's status' (#17) from feat/the-checker into main 2026-10-21 21:46:33 +00:00
jschoubben b519c8dde2 Keep nothing for a workspace given siblings, place the siblings where the tests state them, decide the hold from the listed plan keys (review D1-D3)
mesh/repo-check head b519c8dde2da merged into trunk a5aa69a4e815 beside mesh-catalog#423@12ff1cfb +1 more — pass: its merge-check.sh passed
mesh/merge-gate head b519c8dde2da merged into trunk a5aa69a4e815 — pass: group feat/the-checker composed: every machine composes with the change as it did …
mesh/delivery delivered
mesh/delivery-group group feat/the-checker delivered: every member is delivered
2026-10-21 21:52:41 +02:00
jschoubben 7214bf4368 Fail a candidate's build when its fetch answers files in conflict or no merge, so the trunk's build is not stored under the candidate's fingerprint (review D4) 2026-10-21 21:50:30 +02:00
49 changed files with 2196 additions and 162 deletions
@@ -57,6 +57,7 @@ type fakeBuildModules struct {
mu sync.Mutex
calls map[string]int // by `<module>.<tool>`
fetches []map[string]any
builds []map[string]any // the arguments of every build call
}
func (f *fakeBuildModules) count(tool string) int {
@@ -88,6 +89,10 @@ func (f *fakeBuildModules) serve(t *testing.T, nc *nats.Conn, module string) {
f.mu.Lock()
f.fetches = append(f.fetches, args)
f.mu.Unlock()
} else {
f.mu.Lock()
f.builds = append(f.builds, args)
f.mu.Unlock()
}
id := fmt.Sprintf("s%d", f.n.Add(1))
key, _ := delivery.StepKey(node, id)
@@ -109,9 +114,16 @@ func (f *fakeBuildModules) serve(t *testing.T, nc *nats.Conn, module string) {
step.Ended = time.Now().UTC()
switch {
case strings.HasSuffix(tool, "_fetch"):
step.State, step.Result = delivery.StepPassed, json.RawMessage(`{"workspace":"w`+id+`","commit":"`+commit+`"}`)
merge := ""
if args["base"] != nil { // a fetch with a base answers the merge commit (mesh-build-go FetchResult)
merge = `,"merge":"` + strings.Repeat("9", 40) + `"`
}
step.State, step.Result = delivery.StepPassed, json.RawMessage(`{"workspace":"w`+id+`","commit":"`+commit+`"`+merge+`}`)
case f.failBuild.Load() && node == "n1":
step.State, step.Reason = delivery.StepFailed, fmt.Sprintf("go build of %v: boom", args["artifact"])
case tool == "build_image_build" && args["publish"] == true: // a trunk image is pushed: the digest and the reference
d := digestOf(fmt.Sprint(args["artifact"]))
step.State, step.Result = delivery.StepPassed, json.RawMessage(`{"digest":"`+d+`","reference":"registry.example/imaged/server@`+d+`"}`)
default:
step.State, step.Result = delivery.StepPassed, json.RawMessage(`{"digest":"`+digestOf(fmt.Sprint(args["artifact"]))+`"}`)
}
@@ -214,6 +226,8 @@ func startBed(t *testing.T) *bed {
b.fake = &fakeBuildModules{js: js, ctx: ctx, calls: map[string]int{}}
b.fake.serve(t, ctl, "mesh-build-go")
b.fake.serve(t, ctl, "mesh-build-node")
b.fake.serve(t, ctl, "mesh-build-image")
b.setHealth(t, "n1", "mesh-build-image", delivery.HealthOK) // the image build module runs on n1 only
b.report(t, "mesh-build-go", "n1", "go", "go1.27.1", "1")
b.report(t, "mesh-build-go", "n2", "go", "go1.27.1", "1")
b.report(t, "mesh-build-node", "n1", "typescript", "tsc5", "1")
@@ -578,3 +592,85 @@ func TestBuilderBuildsACandidatesFingerprintFromTheHeadMergedOntoTheTrunk(t *tes
t.Errorf("fetches = %v, want one of the candidate's head with its ref and the trunk commit", b.fake.fetches)
}
}
// TestAnImageIsBuiltOnOneNodeAndOnlyATrunkKeyPublishes: modules.imaged (one image artifact) is built on n1, where
// mesh-build-image runs: a trunk record's key is done with the digest and the reference, its build call carrying publish; a
// candidate's key for the same module is built first with no publish and no reference; when the pull request is merged and the
// trunk's record names the candidate's fingerprint, the key is built again with publish and ends with a reference
// (ADR 0487 clauses 5 and 12).
func TestAnImageIsBuiltOnOneNodeAndOnlyATrunkKeyPublishes(t *testing.T) { // 1705
b := startBed(t)
other := "src2:" + strings.Repeat("e", 64)
digest := b.moduleCommit(t, "imaged", "catalogue-a67f0196-imaged.module.json", "modules/imaged", nil, commit)
// The trunk carries `other`; its key is built and published.
trunk := func(fp2 string) {
data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: commit, FP: "src1:" + strings.Repeat("f", 64), FP2: fp2,
Manifest: digest, Path: "modules/imaged"})
if err != nil {
t.Fatal(err)
}
if _, err := b.modules.Put(b.ctx, "imaged", data); err != nil {
t.Fatal(err)
}
}
trunk(other)
b.start(t, "n1", nil)
okey, _ := delivery.BuilderBuildKey("imaged", other)
awaitKey := func(key string, pred func(delivery.BuilderBuild) bool) delivery.BuilderBuild {
var last delivery.BuilderBuild
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if e, err := b.builds.Get(b.ctx, key); err == nil {
if last, err = delivery.DecodeBuilderBuild(e.Value()); err == nil && pred(last) {
return last
}
}
}
t.Fatalf("builds.%s = %+v within 20 s", key, last)
return last
}
v := awaitKey(okey, isState(delivery.BuildDone))
if len(v.Artifacts) != 1 || v.Artifacts[0].Digest != digestOf("server") || !strings.HasSuffix(v.Artifacts[0].Reference, "@"+digestOf("server")) {
t.Fatalf("trunk build = %+v, want the digest and the reference", v)
}
// The candidate carries fp: built, not published.
const head = "1111111111111111111111111111111111111111"
pulls := []delivery.CandidatePull{{Head: delivery.Head{Repository: "novox/mesh-catalog", Number: 7, Head: head}, Modules: []string{"imaged"},
Fps: map[string]delivery.CandidateModule{"imaged": {FP2: fp, Manifest: digest}}}}
hash, _ := delivery.HeadsHash([]delivery.Head{pulls[0].Head})
cand, err := delivery.EncodeCandidate(delivery.Candidate{V: delivery.Version, Group: "feat/x", HeadsHash: hash, Pulls: pulls, FPSet: delivery.FingerprintSet(pulls)})
if err != nil {
t.Fatal(err)
}
ckey, _ := delivery.CandidatesKey("feat/x")
if _, err := b.candidates.Put(b.ctx, ckey, cand); err != nil {
t.Fatal(err)
}
v = b.await(t, "imaged", isState(delivery.BuildDone))
if len(v.Artifacts) != 1 || v.Artifacts[0].Reference != "" {
t.Fatalf("candidate build = %+v, want done with no reference", v)
}
b.fake.mu.Lock()
if len(b.fake.builds) != 2 || b.fake.builds[0]["publish"] != true || b.fake.builds[1]["publish"] != nil {
t.Errorf("builds = %v, want the trunk's with publish, then the candidate's with none", b.fake.builds)
}
b.fake.mu.Unlock()
// The pull request is merged: the trunk's record names fp, and its key ends published, built once more with publish.
trunk(fp)
v = b.await(t, "imaged", func(v delivery.BuilderBuild) bool {
return v.State == delivery.BuildDone && len(v.Artifacts) == 1 && v.Artifacts[0].Reference != ""
})
if v.Artifacts[0].Digest != digestOf("server") {
t.Errorf("merged build = %+v", v)
}
b.fake.mu.Lock()
defer b.fake.mu.Unlock()
published := 0
for _, a := range b.fake.builds {
if a["publish"] == true {
published++
}
}
if len(b.fake.builds) != 3 || published != 2 {
t.Errorf("builds = %v, want three, two of them with publish (the trunk's two fingerprints)", b.fake.builds)
}
}
@@ -0,0 +1,14 @@
{
"module": "imaged",
"version": "1",
"slug": "imaged",
"build": {
"artifacts": [
{
"name": "server",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
@@ -2,6 +2,7 @@ package build
import (
"context"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -94,3 +95,20 @@ func TestAFingerprintTheTrunkCarriesIsBuiltFromTheTrunksCommit(t *testing.T) { /
t.Errorf("build = %+v", v)
}
}
func TestACandidateWhoseHeadDoesNotMergeIsFailedAndNoTrunkBuildIsStoredUnderIt(t *testing.T) { // review D4
for name, set := range map[string]func(*rig){
"conflict": func(r *rig) { r.bm.conflicts = "go.mod" },
"no merge": func(r *rig) { r.bm.noMerge = true },
} {
r := newRig(t, goBundle)
trunkModule(t, r)
set(r)
r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX))
r.b.Wait()
v := valueOf(t, r.st, r.key)
if v.State != delivery.BuildFailed || (name == "conflict" && !strings.Contains(v.Error, "go.mod")) || len(r.bm.callsOf("build_go_build")) != 0 {
t.Errorf("%s: build = %+v, builds %d, want failed and nothing built", name, v, len(r.bm.callsOf("build_go_build")))
}
}
}
@@ -63,6 +63,26 @@ type ClaimOptions struct {
// qualifies now and the key may be built again (it is not held back after a failed second build, issue 1664). Nil means
// none does.
MoreNodes func(modules []string) bool
// ImageBuilder says whether a node runs a healthy image build module now: a key failed for NoImageBuilderPrefix is taken
// again only then. Nil means none does.
ImageBuilder func() bool
// Trunk is whether the trunk's record names this fingerprint. A done key whose image has no reference was built as a
// candidate's, which never pushes (ADR 0487 clause 12); on the trunk it is taken again to publish the image, when an image
// builder runs (ImageBuilder).
Trunk bool
}
// Unpublished is whether a done build holds an image built without a push: it has no reference.
func Unpublished(b delivery.BuilderBuild) bool {
if b.State != delivery.BuildDone {
return false
}
for _, a := range b.Artifacts {
if a.Kind == "image" && a.Reference == "" {
return true
}
}
return false
}
// Claim takes the build of module at fingerprint, or answers nil when it is not this builder's to build now:
@@ -72,7 +92,8 @@ type ClaimOptions struct {
// builder writes the new revision when the second build ends (ADR 0478 clause 4);
// - a failed key is taken again as a new revision of the same key (ADR 0429 clause 18), except one failed for a kind no
// builder builds yet (RefusedPrefix, ADR 0478 clause 2) or for digests that differ (buildrun.DigestsDifferPrefix, clause 4),
// which wait for another fingerprint;
// which wait for another fingerprint, and one failed for want of an image builder (NoImageBuilderPrefix), which waits for
// another fingerprint or a node that runs the image build module (ClaimOptions.ImageBuilder);
// - a key left queued or building by this builder is taken as it stands (ADR 0478 clause 5: a stopped builder takes its
// own again when it starts), and so is one left by another builder when ClaimOptions.TakeOver (clause 6).
func (c Claimer) Claim(ctx context.Context, module, fingerprint, manifest string, opt ClaimOptions) (*Claim, error) {
@@ -97,9 +118,14 @@ func (c Claimer) Claim(ctx context.Context, module, fingerprint, manifest string
if modules := OneNodeModules(cur); len(modules) > 0 && opt.MoreNodes != nil && opt.MoreNodes(modules) {
return &Claim{Key: key, Rev: rev, Build: cur}, nil
}
if opt.Trunk && Unpublished(cur) && opt.ImageBuilder != nil && opt.ImageBuilder() {
return &Claim{Key: key, Rev: rev, Build: cur}, nil
}
return nil, nil
case cur.State == delivery.BuildFailed && (strings.HasPrefix(cur.Error, RefusedPrefix) || strings.HasPrefix(cur.Error, buildrun.DigestsDifferPrefix)):
return nil, nil
case cur.State == delivery.BuildFailed && strings.HasPrefix(cur.Error, NoImageBuilderPrefix) && (opt.ImageBuilder == nil || !opt.ImageBuilder()):
return nil, nil
case cur.State == delivery.BuildFailed:
return c.write(ctx, key, building, func(data []byte) (uint64, error) { return c.Store.Update(ctx, key, data, rev) })
case cur.By == c.By || opt.TakeOver:
@@ -155,6 +155,22 @@ func TestAFailedKeyIsTakenAgainAsANewRevisionExceptARefusedKind(t *testing.T) {
}
}
func TestAKeyFailedForNoImageBuilderIsTakenAgainOnlyWhenANodeHasTheModule(t *testing.T) { // 1705b
st := newMem()
key := seed(t, st, delivery.BuilderBuild{State: delivery.BuildFailed, By: "novox.a", Error: NoImageBuilderPrefix + " no node has mesh-build-image"})
writes := st.writes
c := Claimer{Store: st, By: "novox.a"}
for _, opt := range []ClaimOptions{{}, {ImageBuilder: func() bool { return false }}} {
if got, err := c.Claim(context.Background(), "m", fp1, "", opt); err != nil || got != nil || st.writes != writes {
t.Errorf("claim = %v, %v, writes %d (was %d), want none and no write", got, err, st.writes, writes)
}
}
got, err := c.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{ImageBuilder: func() bool { return true }})
if err != nil || got == nil || valueOf(t, st, key).State != delivery.BuildBuilding {
t.Errorf("claim = %v, %v, want a claim with the key building", got, err)
}
}
func TestFinishMovesTheKeyByCompareAndSetAndWritesOnlyADifferentValue(t *testing.T) { // 1607/AC3
st := newMem()
c := Claimer{Store: st, By: "novox.a"}
@@ -0,0 +1,41 @@
package build
import (
"encoding/json"
"os"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// result reads a captured step value of mesh-build-image (testdata/captured/README) and returns its result.
func captured(t *testing.T, name string) json.RawMessage {
t.Helper()
raw, err := os.ReadFile("testdata/captured/" + name)
if err != nil {
t.Fatal(err)
}
var step delivery.Step
if err := json.Unmarshal(raw, &step); err != nil {
t.Fatalf("%s: %v", name, err)
}
if step.State != delivery.StepPassed || len(step.Result) == 0 {
t.Fatalf("%s: state %q, result %s, want a passed step with a result", name, step.State, step.Result)
}
return step.Result
}
func TestCapturedImageStepsDecodeWithTheBuildersOwnTypes(t *testing.T) { // review r2, mesh-catalog 2; ADR 0487 clause 1
var f fetchResult
if err := json.Unmarshal(captured(t, "fetch.json"), &f); err != nil || f.Workspace == "" {
t.Errorf("fetch = %+v, %v, want a workspace (the field the builder reads)", f, err)
}
var trunk buildResult
if err := json.Unmarshal(captured(t, "build-trunk.json"), &trunk); err != nil || trunk.Digest == "" || trunk.Reference == "" {
t.Errorf("trunk build = %+v, %v, want a digest and a reference", trunk, err)
}
var cand buildResult
if err := json.Unmarshal(captured(t, "build-candidate.json"), &cand); err != nil || cand.Digest == "" || cand.Reference != "" {
t.Errorf("candidate build = %+v, %v, want a digest and no reference", cand, err)
}
}
@@ -15,6 +15,15 @@ import (
// a key is not built again as a new revision.
const RefusedPrefix = "refused kind:"
// ImageBuildModule is the build module that builds an image artifact (novox/hq ADR 0487): buildah, rootless, on the nodes that
// run it. It has no toolchain language; the builder finds its nodes from the node-engine's have.
const ImageBuildModule = "mesh-build-image"
// NoImageBuilderPrefix opens the error of a build key failed because no node runs ImageBuildModule (ADR 0487 clause 5): such a
// key is not built again as a new revision, only when a node reports the module (ClaimOptions.ImageBuilder) or the fingerprint
// changes.
const NoImageBuilderPrefix = "no image builder:"
// Task is one artifact to build: its name and kind in the manifest, the build module that builds it and the language
// of the toolchain it is built with.
type Task struct {
@@ -45,7 +54,7 @@ func (p Plan) Modules() []string {
// (the indexer gives an archive no language, so a fingerprint names no toolchain for it; ADR 0478 clause 1).
func (p Plan) NeedsToolchain(module string) bool {
for _, t := range p.Tasks {
if t.Module == module && t.Kind != catalogue.ArtifactArchive {
if t.Module == module && t.Kind != catalogue.ArtifactArchive && t.Kind != catalogue.ArtifactImage {
return true
}
}
@@ -61,7 +70,7 @@ func (r *Refused) Error() string {
}
// PlanOf is the plan of a manifest (ADR 0478 clauses 1 and 2): a Go bundle and an archive are built by the Go build
// module, a TypeScript bundle by the Node one, each in the order of the artifacts' names. A manifest with an image,
// module, an image by the image build module (ADR 0487), a TypeScript bundle by the Node one, each in the order of the artifacts' names. A manifest with an
// upstream or package artifact, or a bundle of another language, is refused as a whole, naming the first such artifact
// by name. A manifest with no artifact has an empty plan.
func PlanOf(m catalogue.Manifest) (Plan, error) {
@@ -79,6 +88,8 @@ func PlanOf(m catalogue.Manifest) (Plan, error) {
p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: buildrun.GoBuildModule, Language: "go"})
case a.Kind == catalogue.ArtifactBundle && a.Language == "typescript":
p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: buildrun.NodeBuildModule, Language: "typescript"})
case a.Kind == catalogue.ArtifactImage:
p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: ImageBuildModule})
case a.Kind == catalogue.ArtifactBundle:
return Plan{}, &Refused{Artifact: a.Name, What: fmt.Sprintf("bundle of language %q", a.Language)}
default:
@@ -41,12 +41,11 @@ func TestPlanRoutesEachKindToItsBuildModule(t *testing.T) { // 1607/AC2
}
}
func TestPlanRefusesImageUpstreamAndPackageByName(t *testing.T) { // 1607/AC2, ADR 0478 clause 2
func TestPlanRefusesUpstreamAndPackageByName(t *testing.T) { // 1607/AC2, ADR 0478 clause 2
for _, c := range []struct {
artifact catalogue.Artifact
kind string
}{
{catalogue.Artifact{Name: "img", Kind: "image"}, "image"},
{catalogue.Artifact{Name: "up", Kind: "upstream"}, "upstream"},
{catalogue.Artifact{Name: "pkg", Kind: "package", Language: "typescript"}, "package"},
} {
@@ -74,3 +73,16 @@ func TestPlanRefusesABundleOfAnotherLanguageAndAModuleWithNoArtifact(t *testing.
}
func asRefused(err error, target **Refused) bool { return errors.As(err, target) }
func TestPlanSendsAnImageToMeshBuildImageWithNoToolchain(t *testing.T) { // 1705a, ADR 0487 clause 5
p, err := PlanOf(manifestOf(catalogue.Artifact{Name: "img", Kind: "image"}, catalogue.Artifact{Name: "files", Kind: "archive"}))
if err != nil || len(p.Tasks) != 2 {
t.Fatalf("plan %+v, error %v", p, err)
}
if p.Tasks[1].Artifact != "img" || p.Tasks[1].Module != ImageBuildModule {
t.Errorf("task = %+v, want the image on %s", p.Tasks[1], ImageBuildModule)
}
if p.NeedsToolchain(ImageBuildModule) {
t.Error("an image needs no toolchain")
}
}
@@ -10,6 +10,7 @@ import (
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -19,7 +20,7 @@ type ToolchainEntries interface {
}
// toolPrefix is the start of each build module's tool names: <prefix>fetch and <prefix>build.
var toolPrefix = map[string]string{buildrun.GoBuildModule: "build_go_", buildrun.NodeBuildModule: "build_node_"}
var toolPrefix = map[string]string{buildrun.GoBuildModule: "build_go_", buildrun.NodeBuildModule: "build_node_", ImageBuildModule: "build_image_"}
// Builder builds, dark, every new-form fingerprint the indexer writes (novox/hq ADR 0453 clause 4, ADR 0478): it is
// handed each modules value as the watch of the indexer's bucket sees it, and for each module with a fingerprint and an
@@ -287,6 +288,7 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) {
}
key, _ := delivery.BuilderBuildKey(module, m.FP2)
b.mu.Lock()
trunk := b.trunkFP[key]
first := !b.tried[key]
b.tried[key] = true
epoch := b.epoch
@@ -296,7 +298,7 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) {
// A second build that failed is not tried again until the nodes change (issue 1664).
return (!failedBefore || epoch > failedAt) && b.moreNodes(modules)
}
cl, err := b.Claimer.Claim(ctx, module, m.FP2, m.Manifest, ClaimOptions{TakeOver: first, MoreNodes: moreNodes})
cl, err := b.Claimer.Claim(ctx, module, m.FP2, m.Manifest, ClaimOptions{TakeOver: first, MoreNodes: moreNodes, ImageBuilder: b.imageBuilderRuns, Trunk: trunk})
if err != nil {
b.logf("%s: %v", module, err)
return
@@ -304,7 +306,22 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) {
if cl == nil {
return
}
result := b.build(ctx, module, m, plan, planErr)
// A done key taken again builds only what is missing: the build modules that ran on one node, and the image when it was
// built as a candidate's; the other artifacts stay as the done key has them (ADR 0487 clauses 5 and 8).
var prior *delivery.BuilderBuild
var only map[string]bool
publishing := false
if cl.Build.State == delivery.BuildDone {
prior, only = &cl.Build, map[string]bool{}
for _, bm := range OneNodeModules(cl.Build) {
only[bm] = true
}
if trunk && Unpublished(cl.Build) {
only[ImageBuildModule] = true
publishing = true
}
}
result := b.build(ctx, module, m, plan, planErr, prior, only)
if ctx.Err() != nil {
return
}
@@ -313,7 +330,11 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) {
return
}
result.By, result.Manifest = b.Claimer.By, m.Manifest
if cl.Build.State == delivery.BuildDone && result.State == delivery.BuildFailed && !strings.HasPrefix(result.Error, buildrun.DigestsDifferPrefix) {
if publishing && result.State == delivery.BuildFailed {
// The trunk's image was built only as a candidate's and its push failed: the key says so, so the builds view, the
// checker and the failed-key retake see it, and a look does not build the image again for nothing.
result.Error = "publishing the image of the merged commit failed: " + result.Error
} else if cl.Build.State == delivery.BuildDone && result.State == delivery.BuildFailed && !strings.HasPrefix(result.Error, buildrun.DigestsDifferPrefix) {
// A done key built again on a second node keeps its done value when the new build fails for another reason than
// the digests: the first build stands, and a later change of the nodes looks again.
b.logf("%s: built again on a second node and failed, the done build stands: %s", cl.Key, result.Error)
@@ -360,10 +381,11 @@ type lane struct {
module, node string
tasks []Task
digests map[string]string // artifact -> digest
references map[string]string // artifact -> the reference an image was pushed to (a trunk build only)
err error
}
func (b *Builder) build(ctx context.Context, module string, m delivery.Module, plan Plan, planErr error) delivery.BuilderBuild {
func (b *Builder) build(ctx context.Context, module string, m delivery.Module, plan Plan, planErr error, prior *delivery.BuilderBuild, only map[string]bool) delivery.BuilderBuild {
switch {
case planErr != nil:
return failed("%s", planErr)
@@ -386,12 +408,27 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
b.Places.Cancel(l.node)
}
}
var kept []string // build modules whose artifacts stay as the prior done key has them
for _, bm := range plan.Modules() {
if prior != nil && len(only) > 0 && !only[bm] {
kept = append(kept, bm)
continue
}
language := buildrun.LanguageOf[bm]
entries := b.Toolchains.Entries(language)
qualifies := func(node string) bool { return b.qualifies(node, bm) }
var nodes []string
if plan.NeedsToolchain(bm) {
if bm == ImageBuildModule {
// An image is built on one node, chosen from the nodes whose image build module runs healthy (ADR 0487 clause 8):
// the module has no toolchain language, so no toolchain entry names its nodes, and a second build would only
// push the image again.
ns, err := buildrun.ChooseAnyNodes("image", b.imageEntries(), b.Places, qualifies, 1)
if err != nil {
giveUpLanes()
return failed("%s no node has %s, so the image cannot be built: the provision rootless-image-build is held by the module buildah; assign buildah and %s to a node (%v)", NoImageBuilderPrefix, ImageBuildModule, ImageBuildModule, err)
}
nodes = ns
} else if plan.NeedsToolchain(bm) {
ns, tc, err := buildrun.ChooseNodes(language, entries, b.Places, qualifies, b.Nodes)
if err != nil {
giveUpLanes()
@@ -413,7 +450,9 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
}
nodes = ns
}
if len(nodes) == 1 {
if bm == ImageBuildModule {
steps = append(steps, fmt.Sprintf("%s on %s", bm, nodes[0]))
} else if len(nodes) == 1 {
oneNode = append(oneNode, bm)
steps = append(steps, fmt.Sprintf("%s on one node, %s", bm, nodes[0]))
} else {
@@ -428,7 +467,7 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
}
}
for _, n := range nodes {
lanes = append(lanes, &lane{module: bm, node: n, tasks: tasks, digests: map[string]string{}})
lanes = append(lanes, &lane{module: bm, node: n, tasks: tasks, digests: map[string]string{}, references: map[string]string{}})
}
}
var wg sync.WaitGroup
@@ -447,7 +486,23 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
return failed("%s", strings.Join(errs, "; "))
}
var artifacts []delivery.BuiltArtifact
keptModule := map[string]bool{}
for _, bm := range kept {
keptModule[bm] = true
}
for _, t := range plan.Tasks {
if keptModule[t.Module] {
found := false
for _, a := range prior.Artifacts {
if a.Name == t.Artifact {
artifacts, found = append(artifacts, a), true
}
}
if !found {
return failed("the done build holds no artifact %s to keep", t.Artifact)
}
continue
}
byNode := map[string]string{}
for _, l := range lanes {
if d, ok := l.digests[t.Artifact]; ok && l.module == t.Module {
@@ -458,7 +513,21 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
if err != nil {
return failed("%s", err)
}
artifacts = append(artifacts, delivery.BuiltArtifact{Name: t.Artifact, Kind: t.Kind, Digest: d})
built := delivery.BuiltArtifact{Name: t.Artifact, Kind: t.Kind, Digest: d}
for _, l := range lanes {
if r := l.references[t.Artifact]; r != "" && l.module == t.Module {
built.Reference = r
}
}
artifacts = append(artifacts, built)
}
for _, bm := range kept {
steps = append(steps, bm+" kept")
for lang, u := range prior.Toolchains {
if _, ok := used[lang]; !ok && buildrun.LanguageOf[bm] == lang {
used[lang] = u
}
}
}
step := "built " + strings.Join(steps, "; ")
if len(oneNode) > 0 {
@@ -478,6 +547,39 @@ func (b *Builder) qualifies(node, bm string) bool {
return b.Places.CapacityOf(node) > 0 && b.online(node) && (b.Health == nil || b.Health.Healthy(node, bm))
}
// imageEntries are the nodes an image may be built on, as toolchain entries for the chooser: every node the builder has a
// capacity for or a build module toolchain from. Whether the image build module runs there is qualifies', from the have.
func (b *Builder) imageEntries() []delivery.NodeToolchain {
seen := map[string]bool{}
var entries []delivery.NodeToolchain
add := func(n string) {
if !seen[n] {
seen[n] = true
entries = append(entries, delivery.NodeToolchain{Node: n})
}
}
for n := range b.Places.Capacity {
add(n)
}
for _, language := range []string{"go", "typescript"} {
for _, e := range b.Toolchains.Entries(language) {
add(e.Node)
}
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Node < entries[j].Node })
return entries
}
// imageBuilderRuns is whether a node can build an image now.
func (b *Builder) imageBuilderRuns() bool {
for _, e := range b.imageEntries() {
if b.qualifies(e.Node, ImageBuildModule) {
return true
}
}
return false
}
// moreNodes is whether any of these build modules has two nodes that qualify now.
func (b *Builder) moreNodes(modules []string) bool {
for _, bm := range modules {
@@ -501,6 +603,18 @@ func (b *Builder) step(ctx context.Context, bm, node, tool string, args map[stri
return b.Runner.Run(ctx, bm, node, toolPrefix[bm]+tool, args)
}
// fetchResult and buildResult are the results of a build module's fetch and build steps as the builder reads them.
type fetchResult struct {
Workspace string `json:"workspace"`
Merge string `json:"merge"`
Conflicts []string `json:"conflicts"`
}
type buildResult struct {
Digest string `json:"digest"`
Reference string `json:"reference"`
}
// runLane takes a place on the node, fetches the commit there and builds each of the lane's artifacts, one step at a time.
func (b *Builder) runLane(ctx context.Context, l *lane, repository string, m delivery.Module, module string) {
release, err := b.Places.Acquire(ctx, l.node)
@@ -523,26 +637,43 @@ func (b *Builder) runLane(ctx context.Context, l *lane, repository string, m del
l.err = err
return
}
var fetched struct {
Workspace string `json:"workspace"`
}
var fetched fetchResult
if err := json.Unmarshal(raw, &fetched); err != nil || fetched.Workspace == "" {
l.err = fmt.Errorf("the fetch on %s answered no workspace: %s", l.node, raw)
return
}
// A candidate is built from its head merged onto the trunk. A head that does not merge ends the fetch passed with the tree at
// the trunk, and building that would store the trunk's build under the candidate's fingerprint.
_, candidate := fetchArgs["base"]
if candidate {
if len(fetched.Conflicts) > 0 {
l.err = fmt.Errorf("the head does not merge into the trunk on %s: files in conflict: %s", l.node, strings.Join(fetched.Conflicts, ", "))
return
}
if fetched.Merge == "" {
l.err = fmt.Errorf("the fetch with a base on %s answered no merge: %s", l.node, raw)
return
}
}
for _, t := range l.tasks {
raw, err := b.step(ctx, l.module, l.node, "build", map[string]any{"workspace": fetched.Workspace, "module": m.Path, "artifact": t.Artifact})
args := map[string]any{"workspace": fetched.Workspace, "module": m.Path, "artifact": t.Artifact}
if t.Kind == catalogue.ArtifactImage && !candidate {
// Only a trunk key pushes the image to the registry; a candidate's build never does (ADR 0487 clause 12).
args["publish"] = true
}
raw, err := b.step(ctx, l.module, l.node, "build", args)
if err != nil {
l.err = err
return
}
var built struct {
Digest string `json:"digest"`
}
var built buildResult
if err := json.Unmarshal(raw, &built); err != nil || built.Digest == "" {
l.err = fmt.Errorf("the build of %s of %s on %s answered no digest: %s", t.Artifact, module, l.node, raw)
return
}
l.digests[t.Artifact] = built.Digest
if built.Reference != "" && !candidate {
l.references[t.Artifact] = built.Reference
}
}
}
@@ -24,8 +24,11 @@ type buildModules struct {
digests map[string]string // `<node>/<artifact>` -> digest; the default is `digest`
fails map[string]string // `<node>/<tool>` -> the reason the step fails with
// hold, when set before the builds start, blocks every call until it is closed; entered is sent to as a call arrives.
hold chan struct{}
entered chan struct{}
// conflicts, when set, is a file a fetch with a base reports in conflict; noMerge makes it answer neither a merge nor a conflict.
conflicts string
noMerge bool
hold chan struct{}
entered chan struct{}
}
func newBuildModules() *buildModules {
@@ -52,12 +55,23 @@ func (b *buildModules) CallOn(node, key string, body any) (json.RawMessage, erro
step.State, step.Reason = delivery.StepFailed, reason
} else if strings.HasSuffix(tool, "_fetch") {
step.Result = json.RawMessage(`{"workspace":"w-` + node + `"}`)
if args["base"] != nil { // a fetch with a base answers the merge, or the files in conflict (mesh-build-go FetchResult)
if b.conflicts != "" {
step.Result = json.RawMessage(`{"workspace":"w-` + node + `","base":"` + fmt.Sprint(args["base"]) + `","conflicts":["` + b.conflicts + `"]}`)
} else if !b.noMerge {
step.Result = json.RawMessage(`{"workspace":"w-` + node + `","base":"` + fmt.Sprint(args["base"]) + `","merge":"` + strings.Repeat("9", 40) + `"}`)
}
}
} else {
d := digest
if v, ok := b.digests[node+"/"+fmt.Sprint(args["artifact"])]; ok {
d = v
}
step.Result = json.RawMessage(`{"digest":"` + d + `"}`)
if tool == "build_image_build" && args["publish"] == true {
step.Result = json.RawMessage(`{"digest":"` + d + `","reference":"registry.example/m/img@` + d + `"}`)
} else {
step.Result = json.RawMessage(`{"digest":"` + d + `"}`)
}
}
sk, _ := delivery.StepKey(node, id)
b.steps.mu.Lock()
@@ -228,11 +242,126 @@ func TestAFailingStepFailsTheBuildWithItsReasonAndNodeAndIsTriedAgainAsANewRevis
}
}
func TestAModuleWithAnImageGetsARefusedKeyNeverBuiltAgain(t *testing.T) { // 1607/AC2, ADR 0478 clause 2
r := newRig(t, archiveA, imageArt)
// imageHave marks mesh-build-image running and healthy on these nodes.
func imageHave(t *testing.T, nodes ...string) *buildrun.HaveSet {
t.Helper()
have := &buildrun.HaveSet{}
for _, n := range nodes {
have.Put(n+".mesh-build-go", haveValue(t, delivery.PhaseRunning, delivery.HealthOK), false)
have.Put(n+"."+ImageBuildModule, haveValue(t, delivery.PhaseRunning, delivery.HealthOK), false)
}
return have
}
func TestAnImageGoesToOneNodeAndGetsOneDoneKeyWithItsReference(t *testing.T) { // 1705a, ADR 0487 clause 5
r := newRig(t, imageArt)
r.b.Health = imageHave(t, "ace", "novox") // two nodes qualify; an image still builds on one
v := r.consider(t, "modules/m")
if v.State != delivery.BuildFailed || !strings.HasPrefix(v.Error, RefusedPrefix) || !strings.Contains(v.Error, "image") || !strings.Contains(v.Error, "build-agent") {
t.Fatalf("build = %+v, want failed 'refused kind: image' naming build-agent", v)
if v.State != delivery.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Kind != "image" || v.Artifacts[0].Digest != digest {
t.Fatalf("build = %+v, want done with one image artifact and its digest", v)
}
if v.Artifacts[0].Reference == "" {
t.Errorf("a trunk image has no reference: %+v", v.Artifacts[0])
}
if strings.HasPrefix(v.Step, OneNodePrefix) || len(OneNodeModules(v)) != 0 {
t.Errorf("step = %q, want no one-node mark: an image is never built again on a second node", v.Step)
}
if n := len(r.bm.callsOf("build_image_fetch")); n != 1 {
t.Errorf("%d image fetches, want one", n)
}
writes := r.st.writes
r.consider(t, "modules/m")
if r.st.writes != writes || len(r.bm.callsOf("build_image_build")) != 1 {
t.Errorf("a done image key was built again")
}
}
func TestAnImageIsBuiltOnlyWhereMeshBuildImageIsHealthy(t *testing.T) { // 1705a
r := newRig(t, imageArt)
r.b.Health = imageHave(t, "novox")
r.consider(t, "modules/m")
c := r.bm.callsOf("build_image_build")
if len(c) != 1 || c[0].node != "novox" {
t.Errorf("calls = %+v, want one on novox", c)
}
}
func TestABundleAndAnImageOfOneModuleBuildOnTwoNodesAndOne(t *testing.T) { // 1705a
r := newRig(t, goBundle, imageArt)
r.b.Health = imageHave(t, "ace", "novox")
v := r.consider(t, "modules/m")
if v.State != delivery.BuildDone || len(v.Artifacts) != 2 {
t.Fatalf("build = %+v", v)
}
if n := len(r.bm.callsOf("build_go_build")); n != 2 {
t.Errorf("%d go builds, want 2", n)
}
if n := len(r.bm.callsOf("build_image_build")); n != 1 {
t.Errorf("%d image builds, want 1", n)
}
if modules := OneNodeModules(v); len(modules) != 0 {
t.Errorf("one-node modules = %v, want none", modules)
}
}
func TestWithNoImageNodeTheKeyFailsNamingTheProvisionAndIsNotRetakenUntilANodeReportsTheModule(t *testing.T) { // 1705b, lead's decision
r := newRig(t, imageArt)
r.b.Health = imageHave(t) // mesh-build-image runs nowhere
v := r.consider(t, "modules/m")
if v.State != delivery.BuildFailed || !strings.HasPrefix(v.Error, NoImageBuilderPrefix) || !strings.Contains(v.Error, "rootless-image-build") || !strings.Contains(v.Error, "buildah") || !strings.Contains(v.Error, ImageBuildModule) {
t.Fatalf("build = %+v, want failed naming rootless-image-build, buildah and mesh-build-image", v)
}
if len(r.bm.calls) != 0 {
t.Errorf("%d calls, want none", len(r.bm.calls))
}
writes := r.st.writes
r.consider(t, "modules/m")
r.consider(t, "modules/m")
if r.st.writes != writes {
t.Errorf("the failed key was written again (%d writes, was %d)", r.st.writes, writes)
}
// A node reports the module: the key is taken again.
r.b.Health = imageHave(t, "ace")
v = r.consider(t, "modules/m")
if v.State != delivery.BuildDone {
t.Errorf("build = %+v, want done once a node reports mesh-build-image", v)
}
}
func TestACandidatesImageCallCarriesNoPublishAndItsKeyNoReference(t *testing.T) { // 1705b, lead's decision
r := newRig(t, imageArt)
r.b.Health = imageHave(t, "ace")
trunkModule(t, r)
r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX))
r.b.Wait()
v := valueOf(t, r.st, r.key)
if v.State != delivery.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Reference != "" {
t.Fatalf("build = %+v, want done with no reference", v)
}
calls := r.bm.callsOf("build_image_build")
if len(calls) != 1 {
t.Fatalf("%d image builds, want 1", len(calls))
}
if _, has := calls[0].body.(map[string]any)["publish"]; has {
t.Errorf("a candidate's image call carries publish: %+v", calls[0].body)
}
}
func TestATrunkImageCallCarriesPublish(t *testing.T) { // 1705b
r := newRig(t, imageArt)
r.b.Health = imageHave(t, "ace")
r.consider(t, "modules/m")
calls := r.bm.callsOf("build_image_build")
if len(calls) != 1 || calls[0].body.(map[string]any)["publish"] != true {
t.Errorf("calls = %+v, want publish true", calls)
}
}
func TestAnUpstreamArtifactIsStillRefusedKeyNeverBuiltAgain(t *testing.T) { // 1607/AC2, ADR 0478 clause 2
r := newRig(t, archiveA, catalogue.Artifact{Name: "up", Kind: "upstream"})
v := r.consider(t, "modules/m")
if v.State != delivery.BuildFailed || !strings.HasPrefix(v.Error, RefusedPrefix) || !strings.Contains(v.Error, "upstream") || !strings.Contains(v.Error, "build-agent") {
t.Fatalf("build = %+v, want failed 'refused kind: upstream' naming build-agent", v)
}
if len(r.bm.calls) != 0 {
t.Errorf("%d calls, want none for a refused kind", len(r.bm.calls))
@@ -401,3 +530,104 @@ func TestANodeThatIsNotOnlineIsNotChosenAndComingBackWakesTheBuild(t *testing.T)
t.Errorf("build = %+v, want done once a node is online", v)
}
}
func TestACandidatesImageIsPublishedOnceWhenTheTrunkNamesItsFingerprint(t *testing.T) { // review r1, mesh-delivery 1; ADR 0487 clause 5
r := newRig(t, imageArt)
r.b.Health = imageHave(t, "ace")
trunkModule(t, r) // the trunk carries another fingerprint; fp1 is the candidate's
r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX))
r.b.Wait()
v := valueOf(t, r.st, r.key)
if v.State != delivery.BuildDone || v.Artifacts[0].Reference != "" {
t.Fatalf("candidate build = %+v, want done with no reference", v)
}
if n := len(r.bm.callsOf("build_image_build")); n != 1 {
t.Fatalf("%d image builds after the candidate, want 1", n)
}
// The pull request is merged: the trunk's record names the candidate's fingerprint.
r.b.Module(context.Background(), "m", moduleValue(t, "modules/m"))
r.b.Wait()
v = valueOf(t, r.st, r.key)
if v.State != delivery.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Reference == "" {
t.Fatalf("trunk build = %+v, want done with a reference", v)
}
calls := r.bm.callsOf("build_image_build")
published := 0
for _, c := range calls {
if c.body.(map[string]any)["publish"] == true {
published++
}
}
if len(calls) != 2 || published != 1 {
t.Errorf("%d image builds, %d with publish, want 2 and exactly 1", len(calls), published)
}
// Written once more only: looking again builds nothing.
rev := r.st.revs[r.key]
r.b.Module(context.Background(), "m", moduleValue(t, "modules/m"))
r.b.Wait()
if r.st.revs[r.key] != rev || len(r.bm.callsOf("build_image_build")) != 2 {
t.Error("a published key was built again")
}
}
func TestASecondGoNodeDoesNotBuildOrPushTheImageAgain(t *testing.T) { // review r1, mesh-delivery 2; ADR 0487 clause 8
r := newRig(t, goBundle, imageArt)
r.b.Health = imageHave(t, "ace", "novox")
r.pres.Put("ace", presenceValue(t, false), false) // only novox qualifies for Go (and the image)
v := r.consider(t, "modules/m")
if v.State != delivery.BuildDone || len(OneNodeModules(v)) != 1 {
t.Fatalf("build = %+v, want done with the Go bundle on one node", v)
}
var image delivery.BuiltArtifact
for _, a := range v.Artifacts {
if a.Kind == "image" {
image = a
}
}
if image.Reference == "" {
t.Fatalf("artifacts = %+v, want a published image", v.Artifacts)
}
r.pres.Put("ace", presenceValue(t, true), false)
r.b.Reconsider(context.Background())
r.b.Wait()
v = valueOf(t, r.st, r.key)
if len(OneNodeModules(v)) != 0 || len(v.Artifacts) != 2 {
t.Fatalf("build = %+v, want done on two Go nodes", v)
}
if n := len(r.bm.callsOf("build_image_build")); n != 1 {
t.Errorf("%d image builds, want the one of the first build only", n)
}
for _, a := range v.Artifacts {
if a.Kind == "image" && a != image {
t.Errorf("image = %+v, was %+v: the done key was rewritten with another image", a, image)
}
}
if n := len(r.bm.callsOf("build_go_build")); n != 3 { // one, then two
t.Errorf("%d go builds, want 3", n)
}
}
func TestAFailedPublishOfACandidatesImageIsWrittenToTheKey(t *testing.T) { // review r2, mesh-delivery 1; ADR 0487 clause 5
r := newRig(t, imageArt)
r.b.Health = imageHave(t, "ace")
trunkModule(t, r)
r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX))
r.b.Wait()
if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone {
t.Fatalf("candidate build = %+v", v)
}
r.bm.fails["ace/build_image_build"] = "push to the registry: connection refused"
r.b.Module(context.Background(), "m", moduleValue(t, "modules/m")) // the merge: the trunk names the fingerprint
r.b.Wait()
v := valueOf(t, r.st, r.key)
if v.State != delivery.BuildFailed || !strings.Contains(v.Error, "connection refused") || !strings.Contains(v.Error, "publish") {
t.Fatalf("build = %+v, want failed naming the publish and its reason", v)
}
// The failed key is retaken as any failed key is, and ends published once the registry answers.
delete(r.bm.fails, "ace/build_image_build")
r.b.Module(context.Background(), "m", moduleValue(t, "modules/m"))
r.b.Wait()
if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone || v.Artifacts[0].Reference == "" {
t.Errorf("build = %+v, want done with a reference", v)
}
}
@@ -0,0 +1,4 @@
Captured step values of mesh-build-image (fetch, a candidate's build, a trunk's build), copied from
novox/mesh-catalog modules/mesh-build-image/cmd/mesh-build-image/testdata/captured/. Both copies must change together: the
module's capture test writes them, and the builder's TestCapturedImageStepsDecodeWithTheBuildersOwnTypes (internal/build,
fixtures_test.go) decodes these with the types the builder reads results with and fails on a missing field.
@@ -0,0 +1,14 @@
{
"ended": "2026-10-22T08:00:01Z",
"id": "STEPID",
"node": "ace",
"result": {
"artifact": "server",
"digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
"pushed": false
},
"started": "2026-10-22T08:00:00Z",
"state": "passed",
"tool": "build_image_build",
"v": 1
}
@@ -0,0 +1,15 @@
{
"ended": "2026-10-22T08:00:01Z",
"id": "STEPID",
"node": "ace",
"result": {
"artifact": "server",
"digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
"pushed": true,
"reference": "REGISTRY/fixture-image/server@sha256:1111111111111111111111111111111111111111111111111111111111111111"
},
"started": "2026-10-22T08:00:00Z",
"state": "passed",
"tool": "build_image_build",
"v": 1
}
@@ -0,0 +1,13 @@
{
"ended": "2026-10-22T08:00:01Z",
"id": "STEPID",
"node": "ace",
"result": {
"commit": "COMMIT",
"workspace": "WORKSPACE"
},
"started": "2026-10-22T08:00:00Z",
"state": "passed",
"tool": "build_image_fetch",
"v": 1
}
@@ -86,12 +86,12 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
if want := []state{{Name: "builds", History: 5}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory
t.Errorf("state = %+v, want %+v", m.State, want)
}
if want := []string{"mesh-build-go.steps", "mesh-build-go.toolchains", "mesh-build-node.steps", "mesh-build-node.toolchains",
if want := []string{"mesh-build-go.steps", "mesh-build-go.toolchains", "mesh-build-image.steps", "mesh-build-node.steps", "mesh-build-node.toolchains",
"mesh-delivery-indexer.candidates", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", "mesh-delivery-watchman.presence", "node-engine.have"}; !reflect.DeepEqual(sorted(m.Reads), want) {
t.Errorf("reads = %v, want %v", m.Reads, want)
}
if want := []string{"mesh-build-go.build_go_build", "mesh-build-go.build_go_fetch", "mesh-build-node.build_node_build", "mesh-build-node.build_node_fetch"}; !reflect.DeepEqual(sorted(m.Invokes), want) {
t.Errorf("invokes = %v, want only the build modules' fetch and build", m.Invokes)
if want := []string{"mesh-build-go.build_go_build", "mesh-build-go.build_go_fetch", "mesh-build-image.build_image_build", "mesh-build-image.build_image_fetch", "mesh-build-node.build_node_build", "mesh-build-node.build_node_fetch"}; !reflect.DeepEqual(sorted(m.Invokes), want) {
t.Errorf("invokes = %v, want only the build modules' fetch and build (go, image, node)", m.Invokes)
}
// The builder is woken by a build module's report of a toolchain, as the indexer is: the bucket may not exist when it starts.
if want := []string{"mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
@@ -12,6 +12,7 @@
"reads": [
"mesh-build-go.steps",
"mesh-build-go.toolchains",
"mesh-build-image.steps",
"mesh-build-node.steps",
"mesh-build-node.toolchains",
"mesh-delivery-indexer.candidates",
@@ -23,6 +24,8 @@
"invokes": [
"mesh-build-go.build_go_build",
"mesh-build-go.build_go_fetch",
"mesh-build-image.build_image_build",
"mesh-build-image.build_image_fetch",
"mesh-build-node.build_node_build",
"mesh-build-node.build_node_fetch"
],
@@ -59,11 +59,52 @@ func Tools(r check.Reader) []stdio.Tool {
kept, err := r.Kept(level, digest)
return map[string]any{"kept": kept}, err
}},
{Name: check.PreviewsReadTool,
Description: "The merge previews the checker holds, each as stored under previews.<repository>.<head>.<trunk>: the merge commit, per module directory " +
"its tree, manifest digest and lock hashes, or the files in conflict. With a repository, that repository's; with a head (7 or more characters) " +
"only that head's. Keys that are no preview key are listed as unreadable. Read-only.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"repository": map[string]any{"type": "string", "description": "<owner>/<name>"},
"head": map[string]any{"type": "string", "description": "a pull request head commit, or a prefix of at least 7 characters"}}},
Run: func(args map[string]any) (any, error) {
repository, _ := args["repository"].(string)
head, _ := args["head"].(string)
if head != "" && len(head) < 7 {
return nil, fmt.Errorf("the head %q is too short: give at least 7 characters", head)
}
ps, bad, err := r.Previews(repository, head)
return map[string]any{"previews": ps, "unreadable": bad}, err
}},
{Name: check.StatusReadTool,
Description: "What holds the checker back and what runs: the capacity per node (the setting, the default one step per node), whether the controller's " +
"delivery plans in their build phase hold a key (no new check starts while one does), the steps in flight on the build modules by node, and the " +
"candidates without a verdict for their heads. The steps the checker holds waiting for a place are in its process, not in a bucket, and the " +
"answer says so. Read-only.",
Input: map[string]any{"type": "object", "properties": map[string]any{}},
Run: func(map[string]any) (any, error) { return r.Status() }},
}
}
func capacity() (string, error) {
path := os.Getenv("MESH_CHECKER_SETTINGS")
if path == "" {
return os.Getenv("MESH_CHECKER_CAPACITY"), nil
}
raw, err := os.ReadFile(path)
if err != nil {
return "", err
}
var s struct {
Capacity string `json:"capacity"`
}
return s.Capacity, json.Unmarshal(raw, &s)
}
func main() {
r := check.Reader{VerdictState: read(stdio.State("verdicts")), KeptState: read(stdio.State("kept")), CandidateState: read(stdio.State("mesh-delivery-indexer.candidates"))}
r := check.Reader{VerdictState: read(stdio.State("verdicts")), KeptState: read(stdio.State("kept")), CandidateState: read(stdio.State("mesh-delivery-indexer.candidates")),
PreviewState: read(stdio.State("previews")), PlansState: read(stdio.State("mesh-controller.plans-building")),
StepStates: map[string]check.StateRead{"mesh-build-go": read(stdio.State("mesh-build-go.steps")), "mesh-build-node": read(stdio.State("mesh-build-node.steps"))},
Capacity: capacity}
if err := stdio.Serve("", Tools(r)); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-checker-tools] %v\n", err)
os.Exit(1)
@@ -1,12 +1,13 @@
package main
import (
"slices"
"testing"
"mesh-delivery-checker/internal/check"
)
func TestTheToolsServedAreTheThreeReadTools(t *testing.T) {
func TestTheToolsServedAreTheFiveReadTools(t *testing.T) {
var names []string
for _, tl := range Tools(check.Reader{}) {
names = append(names, tl.Name)
@@ -14,12 +15,15 @@ func TestTheToolsServedAreTheThreeReadTools(t *testing.T) {
t.Errorf("%s has no description or no run", tl.Name)
}
}
want := []string{check.VerdictsReadTool, check.PullReadTool, check.KeptReadTool}
if len(names) != 3 || names[0] != want[0] || names[1] != want[1] || names[2] != want[2] {
want := []string{check.VerdictsReadTool, check.PullReadTool, check.KeptReadTool, check.PreviewsReadTool, check.StatusReadTool}
if !slices.Equal(names, want) {
t.Errorf("tools = %v, want %v", names, want)
}
// With nothing to read from a tool says so and changes nothing.
for _, tl := range Tools(check.Reader{}) {
if tl.Name == check.StatusReadTool {
continue // it answers from what it can read, and says what it could not
}
if _, err := tl.Run(map[string]any{"repository": "novox/x", "number": float64(1)}); err == nil {
t.Errorf("%s answered with no state to read", tl.Name)
}
@@ -4,7 +4,10 @@ import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"mesh-delivery-checker/internal/check"
)
// The forge's tools the checker reads with (mesh-catalog modules/gitea): gitea_get_file answers {file: {content}}, the text
@@ -13,8 +16,12 @@ import (
const (
getFileTool = "gitea.gitea_get_file"
compareTool = "gitea.gitea_compare"
commitsTool = "gitea.gitea_commits"
)
// notFoundStatus is the 404 of the forge's error, `Gitea API <path>: <status> <body>`.
var notFoundStatus = regexp.MustCompile(`: 404( |$)`)
// forgeReads reads the forge through call (mesh-sdk process.Conn.Call as the checker's own account).
type forgeReads struct {
call func(key string, body any) (json.RawMessage, error)
@@ -30,6 +37,11 @@ func (f forgeReads) File(_ context.Context, repository, path, ref string) (strin
owner, repo := ownerRepo(repository)
raw, err := f.call(getFileTool, map[string]string{"owner": owner, "repo": repo, "path": path, "ref": ref})
if err != nil {
if notFoundStatus.MatchString(err.Error()) {
// The forge answers 404 for a file the ref does not hold (issue 1845). The status is matched in its own place,
// after the path's colon: the path carries a sha that may hold the digits 404.
return "", fmt.Errorf("%w: %v", check.ErrNotFound, err)
}
return "", err
}
var a struct {
@@ -59,3 +71,21 @@ func (f forgeReads) OnTrunk(_ context.Context, repository, commit string) (bool,
}
return *a.Total == 0, nil
}
// FullSHA is the whole sha of a commit named by its first digits: the newest commit of the history at that ref.
func (f forgeReads) FullSHA(_ context.Context, repository, commit string) (string, error) {
owner, repo := ownerRepo(repository)
raw, err := f.call(commitsTool, map[string]any{"owner": owner, "repo": repo, "ref": commit, "limit": 1})
if err != nil {
return "", err
}
var a struct {
Commits []struct {
SHA string `json:"sha"`
} `json:"commits"`
}
if err := json.Unmarshal(raw, &a); err != nil || len(a.Commits) == 0 || len(a.Commits[0].SHA) != 40 || !strings.HasPrefix(a.Commits[0].SHA, commit) {
return "", fmt.Errorf("the answer of %s for %s is not a commit that starts with it: %v", commitsTool, commit, err)
}
return a.Commits[0].SHA, nil
}
@@ -3,10 +3,13 @@ package main
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"mesh-delivery-checker/internal/check"
)
// capturedAnswer is what the forge's tool answered (internal/check/testdata/captured/README).
@@ -53,3 +56,33 @@ func TestTheForgeReadsParseWhatTheForgeAnswered(t *testing.T) { // 1624/AC4
t.Error("an answer with no total was taken")
}
}
func TestTheForgeNamesTheWholeShaOfAShortCommit(t *testing.T) { // review D2
f := forgeReads{call: func(key string, body any) (json.RawMessage, error) {
if key != commitsTool {
t.Errorf("called %s", key)
}
return capturedAnswer(t, "forge-commits"), nil
}}
if sha, err := f.FullSHA(context.Background(), "novox/mesh-sdk", "cb54756c3865"); err != nil || sha != "cb54756c3865df5ac9c0dc0481ad9ebe2bf013a1" {
t.Errorf("sha = %q, %v", sha, err)
}
if _, err := f.FullSHA(context.Background(), "novox/mesh-sdk", "deadbeefdead"); err == nil {
t.Error("a commit that does not start with the pin was taken")
}
}
func TestAFileTheRefDoesNotHoldIsNotFoundAndOtherFailuresAreNot(t *testing.T) { // 1845/AC1, AC4
f := forgeReads{call: func(string, any) (json.RawMessage, error) {
return nil, errors.New("Gitea API contents/checks/particulars/go.mod?ref=98ca97fc: 404 object does not exist")
}}
if _, err := f.File(context.Background(), "novox/mesh-catalog", "checks/particulars/go.mod", "98ca97fc"); !errors.Is(err, check.ErrNotFound) {
t.Errorf("a 404 is %v, want ErrNotFound", err)
}
f = forgeReads{call: func(string, any) (json.RawMessage, error) {
return nil, errors.New("Gitea API contents/go.mod?ref=1111111111111111111111111111111404abcde: 500 internal error")
}}
if _, err := f.File(context.Background(), "novox/mesh-catalog", "go.mod", "main"); err == nil || errors.Is(err, check.ErrNotFound) {
t.Errorf("a 500 is %v, want a failure that is not ErrNotFound", err)
}
}
@@ -0,0 +1,26 @@
package main
import (
"context"
"encoding/json"
"errors"
"github.com/nats-io/nats.go"
"mesh-delivery-checker/internal/check"
)
// heardCaller is the build runner's caller with the calls that reached nobody marked: no responders, or none answering within
// the call's bound. The step never began there, so the check may give it to another node (issue 1820). A call that was
// answered, with a failure or not, is left as it is.
type heardCaller struct {
call func(node, key string, body any) (json.RawMessage, error)
}
func (h heardCaller) CallOn(node, key string, body any) (json.RawMessage, error) {
raw, err := h.call(node, key, body)
if errors.Is(err, nats.ErrNoResponders) || errors.Is(err, nats.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) {
return nil, &check.Unheard{Err: err}
}
return raw, err
}
@@ -0,0 +1,30 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"testing"
"github.com/nats-io/nats.go"
"mesh-delivery-checker/internal/check"
)
func TestACallThatReachedNobodyIsMarkedAndAnAnsweredFailureIsNot(t *testing.T) { // 1820/AC6
for _, err := range []error{nats.ErrNoResponders, nats.ErrTimeout, context.DeadlineExceeded} {
h := heardCaller{call: func(string, string, any) (json.RawMessage, error) { return nil, fmt.Errorf("call: %w", err) }}
_, got := h.CallOn("g14", "m.t", nil)
var u *check.Unheard
if !errors.As(got, &u) {
t.Errorf("%v: %v is not marked unheard", err, got)
}
}
h := heardCaller{call: func(string, string, any) (json.RawMessage, error) { return nil, errors.New("the module refused") }}
_, got := h.CallOn("g14", "m.t", nil)
var u *check.Unheard
if errors.As(got, &u) {
t.Errorf("an answered failure %v is marked unheard", got)
}
}
@@ -11,15 +11,18 @@ import (
"mesh-delivery-checker/internal/check"
)
// holdWatch holds the checker back while a delivery builds (ADR 0484 clause 12): it watches the controller's bucket of the
// delivery plans in their build phase and reads it as empty where it is absent (issue 1652 makes it) or cannot be read,
// saying so once. The bucket may come into being after the checker started and the checker holds no timer (ADR 0437 §8), so
// each time a check is about to start and the bucket is not watched yet, the checker looks for it.
// holdWatch holds the checker back while a delivery builds (ADR 0484 clause 12): whether one does is the list of the keys of
// the controller's bucket of the delivery plans in their build phase, read each time a check is about to start. Its keys age
// out (a time to live, and no delete marker), which a watch never hears, so a watch cannot decide it (mesh-controller
// internal/link/holds.go lists the keys too); the watch is only the wake-up, for a plan that ended by a delete. The bucket
// is read as empty where it is absent (issue 1652 makes it) or cannot be read, saying so once. It may come into being after
// the checker started and the checker holds no timer (ADR 0437 §8), so each time a check is about to start and the bucket is
// not known yet, the checker looks for it.
type holdWatch struct {
ctx context.Context
js bucketOpener
hold check.PlanHold
changed func(ctx context.Context) // the last plan ended: the checks that waited may start
keys func(ctx context.Context) ([]string, error)
changed func(ctx context.Context) // a plan key changed: the checks that waited may start
logger *log.Logger
errs chan error
mu sync.Mutex
@@ -34,23 +37,37 @@ func newHoldWatch(ctx context.Context, js bucketOpener, changed func(context.Con
// Held says whether a delivery builds now.
func (w *holdWatch) Held() bool {
w.ensure()
return w.hold.Held()
w.mu.Lock()
keys := w.keys
w.mu.Unlock()
if keys == nil {
return false
}
list, err := keys(w.ctx)
if err != nil {
w.say("the keys of %s cannot be listed, so it is read as empty: %v", check.PlansBuildingBucket, err)
return false
}
return len(list) > 0
}
func (w *holdWatch) say(format string, args ...any) {
w.mu.Lock()
defer w.mu.Unlock()
if !w.said {
w.said = true
w.logger.Printf(format, args...)
}
}
// ensure starts the watch if the bucket exists and is not watched.
// ensure finds the bucket if it is not known and exists, and starts the wake-up watch.
func (w *holdWatch) ensure() {
w.mu.Lock()
defer w.mu.Unlock()
if w.watched {
if w.watched || w.js == nil {
w.mu.Unlock()
return
}
w.mu.Unlock()
kv, err := w.js.KeyValue(w.ctx, check.PlansBuildingBucket)
switch {
case errors.Is(err, jetstream.ErrBucketNotFound):
@@ -60,17 +77,25 @@ func (w *holdWatch) ensure() {
w.say("the bucket %s cannot be read, so it is read as empty: %v", check.PlansBuildingBucket, err)
return
}
watcher, err := settle(w.ctx, kv, func(e jetstream.KeyValueEntry) { w.hold.Put(e.Key(), !opOf(e)) })
if err != nil {
w.say("the bucket %s cannot be watched, so it is read as empty: %v", check.PlansBuildingBucket, err)
w.mu.Lock()
defer w.mu.Unlock()
if w.watched {
return
}
w.watched, w.said = true, false
w.keys = func(ctx context.Context) ([]string, error) {
list, err := kv.Keys(ctx)
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return list, err
}
go func() {
w.errs <- follow(w.ctx, watcher, func(e jetstream.KeyValueEntry) {
if w.hold.Put(e.Key(), !opOf(e)) && !w.hold.Held() {
w.changed(w.ctx)
}
})
wake, err := kv.WatchAll(w.ctx)
if err != nil {
w.errs <- err
return
}
w.errs <- follow(w.ctx, wake, func(jetstream.KeyValueEntry) { w.changed(w.ctx) })
}()
}
@@ -0,0 +1,35 @@
package main
import (
"context"
"errors"
"io"
"log"
"testing"
)
// The controller's plans-building keys age out of the bucket (a time to live, no delete marker), which a watch never hears:
// whether a delivery builds is the list of the keys now, and the watch is only a wake-up (review D3).
func TestADeliveryHoldsWhileItsKeyIsListedAndNotWhenItAgedOutWithNoEvent(t *testing.T) {
listed := []string{"plan-1"}
w := &holdWatch{watched: true, logger: log.New(io.Discard, "", 0), keys: func(context.Context) ([]string, error) { return listed, nil }}
if !w.Held() {
t.Fatal("a plan key is listed and nothing holds")
}
listed = nil // the key aged out: no delete reached the watch
if w.Held() {
t.Error("the key is gone from the bucket and the checker is still held")
}
}
func TestABucketThatCannotBeListedIsReadAsEmpty(t *testing.T) {
w := &holdWatch{watched: true, logger: log.New(io.Discard, "", 0), keys: func(context.Context) ([]string, error) { return []string{"plan-1"}, errors.New("the bus is down") }}
if w.Held() {
t.Error("an unreadable bucket held the checker")
}
var absent holdWatch
absent.logger = log.New(io.Discard, "", 0)
if absent.Held() {
t.Error("a watch with no bucket held the checker")
}
}
@@ -31,7 +31,7 @@ type judgeDeps struct {
func newJudge(d judgeDeps) check.Judge {
return &check.Examiner{Steps: d.runner, Toolchains: d.tools, Presence: d.presence, Health: d.have, Places: d.places,
Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Forge: d.forge, Builds: d.builds, Announce: d.announce, Trunks: d.trunks, Logf: d.logf}
Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Forge: d.forge, Runs: d.repos, Builds: d.builds, Announce: d.announce, Trunks: d.trunks, Logf: d.logf}
}
// manifestKV reads a manifest by its digest.
@@ -49,6 +49,7 @@ type repoModules struct {
mu sync.Mutex
langs map[string]map[string]bool // by manifest digest: the languages of its artifacts
repoOf map[string]string // by module: its repository
commit map[string]string // by module: the commit its record was computed at (the commit the mesh runs)
typed map[string]string // by module: its manifest digest
}
@@ -56,11 +57,12 @@ type repoModules struct {
func (r *repoModules) Put(ctx context.Context, key string, value []byte, deleted bool) {
r.mu.Lock()
if r.repoOf == nil {
r.repoOf, r.typed, r.langs = map[string]string{}, map[string]string{}, map[string]map[string]bool{}
r.repoOf, r.typed, r.langs, r.commit = map[string]string{}, map[string]string{}, map[string]map[string]bool{}, map[string]string{}
}
if deleted {
delete(r.repoOf, key)
delete(r.typed, key)
delete(r.commit, key)
r.mu.Unlock()
return
}
@@ -72,6 +74,7 @@ func (r *repoModules) Put(ctx context.Context, key string, value []byte, deleted
r.mu.Lock()
r.repoOf[key] = m.Repository
r.typed[key] = m.Manifest
r.commit[key] = m.Commit
_, known := r.langs[m.Manifest]
r.mu.Unlock()
if m.Manifest == "" || known {
@@ -128,3 +131,10 @@ func (r buildReader) Build(ctx context.Context, module, fp2 string) (delivery.Bu
b, err := delivery.DecodeBuilderBuild(value)
return b, err == nil, err
}
// CommitOf is the commit the indexer's record of the module was computed at: the commit of its repository the mesh runs.
func (r *repoModules) CommitOf(module string) string {
r.mu.Lock()
defer r.mu.Unlock()
return r.commit[module]
}
@@ -66,6 +66,9 @@ func TestARepositoryWithATypeScriptModuleIsJudgedByTheNodeBuildModuleToo(t *test
if got := r.BuildModules("novox/mesh-catalog"); strings.Join(got, ",") != buildrun.GoBuildModule+","+buildrun.NodeBuildModule {
t.Errorf("mesh-catalog: %v, want both", got)
}
if got := r.CommitOf("mesh-delivery-checker"); got != strings.Repeat("a", 40) || r.CommitOf("gone") != "" {
t.Errorf("the commit the mesh runs = %q", got)
}
if got := r.BuildModules("novox/mesh-sdk"); strings.Join(got, ",") != buildrun.GoBuildModule {
t.Errorf("a repository with no module: %v, want the Go build module", got)
}
@@ -189,7 +189,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
have := &buildrun.HaveSet{}
trunks := &check.Trunks{}
places := &buildrun.Places{Capacity: capacity}
runner := buildrun.Runner{Caller: caller, Steps: stepsWaiter{js}, Bound: stepBound}
runner := buildrun.Runner{Caller: heardCaller{caller.CallOn}, Steps: stepsWaiter{js}, Bound: stepBound}
ch := &check.Checker{
Verdicts: kvStore{buckets[delivery.CheckerVerdictsBucket]},
Owed: kvStore{buckets[delivery.CheckerOwedBucket]},
@@ -1,6 +1,10 @@
package check
import "strconv"
import (
"context"
"fmt"
"strconv"
)
// BesideRef is a sibling repository a repository's tests read, placed beside the fetched tree as a plain directory (ADR
// 0484 clause 4). Ref names the pull request's head when the commit is only there.
@@ -10,37 +14,74 @@ type BesideRef struct {
Ref string `json:"ref,omitempty"`
}
const (
catalogueRepository = "novox/mesh-catalog"
nodeEngineRepo = "novox/mesh-host"
nodeEngineModule = "mesh-host"
controllerRepo = "novox/mesh-controller"
)
// besideRules are the repositories each core repository's tests read, as the tests state it (mesh-sdk go/internal/beside
// beside.go): the catalogue at its trunk, the node-engine, the SDK as the controller pins it, and the delivery's pinned
// forge dependencies (mesh-catalog checks/deps). The checker chooses which sibling at which commit; the build module only
// places what it is told.
// beside.go): the catalogue, the node-engine, the SDK, and the delivery's pinned forge dependencies (mesh-catalog
// checks/deps). Which commit of each is besideCommit's rule.
var besideRules = map[string][]string{
"novox/mesh-controller": {"novox/mesh-catalog", "novox/mesh-host", "novox/mesh-sdk"},
"novox/mesh-host": {"novox/mesh-catalog", "novox/mesh-sdk"},
"novox/mesh-sdk": {"novox/mesh-catalog", "novox/mesh-host"},
"novox/mesh-catalog": {"novox/mesh-sdk", "novox/mesh-controller"},
"novox/mesh-delivery": {"novox/mesh-sdk", "novox/mesh-catalog"},
controllerRepo: {catalogueRepository, nodeEngineRepo, SDKRepository},
nodeEngineRepo: {catalogueRepository, SDKRepository},
SDKRepository: {catalogueRepository, nodeEngineRepo},
catalogueRepository: {SDKRepository, controllerRepo},
"novox/mesh-delivery": {SDKRepository, catalogueRepository},
}
// Beside are the siblings a pull request's tests read: each at its trunk head, and at the head of the group's pull request
// when the group holds one of that repository (a group's members are checked together, ADR 0482 clause 2) — except the
// catalogue, which the tests read at its trunk even when the group holds a catalogue pull request (beside.go).
func (j *Examiner) Beside(c *Case, p Pull) []BesideRef {
// besideFor are the siblings a pull request's tests read (ADR 0484 clause 4): the catalogue at its trunk, always, even in a
// group holding a catalogue pull request; the node-engine at the commit the mesh runs (the commit of the indexer's module
// mesh-host, its trunk head when none is known); mesh-sdk, for the controller's tests, at the commit the controller's go.mod
// pins; any other sibling at the head of the group's pull request for it when the group holds one, else at its trunk.
func (j *Examiner) besideFor(ctx context.Context, c *Case, p Pull) ([]BesideRef, error) {
var heads map[string]string
if j.Trunks != nil {
heads = j.Trunks.Heads()
}
var out []BesideRef
for _, repo := range besideRules[p.Repository] {
if member := c.member(repo); member != nil && repo != "novox/mesh-catalog" {
out = append(out, BesideRef{Repository: repo, Commit: member.Head, Ref: refOf(member.Number)})
continue
}
if head := heads[repo]; head != "" {
out = append(out, BesideRef{Repository: repo, Commit: head})
switch {
case repo == catalogueRepository:
if head := heads[repo]; head != "" {
out = append(out, BesideRef{Repository: repo, Commit: head})
}
case repo == nodeEngineRepo:
commit := ""
if j.Runs != nil {
commit = j.Runs.CommitOf(nodeEngineModule)
}
if commit == "" {
commit = heads[repo]
}
if commit != "" {
out = append(out, BesideRef{Repository: repo, Commit: commit})
}
case repo == SDKRepository && p.Repository == controllerRepo && j.Forge != nil:
text, err := j.Forge.File(ctx, p.Repository, "go.mod", p.Head)
if err != nil {
return nil, fmt.Errorf("go.mod of %s#%d cannot be read to place the mesh-sdk its tests read: %w", p.Repository, p.Number, err)
}
pin, ok := SDKPin(text)
if !ok {
return nil, fmt.Errorf("go.mod of %s#%d names no mesh-sdk commit, so the mesh-sdk its tests read cannot be placed", p.Repository, p.Number)
}
full, err := j.Forge.FullSHA(ctx, SDKRepository, pin)
if err != nil {
return nil, fmt.Errorf("mesh-sdk %s, which %s#%d pins, cannot be found: %w", pin, p.Repository, p.Number, err)
}
out = append(out, BesideRef{Repository: repo, Commit: full})
default:
if member := c.member(repo); member != nil {
out = append(out, BesideRef{Repository: repo, Commit: member.Head, Ref: refOf(member.Number)})
} else if head := heads[repo]; head != "" {
out = append(out, BesideRef{Repository: repo, Commit: head})
}
}
}
return out
return out, nil
}
func (c *Case) member(repository string) *Pull {
@@ -56,7 +97,5 @@ func refOf(number int) string {
if number <= 0 {
return ""
}
return "refs/pull/" + itoa(number) + "/head"
return "refs/pull/" + strconv.Itoa(number) + "/head"
}
func itoa(n int) string { return strconv.Itoa(n) }
@@ -150,7 +150,7 @@ func caseOf(t testing.TB, pulls ...delivery.CandidatePull) *Case {
if err != nil {
t.Fatal(err)
}
cs := CaseOf(c, map[string]string{repoA: trunkA, repoSDK: trunkS})
cs := CaseOf(c, map[string]string{repoA: trunkA, repoSDK: trunkS, "novox/mesh-controller": trunkA, "novox/mesh-catalog": trunkA})
if cs == nil {
t.Fatal("no case")
}
@@ -552,3 +552,36 @@ func TestTheOutcomeSaysIfEachCandidatesModuleIsBuiltFailedOrWaiting(t *testing.T
t.Errorf("builds = %v, want one note for each module that has a fingerprint, sorted", got)
}
}
// trunksOf are trunk heads of every repository, as the gate's source names them.
func trunksOf(t *testing.T, heads map[string]string) *Trunks {
t.Helper()
data, err := delivery.EncodeSource(delivery.Source{Group: "g", Heads: heads})
if err != nil {
t.Fatal(err)
}
tr := &Trunks{}
tr.Put(data, false)
return tr
}
func TestNothingIsKeptForAWorkspaceGivenSiblingsBeside(t *testing.T) { // review D1, ADR 0444, issue 1287
b := newBench(t)
b.ex.Trunks = trunksOf(t, map[string]string{repoSDK: trunkS, "novox/mesh-catalog": trunkA})
b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
if got := len(b.steps.tool("fetch")); got != 1 || b.steps.tool("fetch")[0].args["beside"] == nil {
t.Fatalf("the fetch was not given siblings: %+v", b.steps.tool("fetch"))
}
if keys, _ := b.kept.Keys(context.Background()); len(keys) != 0 {
t.Errorf("kept %v for a package whose tests may read a sibling the digest does not cover", keys)
}
// And a kept verdict of the same digest from elsewhere is not taken for it: everything runs.
if _, err := (Kept{Store: b.kept}).Keep(context.Background(), "unit", digA, keptValue("fixture/slow", 2)); err != nil {
t.Fatal(err)
}
b.steps.calls = nil
b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
if n := len(b.steps.tool("test")); n != 2 {
t.Errorf("%d test steps, want both levels run whole", n)
}
}
@@ -0,0 +1,101 @@
package check
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/nats-io/nats.go"
)
// Unheard is the error of a call that reached nobody: the bus had no responder for it, or none answered within the call's
// bound. The step it asked for never began, so the same step may be asked of another node (issue 1820). The program wraps
// the errors of its calls in it where the bus gives a timeout; a no-responders error is known as it is.
type Unheard struct{ Err error }
func (u *Unheard) Error() string { return u.Err.Error() }
func (u *Unheard) Unwrap() error { return u.Err }
// isUnheard says whether err is a call that reached nobody. A step that began and failed, or ended failed, is not.
func isUnheard(err error) bool {
var u *Unheard
return errors.As(err, &u) || errors.Is(err, nats.ErrNoResponders)
}
// deafNode is a node that did not answer a build module's call during this judging.
type deafNode struct{ node, why string }
// markDeaf takes a node out of the choice for a build module for the rest of the judging.
func (st *state) markDeaf(bm, node string, err error) {
if st.deaf == nil {
st.deaf = map[string][]deafNode{}
}
st.deaf[bm] = append(st.deaf[bm], deafNode{node, err.Error()})
}
func (st *state) isDeaf(bm, node string) bool {
for _, d := range st.deaf[bm] {
if d.node == node {
return true
}
}
return false
}
// triedWords names every node that did not answer a build module, with what the bus said.
func (st *state) triedWords(bm string) string {
var parts []string
for _, d := range st.deaf[bm] {
parts = append(parts, fmt.Sprintf("%s (%s)", d.node, d.why))
}
return strings.Join(parts, "; ")
}
// moveStep is called when the target's node did not answer: the node is left out of the choice, and another build node of the
// same build module takes the target's place. It answers an error naming every node tried when none is left (the words hold
// NodesWords, so the verdict is judged again when the nodes change).
func (st *state) moveStep(t *target, err error) error {
old := t.node
st.markDeaf(t.module, old, err)
node, _, cerr := st.choose(t.module)
if cerr != nil {
return fmt.Errorf("%s; tried: %s", cerr, st.triedWords(t.module))
}
st.j.logf("%s#%d: %s did not answer (%v), the step goes to %s", t.pull.Repository, t.pull.Number, old, err, node)
t.node = node
other := false
for _, o := range st.targets {
if o != t && o.node == old {
other = true
}
}
if !other {
delete(st.nodes, old)
}
st.nodes[node] = true
return nil
}
// refetch makes the target's workspace again on its new node: the workspace of a step that was lost is on the node that did
// not answer.
func (st *state) refetch(ctx context.Context, t *target) error {
raw, err := st.step(ctx, t, "fetch", fmt.Sprintf("fetch %s#%d on %s", t.pull.Repository, t.pull.Number, t.node), t.fetchArgs)
if err != nil {
return err
}
var f Fetched
if err := json.Unmarshal(raw, &f); err != nil || f.Workspace == "" {
return fmt.Errorf("the fetch of %s#%d on %s answered no workspace: %s", t.pull.Repository, t.pull.Number, t.node, raw)
}
t.fetched = f
return nil
}
// stepOnWorkspace sets the workspace a step reads to the target's current one.
func stepOnWorkspace(t *target, args map[string]any) {
if _, ok := args["workspace"]; ok {
args["workspace"] = t.fetched.Workspace
}
}
@@ -0,0 +1,142 @@
package check
import (
"context"
"fmt"
"strings"
"testing"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Issue 1820: a build step goes to a node whose build module is not heard (a laptop asleep, not yet marked offline by the
// watchman), and the bus answers "no responders". The check moves the step to another node of the same build module.
// deafNodes makes every tool on the named nodes fail as the bus does when nobody listens there; count says how many
// times the node was called.
func deafNodes(b *bench, deaf ...string) {
for tool, h := range b.steps.handlers {
h := h
b.steps.handlers[tool] = func(c stepCall) (any, error) {
for _, n := range deaf {
if c.node == n {
return nil, fmt.Errorf("%s on %s: %w", c.tool, c.node, nats.ErrNoResponders)
}
}
return h(c)
}
}
}
func nodesOf(calls []stepCall) []string {
var out []string
for _, c := range calls {
out = append(out, c.node)
}
return out
}
func TestAFetchThatNobodyAnswersMovesToAnotherBuildNode(t *testing.T) { // 1820/AC1
b := newBench(t)
b.ex.Toolchains = goEntries("ace", "g14")
deafNodes(b, "ace")
v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
if v.State != delivery.VerdictPass {
t.Fatalf("verdict = %+v, want a pass on the node that answers", v)
}
if got := nodesOf(b.steps.tool("fetch")); len(got) != 2 || got[0] != "ace" || got[1] != "g14" {
t.Errorf("fetch went to %v, want ace (no responders) then g14", got)
}
for _, c := range b.steps.tool("test") {
if c.node != "g14" {
t.Errorf("a test went to %s, want g14 only", c.node)
}
}
if len(v.Machines) != 1 || v.Machines[0] != "g14" {
t.Errorf("machines = %v, want [g14]: ace ran nothing", v.Machines)
}
}
func TestATestStepThatNobodyAnswersIsFetchedAgainOnAnotherNode(t *testing.T) { // 1820/AC2
b := newBench(t)
b.ex.Toolchains = goEntries("ace", "g14")
// ace answers the fetch and the digest, then goes to sleep before the test.
test := b.steps.handlers["build_go_test"]
b.steps.handlers["build_go_test"] = func(c stepCall) (any, error) {
if c.node == "ace" {
return nil, fmt.Errorf("test on ace: %w", nats.ErrNoResponders)
}
return test(c)
}
v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
if v.State != delivery.VerdictPass {
t.Fatalf("verdict = %+v, want a pass", v)
}
if got := nodesOf(b.steps.tool("fetch")); len(got) != 2 || got[1] != "g14" {
t.Errorf("fetch went to %v, want ace then g14 (the workspace was on ace)", got)
}
for _, c := range b.steps.tool("test") {
if c.node == "g14" && c.args["workspace"] == nil {
t.Errorf("a test on g14 has no workspace")
}
}
if len(v.Machines) != 1 || v.Machines[0] != "g14" {
t.Errorf("machines = %v, want [g14]", v.Machines)
}
}
func TestWhenNoBuildNodeAnswersTheFailureNamesEveryNodeTried(t *testing.T) { // 1820/AC3
b := newBench(t)
b.ex.Toolchains = goEntries("ace", "g14", "shanks")
deafNodes(b, "ace", "g14", "shanks")
v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
if v.State != delivery.VerdictFail {
t.Fatalf("verdict = %+v, want a failure", v)
}
for _, n := range []string{"ace", "g14", "shanks"} {
if !strings.Contains(v.Failed.Words, n) {
t.Errorf("failure %q does not name %s", v.Failed.Words, n)
}
}
if !IsNodesFailure(v) {
t.Errorf("failure %q is not a failure of the nodes, so it would not be judged again when they change", v.Failed.Words)
}
if got := len(b.steps.tool("fetch")); got != 3 {
t.Errorf("fetch was tried %d times, want once on each of 3 nodes", got)
}
}
func TestAStepThatFailedForTheChangeIsNotMovedToAnotherNode(t *testing.T) { // 1820/AC4
b := newBench(t)
b.ex.Toolchains = goEntries("ace", "g14")
b.steps.handlers["build_go_fetch"] = func(stepCall) (any, error) { return nil, fmt.Errorf("fetch on ace: the step failed: git exploded") }
v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
if v.State != delivery.VerdictFail || len(b.steps.tool("fetch")) != 1 {
t.Errorf("verdict = %+v after %d fetches, want a failure after one", v, len(b.steps.tool("fetch")))
}
}
func TestACanceledCheckIsNotMovedToAnotherNode(t *testing.T) { // 1820/AC4
b := newBench(t)
b.ex.Toolchains = goEntries("ace", "g14")
ctx, cancel := context.WithCancel(context.Background())
b.steps.handlers["build_go_fetch"] = func(stepCall) (any, error) {
cancel()
return nil, fmt.Errorf("fetch on ace: %w", nats.ErrNoResponders)
}
_, _ = b.ex.Judge(ctx, caseOf(t, pullOf(repoA, 7, head1)))
if n := len(b.steps.tool("fetch")); n != 1 {
t.Errorf("fetch was tried %d times after the check ended, want 1", n)
}
}
func TestAPinReadThatNobodyAnswersIsTheForgesFailureNotABuildNodes(t *testing.T) { // 1820/AC5
b := groupBench(t, "cb54756c3865")
b.ex.Forge.(*fakeForge).err = fmt.Errorf("gitea.gitea_get_file: %w", nats.ErrNoResponders)
v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1), pullOf(repoSDK, 12, head2))).Verdict
if v.State != delivery.VerdictFail || v.Failed.Machine != "forge" {
t.Errorf("verdict = %+v, want a failure at the forge: no build node was to blame", v)
}
}
@@ -1,40 +1,7 @@
package check
import "sync"
// PlansBuildingBucket is the controller's bucket of the delivery plans in their build phase, one key for each plan, written
// by the controller alone (issue 1652): a plan key there means a delivery builds on the build nodes, and no new check starts
// meanwhile (ADR 0484 clause 12, ADR 0483 clause 6). The bucket may be absent: the controller's issue 1652 is delivered
// meanwhile (ADR 0484 clause 12, ADR 0483 clause 6). The keys age out with no delete marker, so the keys are listed, not watched. The bucket may be absent: the controller's issue 1652 is delivered
// before R4c-5b, and if it is not, the bucket is read as empty.
const PlansBuildingBucket = "mesh-controller_plans-building"
// PlanHold holds which delivery plans build now, from the controller's bucket (a watch puts the changes in). Safe for use
// from the watch and from a check at once.
type PlanHold struct {
mu sync.Mutex
plans map[string]bool
}
// Put takes a change of the bucket in: a put of a plan's key, or its delete. It answers whether a delivery builds now
// differently than before, so a watch wakes the checker when the last plan ended.
func (h *PlanHold) Put(key string, deleted bool) (changed bool) {
h.mu.Lock()
defer h.mu.Unlock()
before := len(h.plans) > 0
if deleted {
delete(h.plans, key)
} else {
if h.plans == nil {
h.plans = map[string]bool{}
}
h.plans[key] = true
}
return before != (len(h.plans) > 0)
}
// Held says whether any delivery plan builds now.
func (h *PlanHold) Held() bool {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.plans) > 0
}
@@ -7,39 +7,17 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
)
func TestADeliveryThatBuildsHoldsTheChecksAndTheEndOfTheLastPlanSaysSo(t *testing.T) { // N8/AC1
var h PlanHold
if h.Held() {
t.Fatal("an empty bucket holds")
}
if !h.Put("plan-1", false) || !h.Held() {
t.Error("a first plan did not hold")
}
if h.Put("plan-2", false) {
t.Error("a second plan changed whether a delivery builds")
}
if h.Put("plan-1", true) || !h.Held() {
t.Error("one of two plans ending released the hold")
}
if !h.Put("plan-2", true) || h.Held() {
t.Error("the last plan ending did not release the hold")
}
if h.Put("plan-3", true) {
t.Error("the delete of a plan never put changed the hold")
}
}
func TestARunningCheckIsNotStoppedWhenADeliveryStartsToBuild(t *testing.T) { // ADR 0484 clause 12, ADR 0483 clause 4
ctx := context.Background()
var h PlanHold
held := false
j := &judgeFunc{f: func(_ context.Context, _ *Case, call int) (Outcome, error) {
if call == 1 {
h.Put("plan-1", false) // a delivery starts while this check runs
held = true // a delivery starts while this check runs
}
return Outcome{Verdict: passing()}, nil
}}
r := newRig(t, j)
r.ch.Hold = h.Held
r.ch.Hold = func() bool { return held }
r.ch.Candidate(ctx, "feat/x", candidateOf(t, "feat/x", pullOf(repoA, 7, head1)))
r.ch.Wait()
if v := r.verdict(t, keyOf(t, "feat/x", candidateOf(t, "feat/x", pullOf(repoA, 7, head1)))); v.State != delivery.VerdictPass {
@@ -51,7 +29,7 @@ func TestARunningCheckIsNotStoppedWhenADeliveryStartsToBuild(t *testing.T) { //
if j.count() != 1 {
t.Errorf("judged %d times while a delivery builds, want only the one already running", j.count())
}
h.Put("plan-1", true)
held = false
r.ch.Reconsider(ctx)
r.ch.Wait()
if j.count() != 2 {
@@ -58,6 +58,8 @@ type Examiner struct {
// Previews is the previews bucket; nil writes none.
Previews KV
Modules BuildModules
// Runs says which commit the mesh runs of a module (the indexer's modules); nil knows none.
Runs interface{ CommitOf(module string) string }
// Forge reads the go.mod files of the Go members and says whether a mesh-sdk commit is on its trunk (ADR 0484 clause 9);
// nil checks no pin.
Forge Forge
@@ -92,9 +94,13 @@ type target struct {
node string
toolchain string
fetched Fetched
// fetchArgs are the arguments of the target's fetch, so a workspace lost with its node is made again on another.
fetchArgs map[string]any
// priority are the Go packages (import paths) or the Node module directories whose test files the merge changes.
changed []string
plans map[string]*goPlan // by level
// beside says the workspace was given siblings: its tests may read what the digest does not cover, so nothing is kept for it.
beside bool
plans map[string]*goPlan // by level
}
// goPlan is what a Go target has to run at one level: the digests, the packages without a kept verdict and the tests taken.
@@ -120,6 +126,7 @@ type state struct {
targets []*target
levels map[string]*acc
nodes map[string]bool
deaf map[string][]deafNode // by build module: the nodes that did not answer during this judging
clone time.Duration
slow []string
}
@@ -198,8 +205,28 @@ func fail(machine, words string) *delivery.Verdict {
return &v
}
// step runs one step of a build module on the node the target is on, holding a place there while it runs.
// step runs one step of a build module on the node the target is on, holding a place there while it runs. A node that does
// not answer (the bus has no responder, or none answered) is left out and the step goes to another node of the same build
// module; a step after the fetch makes its workspace again there first (issue 1820).
func (st *state) step(ctx context.Context, t *target, tool, label string, args map[string]any) (json.RawMessage, error) {
for {
raw, err := st.stepOnce(ctx, t, tool, label, args)
if err == nil || ctx.Err() != nil || !isUnheard(err) {
return raw, err
}
if merr := st.moveStep(t, err); merr != nil {
return nil, merr
}
if tool != "fetch" {
if rerr := st.refetch(ctx, t); rerr != nil {
return nil, rerr
}
stepOnWorkspace(t, args)
}
}
}
func (st *state) stepOnce(ctx context.Context, t *target, tool, label string, args map[string]any) (json.RawMessage, error) {
st.c.Progress.Set(label, t.node)
release, err := st.j.Places.Acquire(ctx, t.node)
if err != nil {
@@ -213,7 +240,7 @@ func (st *state) step(ctx context.Context, t *target, tool, label string, args m
func (st *state) choose(bm string) (node, toolchain string, err error) {
language := buildrun.LanguageOf[bm]
qualifies := func(node string) bool {
return (st.j.Presence == nil || st.j.Presence.Online(node)) && (st.j.Health == nil || st.j.Health.Healthy(node, bm))
return !st.isDeaf(bm, node) && (st.j.Presence == nil || st.j.Presence.Online(node)) && (st.j.Health == nil || st.j.Health.Healthy(node, bm))
}
nodes, tc, err := buildrun.ChooseNodes(language, st.j.Toolchains.Entries(language), st.j.Places, qualifies, 1)
if err != nil {
@@ -237,12 +264,19 @@ func (st *state) fetchAll(ctx context.Context) *delivery.Verdict {
t := &target{pull: p, module: bm, node: node, toolchain: tc, plans: map[string]*goPlan{}}
st.nodes[node] = true
args := map[string]any{"repository": p.Repository, "commit": p.Head, "ref": refOf(p.Number), "base": p.Trunk}
t.fetchArgs = args
if bm == buildrun.GoBuildModule {
if beside := st.j.Beside(st.c, p); len(beside) > 0 {
beside, err := st.j.besideFor(ctx, st.c, p)
if err != nil {
return fail(node, err.Error())
}
if len(beside) > 0 {
args["beside"] = beside
t.beside = true
}
}
raw, err := st.step(ctx, t, "fetch", fmt.Sprintf("fetch %s#%d on %s", p.Repository, p.Number, node), args)
node = t.node // the step may have moved to another node
if err != nil {
return fail(node, fmt.Sprintf("fetch of %s#%d: %v%s", p.Repository, p.Number, err, pinHint(st.c, err.Error())))
}
@@ -364,6 +398,12 @@ func (st *state) planGo(ctx context.Context, t *target, level string) *delivery.
}
plan := &goPlan{digests: map[string]string{}}
t.plans[level] = plan
if t.beside {
// The digest covers the package's own files, the toolchain and the header, not the siblings placed beside the tree: a
// package whose tests read one must never be kept, nor taken from a verdict kept for other inputs (ADR 0444).
plan.all = true
return nil
}
raw, err := st.step(ctx, t, "digest", fmt.Sprintf("digest the %s level of %s#%d on %s", level, t.pull.Repository, t.pull.Number, t.node),
map[string]any{"workspace": t.fetched.Workspace, "level": level})
var d Digested
@@ -2,6 +2,7 @@ package check
import (
"context"
"errors"
"fmt"
"path"
"regexp"
@@ -22,6 +23,10 @@ const (
// Issue1148 is why a pin that only a head carries fails the check.
const Issue1148 = "issue 1148"
// ErrNotFound is what Forge.File wraps when the ref holds no such file (the forge answered 404): a file the ref does not hold,
// told from a forge that could not be read (issue 1845).
var ErrNotFound = errors.New("the ref holds no such file")
// Forge reads a file of a repository at a ref, and says whether a commit is on a repository's trunk.
type Forge interface {
// File is the text of a file at a ref (a branch, a tag or a commit).
@@ -29,6 +34,8 @@ type Forge interface {
// OnTrunk says whether commit (a full or a 12-digit sha) is already merged into the trunk of the repository: it holds no
// commit the trunk does not.
OnTrunk(ctx context.Context, repository, commit string) (bool, error)
// FullSHA is the 40-digit sha of a commit named by its first 12 digits.
FullSHA(ctx context.Context, repository, commit string) (string, error)
}
var pseudoVersion = regexp.MustCompile(`-([0-9a-f]{12})$`)
@@ -49,8 +56,9 @@ func SDKPin(goMod string) (commit string, ok bool) {
}
// checkPins refuses a Go member whose go.mod names a mesh-sdk commit that is neither on mesh-sdk's trunk nor the head of the
// group's mesh-sdk pull request. It reads each go.mod the fetch named at the member's head from the forge. It answers nil
// when every pin is the group's.
// group's mesh-sdk pull request. It reads each go.mod the fetch named from the forge: the one the check builds, which is the
// head's when the head holds the file and the trunk's when it does not, since a module added on the trunk after the branch was
// cut is in the merge and not at the head (issue 1845). It answers nil when every pin is the group's.
func (st *state) checkPins(ctx context.Context) verdictPtr {
if st.j.Forge == nil {
return nil
@@ -64,8 +72,12 @@ func (st *state) checkPins(ctx context.Context) verdictPtr {
file := path.Join(dir, "go.mod")
st.c.Progress.Set(fmt.Sprintf("read %s of %s#%d", file, t.pull.Repository, t.pull.Number), t.node)
text, err := st.j.Forge.File(ctx, t.pull.Repository, file, t.pull.Head)
if errors.Is(err, ErrNotFound) && t.pull.Trunk != "" {
// Only a file the head does not hold is read at the trunk; any other failure fails the check as it did.
text, err = st.j.Forge.File(ctx, t.pull.Repository, file, t.pull.Trunk)
}
if err != nil {
return failed(t.node, fmt.Sprintf("%s#%d: %s cannot be read to check its mesh-sdk pin: %v", t.pull.Repository, t.pull.Number, file, err))
return failed(forgeMachine(err, t.node), fmt.Sprintf("%s#%d: %s cannot be read to check its mesh-sdk pin: %v", t.pull.Repository, t.pull.Number, file, err))
}
pin, ok := SDKPin(text)
if !ok {
@@ -76,7 +88,7 @@ func (st *state) checkPins(ctx context.Context) verdictPtr {
}
onTrunk, err := st.j.Forge.OnTrunk(ctx, SDKRepository, pin)
if err != nil {
return failed(t.node, fmt.Sprintf("%s#%d: whether mesh-sdk %s is on its trunk cannot be told: %v", t.pull.Repository, t.pull.Number, pin, err))
return failed(forgeMachine(err, t.node), fmt.Sprintf("%s#%d: whether mesh-sdk %s is on its trunk cannot be told: %v", t.pull.Repository, t.pull.Number, pin, err))
}
if onTrunk {
continue
@@ -102,3 +114,12 @@ func pinHint(c *Case, fetchError string) string {
}
return " (a pin to the head of the group's mesh-sdk pull request names a commit only that head carries, which go mod download cannot resolve; " + Issue1148 + ")"
}
// forgeMachine is the machine a failed read of the forge is put at: the forge when nobody answered it (no build node is to
// blame, issue 1820), the build node whose workspace was read otherwise.
func forgeMachine(err error, node string) string {
if isUnheard(err) {
return "forge"
}
return node
}
@@ -17,16 +17,27 @@ type fakeForge struct {
goMods map[string]string // `<repository> <path> <ref>` -> text
onTrunk map[string]bool // by commit prefix
err error
errAt map[string]error // by ref: a read at that ref fails with it
}
func (f *fakeForge) File(_ context.Context, repository, path, ref string) (string, error) {
if f.err != nil {
return "", f.err
}
if e := f.errAt[ref]; e != nil {
return "", e
}
if s, ok := f.goMods[repository+" "+path+" "+ref]; ok {
return s, nil
}
return "", fmt.Errorf("no file %s of %s at %s", path, repository, ref)
return "", fmt.Errorf("no file %s of %s at %s: %w", path, repository, ref, ErrNotFound)
}
func (f *fakeForge) FullSHA(_ context.Context, _ string, commit string) (string, error) {
if f.err != nil {
return "", f.err
}
return commit + strings.Repeat("0", 40-len(commit)), nil
}
func (f *fakeForge) OnTrunk(_ context.Context, _ string, commit string) (bool, error) {
@@ -145,3 +156,100 @@ func TestAPinThatCannotBeCheckedFailsAndNeverPasses(t *testing.T) { // ADR 0453
t.Errorf("verdict = %+v", v)
}
}
// trunkOnlyBench is a group of mesh-delivery#7 whose fetch holds two Go modules: modules/m, which the head holds, and
// modules/new, which only the trunk holds (added on the trunk after the branch was cut). The SDK pin of each go.mod is given.
func trunkOnlyBench(t *testing.T, headPin, trunkPin string) *bench {
b := newBench(t)
b.fetch.Modules = []string{"modules/m", "modules/new"}
forge := &fakeForge{goMods: map[string]string{
repoA + " modules/m/go.mod " + head1: capturedGoMod(t, headPin),
repoA + " modules/m/go.mod " + trunkA: capturedGoMod(t, headPin),
repoA + " modules/new/go.mod " + trunkA: capturedGoMod(t, trunkPin),
}, onTrunk: map[string]bool{"cb54756c3865": true}}
b.ex.Forge = forge
return b
}
func TestAModuleOnlyTheTrunkHoldsIsReadAtTheTrunkAndPasses(t *testing.T) { // 1845/AC1
b := trunkOnlyBench(t, "cb54756c3865", "cb54756c3865")
if v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict; v.State != delivery.VerdictPass {
t.Fatalf("verdict = %+v", v)
}
}
func TestAStalePinOfAModuleOnlyTheTrunkHoldsStillFailsNamingTheFileAndTheCommit(t *testing.T) { // 1845/AC2
b := trunkOnlyBench(t, "cb54756c3865", "9aaa5c80ac5c")
v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
if v.State != delivery.VerdictFail || !strings.Contains(v.Failed.Words, repoA+"#7") || !strings.Contains(v.Failed.Words, "modules/new/go.mod") || !strings.Contains(v.Failed.Words, "9aaa5c80ac5c") {
t.Fatalf("verdict = %+v", v)
}
}
func TestAModuleTheHeadHoldsIsJudgedByTheHeadsPin(t *testing.T) { // 1845/AC3
b := trunkOnlyBench(t, "9aaa5c80ac5c", "cb54756c3865") // the head's pin is stale, the trunk's go.mod of the same module is fine
b.ex.Forge.(*fakeForge).goMods[repoA+" modules/m/go.mod "+trunkA] = capturedGoMod(t, "cb54756c3865")
v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
if v.State != delivery.VerdictFail || !strings.Contains(v.Failed.Words, "modules/m/go.mod") || !strings.Contains(v.Failed.Words, "9aaa5c80ac5c") {
t.Fatalf("verdict = %+v", v)
}
}
func TestAReadThatFailsForAnotherReasonStillFailsAndIsNotTriedAtTheTrunk(t *testing.T) { // 1845/AC4
b := trunkOnlyBench(t, "cb54756c3865", "cb54756c3865")
b.ex.Forge.(*fakeForge).errAt = map[string]error{head1: fmt.Errorf("the forge answered 500")}
v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
if v.State != delivery.VerdictFail || !strings.Contains(v.Failed.Words, "modules/m/go.mod") || !strings.Contains(v.Failed.Words, "the forge answered 500") {
t.Fatalf("verdict = %+v", v)
}
}
type runsAt map[string]string
func (r runsAt) CommitOf(module string) string { return r[module] }
func besideOf(t *testing.T, b *bench, repository string) map[string]BesideRef {
t.Helper()
out := map[string]BesideRef{}
for _, f := range b.steps.tool("fetch") {
if f.args["repository"] == repository {
refs, _ := f.args["beside"].([]BesideRef)
for _, r := range refs {
out[r.Repository] = r
}
}
}
return out
}
func TestTheSiblingsAreWhereTheTestsStateThem(t *testing.T) { // review D2, ADR 0484 clause 4
const controller, catalogue, host = "novox/mesh-controller", "novox/mesh-catalog", "novox/mesh-host"
hostRuns := strings.Repeat("7", 40)
b := newBench(t)
b.ex.Trunks = trunksOf(t, map[string]string{controller: trunkA, catalogue: trunkS, host: trunkA2, SDKRepository: "9999999999999999999999999999999999999999"})
b.ex.Runs = runsAt{"mesh-host": hostRuns}
forge := &fakeForge{goMods: map[string]string{controller + " go.mod " + head1: capturedGoMod(t, "cb54756c3865")}, onTrunk: map[string]bool{"cb54756c3865": true}}
b.ex.Forge = forge
// A group of the controller and the catalogue: the controller's tests read the catalogue's trunk, not the group's head.
b.judge(t, caseOf(t, pullOf(controller, 7, head1), pullOf(catalogue, 12, head2)))
got := besideOf(t, b, controller)
if c := got[catalogue]; c.Commit != trunkS || c.Ref != "" {
t.Errorf("the catalogue beside the controller = %+v, want its trunk %s", c, trunkS)
}
if h := got[host]; h.Commit != hostRuns {
t.Errorf("the node-engine beside the controller = %+v, want the commit the mesh runs %s", h, hostRuns)
}
if s := got[SDKRepository]; s.Commit != "cb54756c3865"+strings.Repeat("0", 28) {
t.Errorf("mesh-sdk beside the controller = %+v, want the commit its go.mod pins, whole", s)
}
}
func TestThePinOfTheControllerThatCannotBeReadFailsTheCheck(t *testing.T) { // review D2
b := newBench(t)
b.ex.Trunks = trunksOf(t, map[string]string{"novox/mesh-controller": trunkA})
b.ex.Forge = &fakeForge{goMods: map[string]string{}, onTrunk: map[string]bool{}}
v := b.judge(t, caseOf(t, pullOf("novox/mesh-controller", 7, head1))).Verdict
if v.State != delivery.VerdictFail || !strings.Contains(v.Failed.Words, "go.mod") {
t.Errorf("verdict = %+v", v)
}
}
@@ -7,6 +7,7 @@ import (
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/delivery/buildrun"
)
// The read tools (novox/hq ADR 0484 clauses 8 and 13, issue 1686): what the checker stored, as stored, with the line of the
@@ -17,6 +18,9 @@ const (
VerdictsReadTool = "checker_verdicts"
PullReadTool = "checker_pull"
KeptReadTool = "checker_kept"
// PreviewsReadTool reads the previews state (issue 1680); StatusReadTool reads what the checker is held by and what it runs (issue 1685).
PreviewsReadTool = "checker_previews"
StatusReadTool = "checker_status"
)
// StateRead reads one state: its keys and a key's stored value (nil for a key with none).
@@ -26,7 +30,14 @@ type StateRead struct {
}
// Reader answers the read tools from the checker's verdicts and kept states and the indexer's candidates.
type Reader struct{ VerdictState, KeptState, CandidateState StateRead }
type Reader struct {
VerdictState, KeptState, CandidateState StateRead
PreviewState StateRead // the checker's merge previews
PlansState StateRead // the controller's delivery plans in their build phase; zero where the bucket is absent
StepStates map[string]StateRead // the build modules' steps states, by module
Capacity func() (string, error) // the capacity setting as written, `node=steps,...`
}
// VerdictRead is a verdict as stored with the full report of the status of each pull request it judged (Report), when the
// candidate still holds those heads; without it the report is that of the verdict alone.
@@ -191,3 +202,153 @@ func (r Reader) Kept(level, digest string) (map[string]json.RawMessage, error) {
}
return out, nil
}
// PreviewRead is one preview as stored, with the parts of its key.
type PreviewRead struct {
Key string `json:"key"`
Repository string `json:"repository"`
Head string `json:"head"`
Trunk string `json:"trunk"`
Preview json.RawMessage `json:"preview"`
}
// Previews are the previews the checker holds, each as stored. With a repository only that repository's; with a head (a
// prefix of at least 7 characters is enough) only previews of that head. A key that is no preview key is listed as unreadable.
func (r Reader) Previews(repository, head string) ([]PreviewRead, []string, error) {
if r.PreviewState.Keys == nil || r.PreviewState.Get == nil {
return nil, nil, fmt.Errorf("the previews state cannot be read here")
}
keys, err := r.PreviewState.Keys()
if err != nil {
return nil, nil, err
}
sort.Strings(keys)
out := []PreviewRead{}
var bad []string
for _, k := range keys {
repo, h, trunk, err := delivery.ParsePreviewKey(k)
if err != nil {
bad = append(bad, k)
continue
}
if (repository != "" && repo != repository) || (head != "" && !strings.HasPrefix(h, head)) {
continue
}
raw, err := r.PreviewState.Get(k)
if err != nil {
return nil, nil, err
}
if len(raw) > 0 {
out = append(out, PreviewRead{Key: k, Repository: repo, Head: h, Trunk: trunk, Preview: raw})
}
}
return out, bad, nil
}
// RunningStep is a step in flight on a build module.
type RunningStep struct {
Module string `json:"module"`
Node string `json:"node"`
ID string `json:"id"`
Tool string `json:"tool"`
Since string `json:"since,omitempty"`
}
// Status is what holds the checker back and what runs now, read from the buckets: the checker's held and running steps live in
// its process, so they are shown as what the buckets say (the steps in flight on the build modules, the candidates without a
// verdict for their heads, and the delivery plans in their build phase), not as the process's own counters.
type Status struct {
Capacity map[string]int `json:"capacity"`
CapacityNote string `json:"capacity_note,omitempty"`
DefaultPerNode int `json:"default_per_node"`
PlansBuilding struct {
Readable bool `json:"readable"`
Keys []string `json:"keys"`
Holds bool `json:"holds"`
} `json:"plans_building"`
Running []RunningStep `json:"running_steps"`
RunningByNode map[string]int `json:"running_by_node"`
Unjudged []string `json:"unjudged_candidates"`
Notes []string `json:"notes,omitempty"`
}
// Status reads the capacity, the plan bucket, the build modules' steps and the candidates against their verdicts.
func (r Reader) Status() (Status, error) {
st := Status{DefaultPerNode: buildrun.DefaultCapacity, Capacity: map[string]int{}, RunningByNode: map[string]int{}}
if r.Capacity == nil {
st.CapacityNote = "the capacity setting cannot be read from here"
} else if setting, err := r.Capacity(); err != nil {
st.CapacityNote = "the capacity setting cannot be read: " + err.Error()
} else if st.Capacity, err = buildrun.ParseCapacity(setting); err != nil {
st.Capacity, st.CapacityNote = map[string]int{}, "the capacity setting is not valid: "+err.Error()
}
if r.PlansState.Keys == nil {
st.Notes = append(st.Notes, PlansBuildingBucket+" is not readable here (absent), so it is read as empty: no delivery holds the checker")
} else if keys, err := r.PlansState.Keys(); err != nil {
st.Notes = append(st.Notes, PlansBuildingBucket+" cannot be listed, so it is read as empty: "+err.Error())
} else {
sort.Strings(keys)
st.PlansBuilding.Readable, st.PlansBuilding.Keys, st.PlansBuilding.Holds = true, keys, len(keys) > 0
}
mods := make([]string, 0, len(r.StepStates))
for m := range r.StepStates {
mods = append(mods, m)
}
sort.Strings(mods)
st.Running = []RunningStep{}
for _, m := range mods {
sr := r.StepStates[m]
if sr.Keys == nil || sr.Get == nil {
continue
}
keys, err := sr.Keys()
if err != nil {
st.Notes = append(st.Notes, "the steps of "+m+" cannot be listed: "+err.Error())
continue
}
sort.Strings(keys)
for _, k := range keys {
raw, err := sr.Get(k)
if err != nil || len(raw) == 0 {
continue
}
step, err := delivery.DecodeStep(raw)
if err != nil || step.State != delivery.StepRunning {
continue
}
rs := RunningStep{Module: m, Node: step.Node, ID: step.ID, Tool: step.Tool}
if !step.Started.IsZero() {
rs.Since = step.Started.UTC().Format("2006-01-02T15:04:05Z")
}
st.Running = append(st.Running, rs)
st.RunningByNode[step.Node]++
}
}
st.Unjudged = []string{}
if r.CandidateState.Keys != nil && r.CandidateState.Get != nil && r.VerdictState.Get != nil {
keys, err := r.CandidateState.Keys()
if err != nil {
return st, err
}
sort.Strings(keys)
for _, k := range keys {
raw, err := r.CandidateState.Get(k)
if err != nil || len(raw) == 0 {
continue
}
c, err := delivery.DecodeCandidate(raw)
if err != nil {
continue
}
vkey, err := delivery.CheckerVerdictKey(c.Group, c.HeadsHash)
if err != nil {
continue
}
if v, err := r.VerdictState.Get(vkey); err == nil && len(v) == 0 {
st.Unjudged = append(st.Unjudged, c.Group)
}
}
}
st.Notes = append(st.Notes, "the steps the checker holds waiting for a place are in its process and in no bucket: a candidate without a verdict while a plan key exists, or while its node runs as many steps as its capacity, is held")
return st, nil
}
@@ -3,8 +3,10 @@ package check
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -102,3 +104,64 @@ func TestTheKeptToolAnswersTheKeysAsStoredByLevelAndByDigest(t *testing.T) { //
t.Error("a digest nothing kept was answered")
}
}
// 1680 live acceptance: checker_previews shows the preview key of an open pull request, as stored, narrowed by repository and head.
func TestThePreviewsToolShowsTheStoredPreviewOfAPullRequest(t *testing.T) {
pv := delivery.Preview{Repository: repoA, Head: head1, Trunk: head2, Merge: "m1", Modules: map[string]delivery.PreviewModule{}}
data, err := delivery.EncodePreview(pv)
if err != nil {
t.Fatal(err)
}
key, _ := delivery.PreviewKey(repoA, head1, head2)
rd := Reader{PreviewState: fakeState{key: data, "junk": []byte("{}")}.read()}
got, bad, err := rd.Previews(repoA, head1[:8])
if err != nil || len(got) != 1 || got[0].Key != key || got[0].Head != head1 || got[0].Trunk != head2 || string(got[0].Preview) != string(data) {
t.Fatalf("previews = %+v, %v", got, err)
}
if len(bad) != 1 || bad[0] != "junk" {
t.Errorf("unreadable = %v", bad)
}
if got, _, _ := rd.Previews("novox/other", ""); len(got) != 0 {
t.Errorf("another repository's = %+v", got)
}
if _, _, err := (Reader{}).Previews("", ""); err == nil {
t.Error("answered with no state to read")
}
}
// 1685 live acceptance: checker_status reads the capacity, whether a plan key holds the checker, the steps in flight by node and
// the candidates without a verdict.
func TestTheStatusToolReadsCapacityHoldsAndRunningSteps(t *testing.T) {
running, _ := delivery.EncodeStep(delivery.Step{ID: "abc1", Tool: "build_go_test", Node: "shanks", State: delivery.StepRunning, Started: time.Date(2026, 10, 22, 15, 0, 0, 0, time.UTC)})
passed, _ := delivery.EncodeStep(delivery.Step{ID: "abc2", Tool: "build_go_test", Node: "ace", State: delivery.StepPassed})
cand := candidateOf(t, "feat/x", pullOf(repoA, 7, head1))
ckey, _ := delivery.CandidatesKey("feat/x")
rd := Reader{
Capacity: func() (string, error) { return "shanks=2,ace=0", nil },
PlansState: fakeState{"p1": []byte("{}")}.read(),
StepStates: map[string]StateRead{"mesh-build-go": fakeState{"shanks.abc1": running, "ace.abc2": passed}.read()},
CandidateState: fakeState{ckey: cand}.read(),
VerdictState: fakeState{}.read(),
}
st, err := rd.Status()
if err != nil {
t.Fatal(err)
}
if st.Capacity["shanks"] != 2 || st.Capacity["ace"] != 0 || st.DefaultPerNode != 1 {
t.Errorf("capacity = %v default %d", st.Capacity, st.DefaultPerNode)
}
if !st.PlansBuilding.Holds || len(st.PlansBuilding.Keys) != 1 {
t.Errorf("plans = %+v", st.PlansBuilding)
}
if len(st.Running) != 1 || st.Running[0].Node != "shanks" || st.RunningByNode["shanks"] != 1 || st.RunningByNode["ace"] != 0 {
t.Errorf("running = %+v %v", st.Running, st.RunningByNode)
}
if len(st.Unjudged) != 1 || st.Unjudged[0] != "feat/x" {
t.Errorf("unjudged = %v", st.Unjudged)
}
// An absent plan bucket is read as empty and said; an unreadable capacity is said, not guessed.
st, err = Reader{Capacity: func() (string, error) { return "", os.ErrNotExist }}.Status()
if err != nil || st.PlansBuilding.Holds || st.CapacityNote == "" || len(st.Notes) == 0 {
t.Errorf("empty status = %+v, %v", st, err)
}
}
@@ -17,3 +17,5 @@ novox/mesh-delivery at main, and forge-compare-trunk.json the answer of gitea_co
trunk: no commit main does not hold); both called through the mesh on 2026-10-21 for issue 1624. forge-compare-branch.json is the answer of the same call for a
head that is not on the trunk (novox/mesh-sdk base main, head afbe371003a5, the branch feat/r4c5-sdk), shortened to two of its seven commits and one of its
twenty-six files, with the head and the totals set to match.
forge-commits.json is the answer of the forge's gitea_commits for novox/mesh-sdk, ref cb54756c3865, limit 1, called through the mesh on 2026-10-21 (review D2).
@@ -0,0 +1,14 @@
{
"commits": [
{
"author": "mesh-tools",
"date": "2026-10-21T10:02:09Z",
"sha": "cb54756c3865df5ac9c0dc0481ad9ebe2bf013a1",
"subject": "Merge pull request 'Give delivery.Module the toolchains its fingerprint was computed with, and '.' as a root module's path, so the builder builds a key only with its own toolchain (issue 1615)' (#55) from fix/1615-module-toolchains-root into main"
}
],
"more": true,
"page": 1,
"ref": "cb54756c3865",
"repository": "novox/mesh-sdk"
}
@@ -100,7 +100,7 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1
"mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", "mesh-delivery-watchman.presence", "node-engine.have"}; !reflect.DeepEqual(sorted(m.Reads), want) {
t.Errorf("reads = %v, want %v", m.Reads, want)
}
if want := []string{"gitea.gitea_compare", "gitea.gitea_get_file", "mesh-build-go.build_go_digest", "mesh-build-go.build_go_fetch", "mesh-build-go.build_go_test", "mesh-build-node.build_node_fetch", "mesh-build-node.build_node_test"}; !reflect.DeepEqual(sorted(m.Invokes), want) {
if want := []string{"gitea.gitea_commits", "gitea.gitea_compare", "gitea.gitea_get_file", "mesh-build-go.build_go_digest", "mesh-build-go.build_go_fetch", "mesh-build-go.build_go_test", "mesh-build-node.build_node_fetch", "mesh-build-node.build_node_test"}; !reflect.DeepEqual(sorted(m.Invokes), want) {
t.Errorf("invokes = %v, want only the build modules' fetch, digest and test (no run step before issue 1309, ADR 0484 clause 5) and the forge's two reads for a mesh-sdk pin (clause 9)", m.Invokes)
}
// The checker is woken by a build module's report of a toolchain, as the indexer is: the bucket may not exist when it starts.
@@ -118,7 +118,7 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1
t.Errorf("streams = %+v, want %+v", m.Streams, want)
}
// The read tools (issue 1686) are served by the tools bundle, not by the checker's process.
if want := []string{"checker_kept", "checker_pull", "checker_verdicts"}; !reflect.DeepEqual(sorted(m.Tools), want) {
if want := []string{"checker_kept", "checker_previews", "checker_pull", "checker_status", "checker_verdicts"}; !reflect.DeepEqual(sorted(m.Tools), want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
for _, k := range []string{"logs", "emits", "claims", "provides", "requires", "uses", "defines-seats", "seat", "seats", "bus"} {
@@ -31,10 +31,13 @@
],
"tools": [
"checker_kept",
"checker_previews",
"checker_pull",
"checker_status",
"checker_verdicts"
],
"invokes": [
"gitea.gitea_commits",
"gitea.gitea_compare",
"gitea.gitea_get_file",
"mesh-build-go.build_go_digest",
@@ -35,18 +35,74 @@ func Tools(r view.Reader) []stdio.Tool {
mods, err := r.Modules(only)
return map[string]any{"modules": mods}, err
}},
{Name: view.CandidateReadTool,
Description: "What the indexer stored for one candidate (the open pull requests of a branch) under candidates.<group>, as stored: " +
"the pull requests and heads, the base, and per pull request the touched modules with their new-form fingerprint (fp2) or why " +
"they have none (none2). Read-only.",
Input: map[string]any{"type": "object", "required": []string{"group"}, "properties": map[string]any{
"group": map[string]any{"type": "string", "description": "the candidate's group, the branch name its pull requests share"}}},
Run: func(args map[string]any) (any, error) {
group, _ := args["group"].(string)
return r.Candidate(group)
}},
{Name: view.CandidatesReadTool,
Description: "Every candidate the indexer holds, by group, each as stored under candidates.<group>; with module, only those with a " +
"pull request touching that module. Keys that cannot be read are listed as unreadable. Read-only.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"module": map[string]any{"type": "string", "description": "only candidates touching this module"}}},
Run: func(args map[string]any) (any, error) {
module, _ := args["module"].(string)
c, bad, err := r.Candidates(module)
return map[string]any{"candidates": c, "unreadable": bad}, err
}},
{Name: view.BuilderBuildsReadTool,
Description: "The builder's build keys, each as stored in mesh-delivery-builder_builds: key, module, fingerprint (src2) and build " +
"(state, by, step with the nodes it was built on, manifest, artifacts with their digests, toolchains, error). Narrow by module " +
"or state (queued, building, done, failed); at most limit builds (default 50), newest revision first for a module, and the answer says when it was cut and lists keys that are no build key (unreadable). Without a module, give one to get its newest. Read-only.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"module": map[string]any{"type": "string", "description": "only this module's builds"},
"state": map[string]any{"type": "string", "description": "only builds in this state"},
"limit": map[string]any{"type": "integer", "description": "at most this many builds (default 50), newest revision first"}}},
Run: func(args map[string]any) (any, error) {
module, _ := args["module"].(string)
state, _ := args["state"].(string)
limit, _ := args["limit"].(float64)
return r.BuilderBuilds(module, state, int(limit))
}},
{Name: view.BuilderBuildReadTool,
Description: "One build of the builder as stored. Give the module and its fingerprint (fp2, src2:...), or the module alone for the build " +
"at the fingerprint the indexer holds for it now, joined through modules.<module>; with commit, only if the indexer holds the module at that commit. Read-only.",
Input: map[string]any{"type": "object", "required": []string{"module"}, "properties": map[string]any{
"module": map[string]any{"type": "string", "description": "the module's name"},
"fp2": map[string]any{"type": "string", "description": "its new-form fingerprint; empty means the indexer's current one"},
"commit": map[string]any{"type": "string", "description": "the commit the indexer must hold the module at"}}},
Run: func(args map[string]any) (any, error) {
module, _ := args["module"].(string)
fp2, _ := args["fp2"].(string)
commit, _ := args["commit"].(string)
return r.BuilderBuild(module, fp2, commit)
}},
}
}
func main() {
state := stdio.State("mesh-delivery-indexer.modules")
r := view.Reader{State: view.StateRead{Keys: state.Keys, Get: func(key string) (json.RawMessage, error) {
func read(state stdio.KeptState) view.StateRead {
return view.StateRead{Keys: state.Keys, GetEntry: func(key string) (json.RawMessage, uint64, error) {
e, err := state.Get(key)
if err != nil || e == nil {
return nil, 0, err
}
return e.Value, e.Revision, nil
}, Get: func(key string) (json.RawMessage, error) {
e, err := state.Get(key)
if err != nil || e == nil {
return nil, err
}
return e.Value, nil
}}}
}}
}
func main() {
r := view.Reader{State: read(stdio.State("mesh-delivery-indexer.modules")), Builds: read(stdio.State("mesh-delivery-builder.builds")), CandidatesState: read(stdio.State("mesh-delivery-indexer.candidates"))}
if err := stdio.Serve("", Tools(r)); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-view-tools] %v\n", err)
os.Exit(1)
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"fmt"
"strings"
"testing"
@@ -94,3 +95,172 @@ func TestModulesToolAnswersEveryKeyAsStored(t *testing.T) {
t.Errorf("none2 modules = %v", only)
}
}
const (
fp2A = "src2:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
fp2B = "src2:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
keyA = "netcheck.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
keyB = "zsh.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
doneBuild = `{"v":1,"state":"done","by":"novox.mesh-delivery-builder","step":"built on shanks, ace","manifest":"m","artifacts":[{"name":"program","kind":"bundle","digest":"sha256:d1"}]}`
failBuild = `{"v":1,"state":"failed","by":"novox.mesh-delivery-builder","error":"refused kind: package"}`
)
func both(builds, modules fake) view.Reader {
r := modules.reader()
r.Builds = builds.reader().State
return r
}
// 1723/AC1: builder_builds lists every key as stored (module, fingerprint, state, digests, error), and narrows by module and state.
func TestBuilderBuildsListsKeysAsStored(t *testing.T) {
r := both(fake{keyA: doneBuild, keyB: failBuild, "junk": "{}"}, fake{})
out, err := run(t, r, view.BuilderBuildsReadTool, map[string]any{})
if err != nil {
t.Fatal(err)
}
rows := out.(view.BuildsAnswer).Builds
if u := out.(view.BuildsAnswer).Unreadable; len(u) != 1 || u[0] != "junk" {
t.Errorf("unreadable = %v, want [junk] (issue 1818)", u)
}
if len(rows) != 2 || rows[0].Key != keyA || rows[0].Module != "netcheck" || rows[0].Fingerprint != fp2A || string(rows[0].Build) != doneBuild || string(rows[1].Build) != failBuild {
t.Errorf("rows = %+v", rows)
}
for args, want := range map[string]string{"state": keyB, "module": keyA} {
out, _ := run(t, r, view.BuilderBuildsReadTool, map[string]any{args: map[string]string{keyB: "failed", keyA: "netcheck"}[want]})
got := out.(view.BuildsAnswer).Builds
if len(got) != 1 || got[0].Key != want {
t.Errorf("%s filter = %+v, want %s", args, got, want)
}
}
}
// 1723/AC2: builder_build answers one key by module and fp2, and by module alone through the indexer's fp2 (joined at its commit).
func TestBuilderBuildJoinsTheIndexersFingerprint(t *testing.T) {
idx := `{"v":3,"fp2":"` + fp2A + `","commit":"c1","path":"."}`
r := both(fake{keyA: doneBuild}, fake{"netcheck": idx, "zsh": `{"v":3,"none2":"not known","commit":"c9"}`})
for _, args := range []map[string]any{{"module": "netcheck", "fp2": fp2A}, {"module": "netcheck"}, {"module": "netcheck", "commit": "c1"}} {
out, err := run(t, r, view.BuilderBuildReadTool, args)
if err != nil {
t.Fatalf("%v: %v", args, err)
}
if row := out.(view.BuilderRow); row.Key != keyA || string(row.Build) != doneBuild {
t.Errorf("%v = %+v", args, row)
}
}
for args, want := range map[string]string{"netcheck/c2": "not c2", "zsh": "not known", "gone": "no module gone"} {
a := map[string]any{"module": strings.Split(args, "/")[0]}
if strings.Contains(args, "/") {
a["commit"] = "c2"
}
if _, err := run(t, r, view.BuilderBuildReadTool, a); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%v: err = %v, want %q", a, err, want)
}
}
if _, err := run(t, r, view.BuilderBuildReadTool, map[string]any{"module": "netcheck", "fp2": fp2B}); err == nil || !strings.Contains(err.Error(), "holds no build") {
t.Errorf("an unbuilt fingerprint: %v", err)
}
}
// bigBuilds is a bucket of n keys over 100 modules that counts the values read.
type bigBuilds struct {
fake fake
reads *int
}
func newBig(n int) bigBuilds {
f := fake{}
for i := 0; i < n; i++ {
f[fmt.Sprintf("mod%02d.%064x", i%100, i)] = doneBuild
}
return bigBuilds{fake: f, reads: new(int)}
}
func (b bigBuilds) reader() view.Reader {
r := b.fake.reader()
get := r.State.Get
r.Builds = view.StateRead{Keys: r.State.Keys, Get: func(k string) (json.RawMessage, error) { *b.reads++; return get(k) },
GetEntry: func(k string) (json.RawMessage, uint64, error) {
*b.reads++
raw, err := get(k)
var rev uint64
fmt.Sscanf(k[strings.Index(k, ".")+1:], "%x", &rev) // the fake's revision is the key's number
return raw, rev + 1, err
}}
return r
}
// 1827/AC1: with 2,000 keys, a module's builds are found by key prefix, so only that module's values are read (20, not 2,000),
// newest revision first, within the limit, and the answer says it was cut.
func TestBuilderBuildsFiltersByPrefixBeforeAnyGet(t *testing.T) {
b := newBig(2000)
out, err := run(t, b.reader(), view.BuilderBuildsReadTool, map[string]any{"module": "mod07", "limit": float64(5)})
if err != nil {
t.Fatal(err)
}
a := out.(view.BuildsAnswer)
if *b.reads != 20 {
t.Errorf("read %d values for a module of 20 keys, want 20", *b.reads)
}
if len(a.Builds) != 5 || !a.Cut || !strings.Contains(a.Note, "5") {
t.Errorf("answer = %d rows, cut %v, note %q", len(a.Builds), a.Cut, a.Note)
}
for i := 1; i < len(a.Builds); i++ {
if a.Builds[i-1].Revision < a.Builds[i].Revision {
t.Errorf("not newest first: %d before %d", a.Builds[i-1].Revision, a.Builds[i].Revision)
}
}
if a.Builds[0].Revision != 1908 {
t.Errorf("newest revision = %d, want 1908", a.Builds[0].Revision)
}
}
// 1827/AC2: with no module the answer reads at most the limit's worth in key order (default 50), never the 2,000.
func TestBuilderBuildsWithoutAModuleIsBounded(t *testing.T) {
b := newBig(2000)
out, err := run(t, b.reader(), view.BuilderBuildsReadTool, map[string]any{})
if err != nil {
t.Fatal(err)
}
a := out.(view.BuildsAnswer)
if len(a.Builds) != view.DefaultBuildsLimit || !a.Cut || *b.reads > view.DefaultBuildsLimit {
t.Errorf("%d rows, cut %v, %d reads", len(a.Builds), a.Cut, *b.reads)
}
b = newBig(2000)
out, _ = run(t, b.reader(), view.BuilderBuildsReadTool, map[string]any{"state": "failed"})
if a := out.(view.BuildsAnswer); *b.reads != view.MaxBuildReads || !a.Cut || len(a.Builds) != 0 {
t.Errorf("a state matching nothing: %d reads (want the bound %d), cut %v", *b.reads, view.MaxBuildReads, a.Cut)
}
}
// 1818: fp2 together with commit is refused, naming both.
func TestBuilderBuildRefusesFP2WithCommit(t *testing.T) {
r := both(fake{keyA: doneBuild}, fake{"netcheck": `{"v":3,"fp2":"` + fp2A + `","commit":"c1","path":"."}`})
_, err := run(t, r, view.BuilderBuildReadTool, map[string]any{"module": "netcheck", "fp2": fp2A, "commit": "c1"})
if err == nil || !strings.Contains(err.Error(), "fp2") || !strings.Contains(err.Error(), "commit") || !strings.Contains(err.Error(), "not both") {
t.Errorf("err = %v", err)
}
}
// 1681: indexer_candidates and indexer_candidate answer the stored value; a pull request's fp2 per touched module shows.
func TestCandidateToolsAnswerTheStoredValue(t *testing.T) {
cand := `{"v":1,"group":"feat/x","base":3,"heads":"h","pulls":[{"modules":["zsh"],"fps":{"zsh":{"fp2":"` + fp2B + `","manifest":"m"}}}]}`
other := `{"v":1,"group":"fix","base":3,"heads":"h","pulls":[{"modules":["netcheck"]}]}`
r := fake{"feat/x": cand, "fix": other, "bad": "{"}.reader()
r.CandidatesState = r.State
out, err := run(t, r, view.CandidateReadTool, map[string]any{"group": "feat/x"})
if err != nil || string(out.(json.RawMessage)) != cand || !strings.Contains(string(out.(json.RawMessage)), fp2B) {
t.Fatalf("candidate = %v, %v", out, err)
}
if _, err := run(t, r, view.CandidateReadTool, map[string]any{"group": "gone"}); err == nil || !strings.Contains(err.Error(), "no candidate gone") {
t.Errorf("err = %v", err)
}
out, err = run(t, r, view.CandidatesReadTool, map[string]any{})
m := out.(map[string]any)
if err != nil || len(m["candidates"].(map[string]json.RawMessage)) != 2 || len(m["unreadable"].([]string)) != 1 {
t.Errorf("all = %v, %v", m, err)
}
out, _ = run(t, r, view.CandidatesReadTool, map[string]any{"module": "zsh"})
if c := out.(map[string]any)["candidates"].(map[string]json.RawMessage); len(c) != 1 || c["feat/x"] == nil {
t.Errorf("by module = %v", c)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-view
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2
View File
@@ -6,6 +6,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPs
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef h1:xy3wuGWJpqMAXYdXolocYG6w9xnWqBltVUffoaziIKo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc h1:ZJScJSnF6LgFl55/VJ9RT/lNroVWun469sXtx0nlVYw=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc/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/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -7,6 +7,7 @@ package view
import (
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
@@ -23,10 +24,17 @@ const (
type StateRead struct {
Keys func() ([]string, error)
Get func(key string) (json.RawMessage, error)
// GetEntry, when set, also gives the revision of the value; builder_builds orders by it.
GetEntry func(key string) (json.RawMessage, uint64, error)
}
// Reader answers the read tools from a modules state.
type Reader struct{ State StateRead }
type Reader struct {
State StateRead // the indexer's modules
Builds StateRead // the builder's builds
CandidatesState StateRead // the indexer's candidates
}
// Module is the stored value of modules.<module>, exactly as the indexer wrote it.
func (r Reader) Module(module string) (json.RawMessage, error) {
@@ -80,3 +88,243 @@ func (r Reader) Modules(none2 bool) (map[string]json.RawMessage, error) {
}
return out, nil
}
// The read tools of the indexer's candidates (issue 1681): what the indexer stored under candidates.<group>, as stored, so the
// new-form fingerprint of each touched module of an open pull request (pulls[].fps) can be read live.
const (
CandidateReadTool = "indexer_candidate"
CandidatesReadTool = "indexer_candidates"
)
// Candidate is the stored value of the candidates key of a group, exactly as the indexer wrote it.
func (r Reader) Candidate(group string) (json.RawMessage, error) {
key, err := delivery.CandidatesKey(strings.TrimSpace(group))
if err != nil {
return nil, err
}
if r.CandidatesState.Get == nil {
return nil, fmt.Errorf("the candidates state cannot be read here")
}
raw, err := r.CandidatesState.Get(key)
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, fmt.Errorf("no candidate %s in the candidates state", group)
}
return raw, nil
}
// Candidates is every candidate, by group, each as stored; with a module only the candidates with a pull request touching it.
// A value that is not readable is listed under unreadable with its key, not skipped.
func (r Reader) Candidates(module string) (map[string]json.RawMessage, []string, error) {
if r.CandidatesState.Keys == nil || r.CandidatesState.Get == nil {
return nil, nil, fmt.Errorf("the candidates state cannot be read here")
}
keys, err := r.CandidatesState.Keys()
if err != nil {
return nil, nil, err
}
sort.Strings(keys)
out := map[string]json.RawMessage{}
var unreadable []string
for _, k := range keys {
raw, err := r.CandidatesState.Get(k)
if err != nil {
return nil, nil, err
}
if len(raw) == 0 {
continue
}
group, perr := delivery.ParseCandidatesKey(k)
var c delivery.Candidate
if perr != nil || json.Unmarshal(raw, &c) != nil {
unreadable = append(unreadable, k)
continue
}
if module != "" && !slices.ContainsFunc(c.Pulls, func(p delivery.CandidatePull) bool { return slices.Contains(p.Modules, module) }) {
continue
}
out[group] = raw
}
return out, unreadable, nil
}
// The read tools of the builder's builds (novox/hq issue 1723): what the builder stored under `<module>.<fingerprint hex>`
// in mesh-delivery-builder_builds, answered as stored, so its states, nodes (in the step) and digests can be checked live.
const (
BuilderBuildReadTool = "builder_build"
BuilderBuildsReadTool = "builder_builds"
)
// BuilderRow is one key of the builder's builds: the key, the module and the fingerprint it parses to, the revision of its
// stored value (when the read gives one) and the stored value as the builder wrote it.
type BuilderRow struct {
Key string `json:"key"`
Module string `json:"module"`
Fingerprint string `json:"fingerprint"`
Revision uint64 `json:"revision,omitempty"`
Build json.RawMessage `json:"build"`
}
// The bounds of builder_builds (issue 1827): the rows it returns when no limit is given, and the values it reads in one call.
// Reading a value is a bus round trip, so the answer must not read the whole bucket (it timed out at 30 s on the live one).
const (
DefaultBuildsLimit = 50
MaxBuildReads = 200
)
// BuildsAnswer is the answer of builder_builds: the rows, newest revision first, whether the answer was cut and why, and
// the keys that are no build key (Unreadable), which are listed rather than silently skipped (issue 1818).
type BuildsAnswer struct {
Builds []BuilderRow `json:"builds"`
Cut bool `json:"cut,omitempty"`
Note string `json:"note,omitempty"`
Unreadable []string `json:"unreadable,omitempty"`
}
func (r Reader) get(key string) (json.RawMessage, uint64, error) {
if r.Builds.GetEntry != nil {
return r.Builds.GetEntry(key)
}
raw, err := r.Builds.Get(key)
return raw, 0, err
}
func (r Reader) builderRow(key string) (BuilderRow, bool, error) {
module, fp, err := delivery.ParseBuilderBuildKey(key)
if err != nil {
return BuilderRow{}, false, nil // a key that is no build key is not a build
}
raw, rev, err := r.get(key)
if err != nil {
return BuilderRow{}, false, err
}
if len(raw) == 0 {
return BuilderRow{}, false, nil
}
return BuilderRow{Key: key, Module: module, Fingerprint: fp, Revision: rev, Build: raw}, true, nil
}
// BuilderBuilds lists the builder's keys, each as stored, newest revision first. With a module it considers only the keys
// that begin `<module>.`, before any value is read; with a state it keeps the builds in that state. It returns at most limit
// rows (DefaultBuildsLimit when limit is not above zero) and reads at most MaxBuildReads values, and says when it cut the
// answer. Keys that are no build key are listed as unreadable. Without a module the keys are walked in key order, so the
// answer is the first rows found, not the newest of the bucket: give a module to get its newest.
func (r Reader) BuilderBuilds(module, state string, limit int) (BuildsAnswer, error) {
if r.Builds.Keys == nil || r.Builds.Get == nil {
return BuildsAnswer{}, fmt.Errorf("the builder's builds cannot be read here")
}
if limit <= 0 {
limit = DefaultBuildsLimit
}
keys, err := r.Builds.Keys()
if err != nil {
return BuildsAnswer{}, err
}
sort.Strings(keys)
prefix := ""
if module = strings.TrimSpace(module); module != "" {
prefix = module + "."
}
out := BuildsAnswer{Builds: []BuilderRow{}}
var candidates []string
for _, k := range keys {
if prefix != "" && !strings.HasPrefix(k, prefix) {
continue
}
if _, _, err := delivery.ParseBuilderBuildKey(k); err != nil {
out.Unreadable = append(out.Unreadable, k)
continue
}
candidates = append(candidates, k)
}
reads := 0
for _, k := range candidates {
if reads >= MaxBuildReads {
out.Cut, out.Note = true, fmt.Sprintf("read %d values and stopped; give a module (and a state) to narrow the answer", MaxBuildReads)
break
}
reads++
row, ok, err := r.builderRow(k)
if err != nil {
return BuildsAnswer{}, err
}
if !ok {
continue
}
if state != "" {
var b struct {
State string `json:"state"`
}
if json.Unmarshal(row.Build, &b) != nil || b.State != state {
continue
}
}
out.Builds = append(out.Builds, row)
// Without a module the walk is in key order and stops at the limit; with one, all its (few) keys are read to order them.
if prefix == "" && len(out.Builds) >= limit {
if reads < len(candidates) {
out.Cut, out.Note = true, fmt.Sprintf("stopped at the limit of %d builds in key order, not newest first; give a module for its newest", limit)
}
break
}
}
sort.SliceStable(out.Builds, func(i, j int) bool {
if out.Builds[i].Revision != out.Builds[j].Revision {
return out.Builds[i].Revision > out.Builds[j].Revision
}
return out.Builds[i].Key < out.Builds[j].Key
})
if len(out.Builds) > limit {
out.Builds = out.Builds[:limit]
out.Cut, out.Note = true, fmt.Sprintf("cut to the newest %d builds of %s", limit, module)
}
return out, nil
}
// BuilderBuild is the build of module at the new-form fingerprint fp2; with an empty fp2 it is the build at the fingerprint
// the indexer holds for the module now (its fp2), joined through modules.<module>; commit, when given, must be the commit
// the indexer holds that fp2 for, or the answer says which commit it holds (the fingerprint of another commit is not guessed).
func (r Reader) BuilderBuild(module, fp2, commit string) (BuilderRow, error) {
module = strings.TrimSpace(module)
if r.Builds.Get == nil {
return BuilderRow{}, fmt.Errorf("the builder's builds cannot be read here")
}
if fp2 != "" && commit != "" {
return BuilderRow{}, fmt.Errorf("give fp2 or commit, not both: fp2 %s names a build directly, commit %s asks for the build at the indexer's fingerprint of that commit", fp2, commit)
}
if fp2 == "" {
raw, err := r.Module(module)
if err != nil {
return BuilderRow{}, err
}
var m struct {
FP2 string `json:"fp2"`
None2 string `json:"none2"`
Commit string `json:"commit"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return BuilderRow{}, fmt.Errorf("the indexer's value of %s is not readable: %w", module, err)
}
if commit != "" && m.Commit != commit {
return BuilderRow{}, fmt.Errorf("the indexer holds %s at commit %s, not %s, so its fingerprint is not the one of that commit", module, m.Commit, commit)
}
if m.FP2 == "" {
return BuilderRow{}, fmt.Errorf("the indexer holds no new-form fingerprint of %s: %s", module, m.None2)
}
fp2 = m.FP2
}
key, err := delivery.BuilderBuildKey(module, fp2)
if err != nil {
return BuilderRow{}, err
}
row, ok, err := r.builderRow(key)
if err != nil {
return BuilderRow{}, err
}
if !ok {
return BuilderRow{}, fmt.Errorf("the builder holds no build %s", key)
}
return row, nil
}
+2 -2
View File
@@ -76,7 +76,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
if m.Module != "mesh-delivery-view" {
t.Errorf("module = %q", m.Module)
}
want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-gate.asks", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1, 1241/AC4, 1345/AC3
want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-builder.builds", "mesh-delivery-gate.asks", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1, 1241/AC4, 1345/AC3
got := slices.Clone(m.Reads)
slices.Sort(got)
if !reflect.DeepEqual(got, want) {
@@ -95,7 +95,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
}
// The two read tools of the indexer's modules (issue 1562), carried by a bundle the runtime launches; the indexer
// itself serves no tool (ADR 0437 §4). Carrying them gives the tool runner read access to every bucket the view reads.
if want := []string{"indexer_module", "indexer_modules"}; !reflect.DeepEqual(m.Tools, want) {
if want := []string{"indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "builder_build", "builder_builds"}; !reflect.DeepEqual(m.Tools, want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
for _, a := range m.Build.Artifacts {
+6 -1
View File
@@ -5,11 +5,16 @@
"slug": "view",
"tools": [
"indexer_module",
"indexer_modules"
"indexer_modules",
"indexer_candidate",
"indexer_candidates",
"builder_build",
"builder_builds"
],
"reads": [
"mesh-controller.assignments",
"mesh-controller.builds",
"mesh-delivery-builder.builds",
"mesh-delivery-gate.asks",
"mesh-delivery-indexer.candidates",
"mesh-delivery-indexer.modules",