Fail a push whose digest file cannot be read; test the image steps through a real bus against captured shapes (issue 1703)

This commit is contained in:
2026-10-22 10:34:37 +02:00
parent 692fc3e1ab
commit 3dcaa7a996
11 changed files with 382 additions and 6 deletions
@@ -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.<name>": 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)
}
}
@@ -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()))
}
}
@@ -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{}
}
@@ -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
@@ -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)
}
}
}
@@ -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.
@@ -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
}
+18 -1
View File
@@ -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
)
+25
View File
@@ -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=