diff --git a/modules/mesh-delivery-builder/internal/build/fixtures_test.go b/modules/mesh-delivery-builder/internal/build/fixtures_test.go new file mode 100644 index 0000000..b5a6566 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/build/fixtures_test.go @@ -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) + } +} diff --git a/modules/mesh-delivery-builder/internal/build/run.go b/modules/mesh-delivery-builder/internal/build/run.go index eb60cf1..ea67174 100644 --- a/modules/mesh-delivery-builder/internal/build/run.go +++ b/modules/mesh-delivery-builder/internal/build/run.go @@ -310,6 +310,7 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) { // 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) { @@ -317,6 +318,7 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) { } if trunk && Unpublished(cl.Build) { only[ImageBuildModule] = true + publishing = true } } result := b.build(ctx, module, m, plan, planErr, prior, only) @@ -328,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) @@ -597,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) @@ -619,11 +637,7 @@ func (b *Builder) runLane(ctx context.Context, l *lane, repository string, m del l.err = err return } - var fetched struct { - Workspace string `json:"workspace"` - Merge string `json:"merge"` - Conflicts []string `json:"conflicts"` - } + 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 @@ -652,10 +666,7 @@ func (b *Builder) runLane(ctx context.Context, l *lane, repository string, m del l.err = err return } - var built struct { - Digest string `json:"digest"` - Reference string `json:"reference"` - } + 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 diff --git a/modules/mesh-delivery-builder/internal/build/run_test.go b/modules/mesh-delivery-builder/internal/build/run_test.go index 251fe72..6da1ebe 100644 --- a/modules/mesh-delivery-builder/internal/build/run_test.go +++ b/modules/mesh-delivery-builder/internal/build/run_test.go @@ -606,3 +606,28 @@ func TestASecondGoNodeDoesNotBuildOrPushTheImageAgain(t *testing.T) { // review 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) + } +} diff --git a/modules/mesh-delivery-builder/internal/build/testdata/captured/README b/modules/mesh-delivery-builder/internal/build/testdata/captured/README new file mode 100644 index 0000000..087dcbf --- /dev/null +++ b/modules/mesh-delivery-builder/internal/build/testdata/captured/README @@ -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. diff --git a/modules/mesh-delivery-builder/internal/build/testdata/captured/build-candidate.json b/modules/mesh-delivery-builder/internal/build/testdata/captured/build-candidate.json new file mode 100644 index 0000000..ee22661 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/build/testdata/captured/build-candidate.json @@ -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 +} diff --git a/modules/mesh-delivery-builder/internal/build/testdata/captured/build-trunk.json b/modules/mesh-delivery-builder/internal/build/testdata/captured/build-trunk.json new file mode 100644 index 0000000..db897d3 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/build/testdata/captured/build-trunk.json @@ -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 +} diff --git a/modules/mesh-delivery-builder/internal/build/testdata/captured/fetch.json b/modules/mesh-delivery-builder/internal/build/testdata/captured/fetch.json new file mode 100644 index 0000000..6f893f4 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/build/testdata/captured/fetch.json @@ -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 +}