diff --git a/modules/mesh-build-image/cmd/mesh-build-image/bus_integration_test.go b/modules/mesh-build-image/cmd/mesh-build-image/bus_integration_test.go new file mode 100644 index 00000000..bc99e983 --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/bus_integration_test.go @@ -0,0 +1,157 @@ +//go:build integration + +package main + +// ADR 0487 §1: the build step answers the image's digest, through a real bus (issue 1703e). A nats-server from the Go +// library, at the version this module's go.mod names, runs with JetStream on in the test's directory (the pattern of +// mesh-build-go's bus.go). The module's steps state is a key-value bucket on that bus, and its tools are served on bus +// subjects by a small bridge in the test, so a call, the step that runs behind it and the key it ends on all travel over +// the bus; the mesh's own tool runner is not started here. buildah and git are the fake runner's, so this runs wherever +// the check runs; the real buildah is TestARealBuild... in integration_test.go. What the keys hold is compared with the +// captured shapes (testdata/captured, capture_test.go) that the builder's tests copy. + +import ( + "context" + "encoding/json" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// bus starts the throwaway server and answers a connection to it. +func bus(t *testing.T) *nats.Conn { + t.Helper() + s, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true}) + if err != nil { + t.Fatal(err) + } + go s.Start() + if !s.ReadyForConnections(30 * time.Second) { + t.Fatal("the bus was not ready") + } + t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() }) + nc, err := nats.Connect(s.ClientURL()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + return nc +} + +// onBus wires a module's steps state to a bucket and serves its tools on "test.tool.": the request body is the +// arguments, the answer is {"result": ...} or {"error": "..."}. +func onBus(t *testing.T, nc *nats.Conn, m *Module) jetstream.KeyValue { + t.Helper() + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "mesh-build-image-steps"}) + if err != nil { + t.Fatal(err) + } + m.Steps = func(key string, value any) (uint64, error) { + raw, err := json.Marshal(value) + if err != nil { + return 0, err + } + return kv.Put(ctx, key, raw) + } + for _, tl := range m.Tools(m.StepTools()) { + tl := tl + if _, err := nc.Subscribe("test.tool."+tl.Name, func(msg *nats.Msg) { + var args map[string]any + _ = json.Unmarshal(msg.Data, &args) + res, err := tl.Run(args) + out := map[string]any{"result": res} + if err != nil { + out = map[string]any{"error": err.Error()} + } + b, _ := json.Marshal(out) + _ = msg.Respond(b) + }); err != nil { + t.Fatal(err) + } + } + return kv +} + +// call calls a tool over the bus and answers its result, which for a long step is the running step's key. +func call(t *testing.T, nc *nats.Conn, tool string, args map[string]any) map[string]any { + t.Helper() + body, _ := json.Marshal(args) + msg, err := nc.Request("test.tool."+tool, body, 10*time.Second) + if err != nil { + t.Fatal(err) + } + var out struct { + Result map[string]any `json:"result"` + Error string `json:"error"` + } + if err := json.Unmarshal(msg.Data, &out); err != nil || out.Error != "" { + t.Fatalf("%s answered %s (%v)", tool, msg.Data, err) + } + return out.Result +} + +// ended waits for the step's key in the bucket to leave running and answers the step as the bucket holds it. +func ended(t *testing.T, kv jetstream.KeyValue, key string) delivery.Step { + t.Helper() + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + if e, err := kv.Get(context.Background(), key); err == nil { + st, err := delivery.DecodeStep(e.Value()) + if err != nil { + t.Fatal(err) + } + if st.State != delivery.StepRunning { + return st + } + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("the key %s never left running", key) + return delivery.Step{} +} + +func TestTheStepsAnswerThroughARealBusWithTheCapturedShapes(t *testing.T) { + nc := bus(t) + f := newFake(t) + m := moduleWith(t, f, newStates()) + kv := onBus(t, nc, m) + place(t, m, same) + + // The fetch: a running key at once, then the workspace on the key. + started := call(t, nc, FetchTool, map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA}) + if started["state"] != delivery.StepRunning || started["key"] == "" { + t.Fatalf("the fetch answered %v, want a running step and its key", started) + } + st := ended(t, kv, started["key"].(string)) + compareCaptured(t, "fetch.json", normalized(t, st, workspaceOf(t, st), commitSHA, "")) + + // The build of a pull request: the image's digest, nothing pushed. The workspace is the fixture's, "ws1". + for name, extra := range map[string]map[string]any{"build-candidate.json": nil, "build-trunk.json": {"publish": true}} { + args := map[string]any{"workspace": "ws1", "module": "modules/demo", "artifact": "server"} + for k, v := range extra { + args[k] = v + } + started := call(t, nc, BuildTool, args) + st := ended(t, kv, started["key"].(string)) + if st.State != delivery.StepPassed { + t.Fatalf("%s: step = %+v", name, st) + } + var res BuildResult + if err := json.Unmarshal(st.Result, &res); err != nil || !digestOK.MatchString(res.Digest) || res.Pushed != (extra != nil) { + t.Errorf("%s: result = %s (%v), want the image's digest and pushed %v", name, st.Result, err, extra != nil) + } + compareCaptured(t, name, normalized(t, st, "", "", f.address())) + } + if len(f.registry) != 1 || f.pushes != 1 { + t.Errorf("the registry holds %v after %d pushes, want only the trunk build's one", f.registry, f.pushes) + } +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/capture_test.go b/modules/mesh-build-image/cmd/mesh-build-image/capture_test.go new file mode 100644 index 00000000..b6f372a7 --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/capture_test.go @@ -0,0 +1,102 @@ +package main + +// The shapes of what the module's steps end on in the steps state (novox/hq ADR 0487 §1, issue 1703e), kept as fixtures in +// testdata/captured for the builder's tests (mesh-delivery-builder copies them): the stored step of a fetch, of a +// candidate's build (no publish) and of a trunk build (publish). They are the step exactly as buildstep writes it to the +// state, with what differs between runs (ids, times, the workspace, the commit, digests, the registry's address) put +// to fixed words. This unit test regenerates them from the fake runner and compares (MESH_UPDATE_CAPTURED=1 rewrites +// them); the integration test through the real bus (bus_integration_test.go) compares the steps the real bus holds with +// the same files, so a change of shape fails in both places. + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +const capturedDir = "testdata/captured" + +var hexDigest = regexp.MustCompile(`sha256:[0-9a-f]{64}`) + +// normalized is a stored step with the words that differ between runs put to fixed ones. +func normalized(t *testing.T, st delivery.Step, workspace, commit, registry string) []byte { + t.Helper() + raw, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + var v map[string]any + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatal(err) + } + v["id"] = "STEPID" + v["started"], v["ended"] = "2026-10-22T08:00:00Z", "2026-10-22T08:00:01Z" + out, _ := json.MarshalIndent(v, "", " ") + s := string(out) + for from, to := range map[string]string{workspace: "WORKSPACE", commit: "COMMIT", registry: "REGISTRY"} { + if from != "" { + s = strings.ReplaceAll(s, from, to) + } + } + s = hexDigest.ReplaceAllString(s, imageDigest) + // The result is stored as a JSON string of an object in some encodings: keep the file readable by decoding it once. + var back map[string]any + if err := json.Unmarshal([]byte(s), &back); err != nil { + t.Fatal(err) + } + pretty, _ := json.MarshalIndent(back, "", " ") + return append(pretty, '\n') +} + +func compareCaptured(t *testing.T, name string, got []byte) { + t.Helper() + path := filepath.Join(capturedDir, name) + if os.Getenv("MESH_UPDATE_CAPTURED") == "1" { + if err := os.WriteFile(path, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s: %v (MESH_UPDATE_CAPTURED=1 writes it)", path, err) + } + if string(want) != string(got) { + t.Errorf("%s differs from what the step ended on:\n--- want\n%s--- got\n%s", name, want, got) + } +} + +func workspaceOf(t *testing.T, st delivery.Step) string { + var r struct { + Workspace string `json:"workspace"` + } + json.Unmarshal(st.Result, &r) + return r.Workspace +} + +func TestTheStoredStepsHaveTheCapturedShapes(t *testing.T) { + _ = time.Now + // fetch + { + f := newFake(t) + states := newStates() + m := moduleWith(t, f, states) + if _, err := tool(t, m, FetchTool)(map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA}); err != nil { + t.Fatal(err) + } + st := states.waitEnd(t) + compareCaptured(t, "fetch.json", normalized(t, st, workspaceOf(t, st), commitSHA, "")) + } + // a candidate's build and a trunk build + for name, extra := range map[string]map[string]any{"build-candidate.json": nil, "build-trunk.json": {"publish": true}} { + m, f, states := setup(t, same) + _, st := runBuild(t, m, states, extra) + compareCaptured(t, name, normalized(t, st, "", "", f.address())) + } +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/helpers_test.go b/modules/mesh-build-image/cmd/mesh-build-image/helpers_test.go index 293f6fa9..876c6685 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/helpers_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/helpers_test.go @@ -72,6 +72,8 @@ type fake struct { pushes int refuse int // the first n pushes answer "connection refused" failBud string + // digestFile: "" writes the digest, "none" writes no file, "empty" writes an empty one. + digestFile string // registry: repository:tag -> digest registry map[string]string server *httptest.Server @@ -138,7 +140,12 @@ func (f *fake) run(ctx context.Context, env []string, name string, args ...strin } dest = strings.TrimPrefix(dest, f.address()+"/") f.registry[dest] = f.digest - os.WriteFile(digestFile, []byte(f.digest), 0o644) + switch f.digestFile { + case "": + os.WriteFile(digestFile, []byte(f.digest), 0o644) + case "empty": + os.WriteFile(digestFile, nil, 0o644) + } } return forgekey.Ran{} } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/push.go b/modules/mesh-build-image/cmd/mesh-build-image/push.go index 56fe6136..a78ed0ce 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/push.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/push.go @@ -64,10 +64,18 @@ func (m *Module) push(ctx context.Context, workspace, module, local string, res }); err != nil { return nil, fmt.Errorf("pushed %s and cannot read back what the registry serves for it: %w", dest, err) } - if raw, err := os.ReadFile(digestFile); err == nil { - if pushed := strings.TrimSpace(string(raw)); pushed != "" && pushed != served { - return nil, fmt.Errorf("pushed %s as %s, but the registry serves %s for it", dest, pushed, served) - } + // A push that succeeded wrote the digest it made: a file missing, unreadable or empty is a fault, and the registry's + // digest is never answered unchecked. + raw, err := os.ReadFile(digestFile) + if err != nil { + return nil, fmt.Errorf("pushed %s, but the digest buildah pushed cannot be read from %s, so the registry's %s cannot be checked: %w", dest, digestFile, served, err) + } + pushed := strings.TrimSpace(string(raw)) + if !digestOK.MatchString(pushed) { + return nil, fmt.Errorf("pushed %s, but the digest file %s holds %q, not a digest, so the registry's %s cannot be checked", dest, digestFile, pushed, served) + } + if pushed != served { + return nil, fmt.Errorf("pushed %s as %s, but the registry serves %s for it", dest, pushed, served) } res.Digest, res.Pushed = served, true res.Reference = addr + "/" + repository + "@" + served diff --git a/modules/mesh-build-image/cmd/mesh-build-image/push_test.go b/modules/mesh-build-image/cmd/mesh-build-image/push_test.go index a482658c..fa882ec2 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/push_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/push_test.go @@ -141,3 +141,16 @@ func writeFile(t *testing.T, path, body string) { t.Fatal(err) } } + +// A push that succeeded wrote its digest file: one missing, unreadable or empty is a fault, and the registry's digest is +// never answered unchecked (review round 1). +func TestAPushWithoutAReadableDigestFileFailsTheStep(t *testing.T) { + for _, kind := range []string{"none", "empty"} { + m, f, states := setup(t, same) + f.digestFile = kind + _, st := runBuild(t, m, states, map[string]any{"publish": true}) + if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "digest") { + t.Errorf("%s: step = %+v, want failed naming the digest file", kind, st) + } + } +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/testdata/captured/README b/modules/mesh-build-image/cmd/mesh-build-image/testdata/captured/README new file mode 100644 index 00000000..11ed2340 --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/testdata/captured/README @@ -0,0 +1,5 @@ +The stored steps (what a step ends on in the module's `steps` state) of a fetch (fetch.json), a candidate's build +(build-candidate.json, no publish) and a trunk build (build-trunk.json, publish), as buildstep writes them, with ids, times, +the workspace, the commit, digests and the registry's address put to fixed words (STEPID, WORKSPACE, COMMIT, REGISTRY, one +sha256 digest). Written by capture_test.go (MESH_UPDATE_CAPTURED=1) from the unit tests' fake runner on 2026-10-22, and +compared by bus_integration_test.go with the steps a real bus holds. mesh-delivery-builder's tests copy these files. diff --git a/modules/mesh-build-image/cmd/mesh-build-image/testdata/captured/build-candidate.json b/modules/mesh-build-image/cmd/mesh-build-image/testdata/captured/build-candidate.json new file mode 100644 index 00000000..ee226619 --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/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-build-image/cmd/mesh-build-image/testdata/captured/build-trunk.json b/modules/mesh-build-image/cmd/mesh-build-image/testdata/captured/build-trunk.json new file mode 100644 index 00000000..db897d3d --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/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-build-image/cmd/mesh-build-image/testdata/captured/fetch.json b/modules/mesh-build-image/cmd/mesh-build-image/testdata/captured/fetch.json new file mode 100644 index 00000000..6f893f4e --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/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 +} diff --git a/modules/mesh-build-image/go.mod b/modules/mesh-build-image/go.mod index 690465cd..ca0bfead 100644 --- a/modules/mesh-build-image/go.mod +++ b/modules/mesh-build-image/go.mod @@ -2,4 +2,21 @@ module meshbuildimage go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc +require ( + 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 +) + +require ( + github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/klauspost/compress v1.20.0 // indirect + github.com/minio/highwayhash v1.0.4 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/time v0.15.0 // indirect +) diff --git a/modules/mesh-build-image/go.sum b/modules/mesh-build-image/go.sum index 638b2811..3515fb85 100644 --- a/modules/mesh-build-image/go.sum +++ b/modules/mesh-build-image/go.sum @@ -1,2 +1,27 @@ 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= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=