Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 14 additions & 5 deletions service/runway/server/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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"],
)

Expand Down Expand Up @@ -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__"],
)
19 changes: 13 additions & 6 deletions service/stovepipe/server/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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"],
Expand Down Expand Up @@ -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__"],
)
21 changes: 15 additions & 6 deletions service/submitqueue/gateway/server/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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"],
)

Expand Down Expand Up @@ -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(
Expand Down
19 changes: 14 additions & 5 deletions service/submitqueue/orchestrator/server/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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"],
)

Expand Down Expand Up @@ -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__"],
)
3 changes: 1 addition & 2 deletions test/e2e/stovepipe/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
10 changes: 3 additions & 7 deletions test/e2e/submitqueue/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
3 changes: 1 addition & 2 deletions test/integration/stovepipe/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
4 changes: 1 addition & 3 deletions test/integration/submitqueue/gateway/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
3 changes: 1 addition & 2 deletions test/integration/submitqueue/orchestrator/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
88 changes: 63 additions & 25 deletions test/testutil/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ import (
"crypto/sha256"
"database/sql"
"fmt"
"hash"
"io"
"os"
"os/exec"
"os/user"
"path/filepath"
"sort"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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}
Expand All @@ -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
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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)
Expand Down
Loading