diff --git a/AGENTS.md b/AGENTS.md index 5af69bad..71ee93d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -309,7 +309,7 @@ deps = [ **Integration tests** use Docker Compose via `testutil.ComposeStack`: - Package naming: folder name as package (NOT `*_test` suffix) -- Bazel: add `tags = ["integration", "requires-network"]` and `data = [...]` for every input the test reads (compose file, schema dirs, Dockerfiles, Bazel-built `*_linux` service binaries). Tests are hermetic: never resolve the repo root — resolve inputs from runfiles via `testutil.Runfile` and stage docker build contexts with `testutil.WithBuildContext`. +- Bazel: add `tags = ["integration", "requires-network"]` and `data = [...]` for every input the test reads (compose file, schema dirs, and each service's `docker_test_context` filegroup bundling its Dockerfile, configs, and Bazel-built `*_linux` binary). Tests are hermetic: never resolve the repo root — resolve inputs from runfiles via `testutil.Runfile` and stage docker build contexts with `testutil.WithBuildContext`. - Use `testutil.NewComposeStack()` with meaningful context (e.g., `"ext-storage-mysql"`) See [doc/howto/TESTING.md](doc/howto/TESTING.md) for full testing guide. diff --git a/service/runway/server/BUILD.bazel b/service/runway/server/BUILD.bazel index fec51e28..6ef3fe82 100644 --- a/service/runway/server/BUILD.bazel +++ b/service/runway/server/BUILD.bazel @@ -1,10 +1,7 @@ load("@rules_go//go:def.bzl", "go_binary", "go_cross_binary", "go_library") exports_files( - [ - "Dockerfile", - "docker-compose.yml", - ], + ["docker-compose.yml"], visibility = ["//visibility:public"], ) @@ -48,5 +45,17 @@ go_cross_binary( name = "runway_linux", platform = "@rules_go//go/toolchain:linux_amd64", target = ":runway", - visibility = ["//visibility:public"], +) + +# Everything a Docker-based test needs to build the runway image from a staged +# build context (see testutil.WithBuildContext). Test-only so the +# cross-compiled binary cannot leak into production targets. +filegroup( + name = "docker_test_context", + testonly = True, + srcs = [ + "Dockerfile", + ":runway_linux", + ], + visibility = ["//test:__subpackages__"], ) diff --git a/service/stovepipe/server/BUILD.bazel b/service/stovepipe/server/BUILD.bazel index ddc13418..214e5c6c 100644 --- a/service/stovepipe/server/BUILD.bazel +++ b/service/stovepipe/server/BUILD.bazel @@ -1,10 +1,5 @@ load("@rules_go//go:def.bzl", "go_binary", "go_cross_binary", "go_library") -exports_files( - ["Dockerfile"], - visibility = ["//visibility:public"], -) - go_library( name = "go_default_library", srcs = ["main.go"], @@ -53,5 +48,17 @@ go_cross_binary( name = "stovepipe_linux", platform = "@rules_go//go/toolchain:linux_amd64", target = ":stovepipe", - visibility = ["//visibility:public"], +) + +# Everything a Docker-based test needs to build the stovepipe image from a +# staged build context (see testutil.WithBuildContext). Test-only so the +# cross-compiled binary cannot leak into production targets. +filegroup( + name = "docker_test_context", + testonly = True, + srcs = [ + "Dockerfile", + ":stovepipe_linux", + ], + visibility = ["//test:__subpackages__"], ) diff --git a/service/submitqueue/gateway/server/BUILD.bazel b/service/submitqueue/gateway/server/BUILD.bazel index 711d1279..01fecd7a 100644 --- a/service/submitqueue/gateway/server/BUILD.bazel +++ b/service/submitqueue/gateway/server/BUILD.bazel @@ -1,11 +1,7 @@ load("@rules_go//go:def.bzl", "go_binary", "go_cross_binary", "go_library", "go_test") exports_files( - [ - "Dockerfile", - "docker-compose.yml", - "queues.yaml", - ], + ["docker-compose.yml"], visibility = ["//visibility:public"], ) @@ -52,7 +48,20 @@ go_cross_binary( name = "gateway_linux", platform = "@rules_go//go/toolchain:linux_amd64", target = ":gateway", - visibility = ["//visibility:public"], +) + +# Everything a Docker-based test needs to build the gateway image from a staged +# build context (see testutil.WithBuildContext). Test-only so the +# cross-compiled binary cannot leak into production targets. +filegroup( + name = "docker_test_context", + testonly = True, + srcs = [ + "Dockerfile", + "queues.yaml", + ":gateway_linux", + ], + visibility = ["//test:__subpackages__"], ) go_test( diff --git a/service/submitqueue/orchestrator/server/BUILD.bazel b/service/submitqueue/orchestrator/server/BUILD.bazel index 0b64c2e7..7a7b88a8 100644 --- a/service/submitqueue/orchestrator/server/BUILD.bazel +++ b/service/submitqueue/orchestrator/server/BUILD.bazel @@ -1,10 +1,7 @@ load("@rules_go//go:def.bzl", "go_binary", "go_cross_binary", "go_library") exports_files( - [ - "Dockerfile", - "docker-compose.yml", - ], + ["docker-compose.yml"], visibility = ["//visibility:public"], ) @@ -82,5 +79,17 @@ go_cross_binary( name = "orchestrator_linux", platform = "@rules_go//go/toolchain:linux_amd64", target = ":orchestrator", - visibility = ["//visibility:public"], +) + +# Everything a Docker-based test needs to build the orchestrator image from a +# staged build context (see testutil.WithBuildContext). Test-only so the +# cross-compiled binary cannot leak into production targets. +filegroup( + name = "docker_test_context", + testonly = True, + srcs = [ + "Dockerfile", + ":orchestrator_linux", + ], + visibility = ["//test:__subpackages__"], ) diff --git a/test/e2e/stovepipe/BUILD.bazel b/test/e2e/stovepipe/BUILD.bazel index c599fdfd..75e7ae96 100644 --- a/test/e2e/stovepipe/BUILD.bazel +++ b/test/e2e/stovepipe/BUILD.bazel @@ -9,8 +9,7 @@ go_test( data = [ "//platform/extension/messagequeue/mysql/schema", "//service/stovepipe:docker-compose.yml", - "//service/stovepipe/server:Dockerfile", - "//service/stovepipe/server:stovepipe_linux", + "//service/stovepipe/server:docker_test_context", "//stovepipe/extension/storage/mysql/schema", ], tags = [ diff --git a/test/e2e/submitqueue/BUILD.bazel b/test/e2e/submitqueue/BUILD.bazel index da0f7b0f..7afbac82 100644 --- a/test/e2e/submitqueue/BUILD.bazel +++ b/test/e2e/submitqueue/BUILD.bazel @@ -9,14 +9,10 @@ go_test( data = [ "//platform/extension/counter/mysql/schema", "//platform/extension/messagequeue/mysql/schema", - "//service/runway/server:Dockerfile", - "//service/runway/server:runway_linux", + "//service/runway/server:docker_test_context", "//service/submitqueue:docker-compose.yml", - "//service/submitqueue/gateway/server:Dockerfile", - "//service/submitqueue/gateway/server:gateway_linux", - "//service/submitqueue/gateway/server:queues.yaml", - "//service/submitqueue/orchestrator/server:Dockerfile", - "//service/submitqueue/orchestrator/server:orchestrator_linux", + "//service/submitqueue/gateway/server:docker_test_context", + "//service/submitqueue/orchestrator/server:docker_test_context", "//submitqueue/extension/storage/mysql/schema", ], tags = [ diff --git a/test/integration/stovepipe/BUILD.bazel b/test/integration/stovepipe/BUILD.bazel index 457b8113..320fca6c 100644 --- a/test/integration/stovepipe/BUILD.bazel +++ b/test/integration/stovepipe/BUILD.bazel @@ -6,8 +6,7 @@ go_test( data = [ "//platform/extension/messagequeue/mysql/schema", "//service/stovepipe:docker-compose.yml", - "//service/stovepipe/server:Dockerfile", - "//service/stovepipe/server:stovepipe_linux", + "//service/stovepipe/server:docker_test_context", "//stovepipe/extension/storage/mysql/schema", ], tags = [ diff --git a/test/integration/submitqueue/gateway/BUILD.bazel b/test/integration/submitqueue/gateway/BUILD.bazel index 5fa2fb34..19476900 100644 --- a/test/integration/submitqueue/gateway/BUILD.bazel +++ b/test/integration/submitqueue/gateway/BUILD.bazel @@ -6,10 +6,8 @@ go_test( data = [ "//platform/extension/counter/mysql/schema", "//platform/extension/messagequeue/mysql/schema", - "//service/submitqueue/gateway/server:Dockerfile", "//service/submitqueue/gateway/server:docker-compose.yml", - "//service/submitqueue/gateway/server:gateway_linux", - "//service/submitqueue/gateway/server:queues.yaml", + "//service/submitqueue/gateway/server:docker_test_context", "//submitqueue/extension/storage/mysql/schema", ], tags = [ diff --git a/test/integration/submitqueue/orchestrator/BUILD.bazel b/test/integration/submitqueue/orchestrator/BUILD.bazel index 1df538b8..a81f9d01 100644 --- a/test/integration/submitqueue/orchestrator/BUILD.bazel +++ b/test/integration/submitqueue/orchestrator/BUILD.bazel @@ -6,9 +6,8 @@ go_test( data = [ "//platform/extension/counter/mysql/schema", "//platform/extension/messagequeue/mysql/schema", - "//service/submitqueue/orchestrator/server:Dockerfile", "//service/submitqueue/orchestrator/server:docker-compose.yml", - "//service/submitqueue/orchestrator/server:orchestrator_linux", + "//service/submitqueue/orchestrator/server:docker_test_context", "//submitqueue/extension/storage/mysql/schema", ], tags = [ diff --git a/test/testutil/compose.go b/test/testutil/compose.go index 52754a8a..085a64eb 100644 --- a/test/testutil/compose.go +++ b/test/testutil/compose.go @@ -19,10 +19,13 @@ import ( "crypto/sha256" "database/sql" "fmt" + "hash" + "io" "os" "os/exec" "os/user" "path/filepath" + "sort" "strings" "testing" "time" @@ -99,9 +102,15 @@ func NewComposeStack(t *testing.T, log *TestLogger, ctx context.Context, compose absPath, err := filepath.Abs(composeFile) require.NoError(t, err, "failed to get absolute path to compose file") + // The image prefix is derived from the content of the declared inputs, so + // the compose file participates in the hash alongside the staged build + // context files. + inputHash := sha256.New() + hashFile(t, inputHash, composeFile, absPath) + buildContextDir := "" if len(options.buildContextFiles) > 0 { - buildContextDir = stageBuildContext(t, options.buildContextFiles) + buildContextDir = stageBuildContext(t, inputHash, options.buildContextFiles) } // Generate meaningful project name: sq-test-{context}-{short-timestamp} @@ -116,7 +125,7 @@ func NewComposeStack(t *testing.T, log *TestLogger, ctx context.Context, compose log: log, ctx: ctx, composeCmd: getDockerComposeCommand(), - composeEnv: composeEnvironment(buildContextDir), + composeEnv: composeEnvironment(inputHash.Sum(nil), buildContextDir), } // Register cleanup @@ -160,8 +169,8 @@ func (s *ComposeStack) Up() error { } // down stops and removes all services and volumes in the compose stack. -// Locally built images use stable per-worktree names and remain cached for -// subsequent test runs. +// Locally built images are named after the hash of their declared inputs and +// remain cached for subsequent runs with the same build context. func (s *ComposeStack) down() { s.log.Logf("Stopping compose stack") @@ -411,38 +420,67 @@ func setupDockerEnv(t *testing.T) { } // stageBuildContext copies the given runfiles into a temporary directory that -// serves as the docker build context. Runfiles are symlinks into Bazel's -// output tree, and docker's context upload does not follow symlinks, so the -// content is copied. Files are staged with the execute bit set because the -// context contains service binaries; docker preserves the mode on COPY. -func stageBuildContext(t *testing.T, files map[string]string) string { +// serves as the docker build context, feeding each destination path and file +// content into inputHash in deterministic (sorted) order. Runfiles are +// symlinks into Bazel's output tree, and docker's context upload does not +// follow symlinks, so the content is copied. Each staged file keeps its source +// mode, so service binaries stay executable; docker preserves the mode on COPY. +func stageBuildContext(t *testing.T, inputHash hash.Hash, files map[string]string) string { t.Helper() + dests := make([]string, 0, len(files)) + for dest := range files { + dests = append(dests, dest) + } + sort.Strings(dests) + dir := t.TempDir() - for dest, src := range files { - content, err := os.ReadFile(Runfile(src)) - require.NoError(t, err, "failed to read build context input %s (is it declared as a data dependency?)", src) + for _, dest := range dests { + src := files[dest] + srcFile, err := os.Open(Runfile(src)) + require.NoError(t, err, "failed to open build context input %s (is it declared as a data dependency?)", src) + + info, err := srcFile.Stat() + require.NoError(t, err, "failed to stat build context input %s", src) destPath := filepath.Join(dir, dest) require.NoError(t, os.MkdirAll(filepath.Dir(destPath), 0o755), "failed to create build context dir for %s", dest) - require.NoError(t, os.WriteFile(destPath, content, 0o755), "failed to stage build context file %s", dest) + + destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm()) + require.NoError(t, err, "failed to create build context file %s", dest) + + _, _ = io.WriteString(inputHash, dest) + _, _ = inputHash.Write([]byte{0}) + _, err = io.Copy(io.MultiWriter(destFile, inputHash), srcFile) + require.NoError(t, err, "failed to stage build context file %s", dest) + require.NoError(t, destFile.Close(), "failed to close build context file %s", dest) + require.NoError(t, srcFile.Close(), "failed to close build context input %s", src) } return dir } +// hashFile feeds a logical path marker and the content of the file at path +// into inputHash. +func hashFile(t *testing.T, inputHash hash.Hash, logicalPath, path string) { + t.Helper() + + _, _ = io.WriteString(inputHash, logicalPath) + _, _ = inputHash.Write([]byte{0}) + + f, err := os.Open(path) + require.NoError(t, err, "failed to open declared input %s", logicalPath) + _, err = io.Copy(inputHash, f) + require.NoError(t, err, "failed to hash declared input %s", logicalPath) + require.NoError(t, f.Close(), "failed to close declared input %s", logicalPath) +} + // composeEnvironment returns the environment used by every compose command. -// The image prefix is stable per test target so --build can reuse the docker -// build cache between runs of the same test. Concurrent runs of the same -// target against one daemon race on the tag, but every run rebuilds via -// `up --build` from its own staged context, so a stale image is never used -// for a completed run. -func composeEnvironment(buildContextDir string) []string { - seed := os.Getenv("TEST_TARGET") - if seed == "" { - seed, _ = os.Getwd() - } - sum := sha256.Sum256([]byte(seed)) - imagePrefix := fmt.Sprintf("sq-test-%x", sum[:6]) +// The image prefix is derived from the content of the declared inputs (compose +// file plus staged build context), so runs with identical inputs reuse the +// docker build cache while different revisions — including concurrent runs +// from separate worktrees against one daemon — never collide on an image tag. +func composeEnvironment(inputHash []byte, buildContextDir string) []string { + imagePrefix := fmt.Sprintf("sq-test-%x", inputHash[:6]) env := os.Environ() env = append(env, "SQ_DOCKER_IMAGE_PREFIX="+imagePrefix)