diff --git a/service/runway/server/docker-compose.yml b/service/runway/server/docker-compose.yml index d39026ab..25bb7f8d 100644 --- a/service/runway/server/docker-compose.yml +++ b/service/runway/server/docker-compose.yml @@ -19,18 +19,23 @@ services: environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: submitqueue + MYSQL_INITDB_SKIP_TZINFO: ${SQ_MYSQL_INITDB_SKIP_TZINFO:-} ports: - "3306" # Random ephemeral port to avoid conflicts + volumes: + - type: ${SQ_MYSQL_DATA_MOUNT_TYPE:-volume} + target: /var/lib/mysql healthcheck: # Use 127.0.0.1 (TCP) instead of localhost (Unix socket). MySQL treats # "localhost" as a socket connection, which can be ready before the TCP # listener — causing dependent services that connect over TCP to fail. test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"] - interval: 5s - timeout: 5s - retries: 10 + interval: 1s + timeout: 2s + retries: 60 runway-service: + image: ${SQ_DOCKER_IMAGE_PREFIX:-submitqueue}-runway-service:latest build: context: ${REPO_ROOT} dockerfile: service/runway/server/Dockerfile diff --git a/service/stovepipe/docker-compose.yml b/service/stovepipe/docker-compose.yml index 2dadd8d4..3c816c21 100644 --- a/service/stovepipe/docker-compose.yml +++ b/service/stovepipe/docker-compose.yml @@ -23,16 +23,20 @@ services: environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: submitqueue + MYSQL_INITDB_SKIP_TZINFO: ${SQ_MYSQL_INITDB_SKIP_TZINFO:-} ports: - "3306" # Random ephemeral port to avoid conflicts + volumes: + - type: ${SQ_MYSQL_DATA_MOUNT_TYPE:-volume} + target: /var/lib/mysql healthcheck: # Use 127.0.0.1 (TCP) instead of localhost (Unix socket). MySQL treats # "localhost" as a socket connection, which can be ready before the TCP # listener — causing dependent services that connect over TCP to fail. test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"] - interval: 5s - timeout: 5s - retries: 10 + interval: 1s + timeout: 2s + retries: 60 # Queue database - messaging infrastructure (messages, offsets, partition leases). mysql-queue: @@ -40,15 +44,20 @@ services: environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: submitqueue + MYSQL_INITDB_SKIP_TZINFO: ${SQ_MYSQL_INITDB_SKIP_TZINFO:-} ports: - "3306" # Random ephemeral port to avoid conflicts + volumes: + - type: ${SQ_MYSQL_DATA_MOUNT_TYPE:-volume} + target: /var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"] - interval: 5s - timeout: 5s - retries: 10 + interval: 1s + timeout: 2s + retries: 60 stovepipe-service: + image: ${SQ_DOCKER_IMAGE_PREFIX:-submitqueue}-stovepipe-service:latest build: context: ${REPO_ROOT} dockerfile: service/stovepipe/server/Dockerfile diff --git a/service/submitqueue/docker-compose.yml b/service/submitqueue/docker-compose.yml index e8d2c4bd..2b11dc2f 100644 --- a/service/submitqueue/docker-compose.yml +++ b/service/submitqueue/docker-compose.yml @@ -15,16 +15,20 @@ services: environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: submitqueue + MYSQL_INITDB_SKIP_TZINFO: ${SQ_MYSQL_INITDB_SKIP_TZINFO:-} ports: - "3306" # Random ephemeral port to avoid conflicts + volumes: + - type: ${SQ_MYSQL_DATA_MOUNT_TYPE:-volume} + target: /var/lib/mysql healthcheck: # Use 127.0.0.1 (TCP) instead of localhost (Unix socket). MySQL treats # "localhost" as a socket connection, which can be ready before the TCP # listener — causing dependent services that connect over TCP to fail. test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"] - interval: 5s - timeout: 5s - retries: 10 + interval: 1s + timeout: 2s + retries: 60 # Queue Database - Messaging infrastructure (messages, offsets, partition leases) # Separate from app DB to demonstrate queue is pluggable infrastructure @@ -33,15 +37,20 @@ services: environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: submitqueue + MYSQL_INITDB_SKIP_TZINFO: ${SQ_MYSQL_INITDB_SKIP_TZINFO:-} ports: - "3306" # Random ephemeral port to avoid conflicts + volumes: + - type: ${SQ_MYSQL_DATA_MOUNT_TYPE:-volume} + target: /var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"] - interval: 5s - timeout: 5s - retries: 10 + interval: 1s + timeout: 2s + retries: 60 gateway-service: + image: ${SQ_DOCKER_IMAGE_PREFIX:-submitqueue}-gateway-service:latest build: context: ${REPO_ROOT} dockerfile: service/submitqueue/gateway/server/Dockerfile @@ -64,6 +73,7 @@ services: condition: service_healthy orchestrator-service: + image: ${SQ_DOCKER_IMAGE_PREFIX:-submitqueue}-orchestrator-service:latest build: context: ${REPO_ROOT} dockerfile: service/submitqueue/orchestrator/server/Dockerfile @@ -88,6 +98,7 @@ services: # needs the queue DB (shared with the orchestrator over mysql-queue); it has # no application database of its own. runway-service: + image: ${SQ_DOCKER_IMAGE_PREFIX:-submitqueue}-runway-service:latest build: context: ${REPO_ROOT} dockerfile: service/runway/server/Dockerfile diff --git a/service/submitqueue/gateway/server/docker-compose.yml b/service/submitqueue/gateway/server/docker-compose.yml index 6cc666c8..7d439b60 100644 --- a/service/submitqueue/gateway/server/docker-compose.yml +++ b/service/submitqueue/gateway/server/docker-compose.yml @@ -15,16 +15,20 @@ services: environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: submitqueue + MYSQL_INITDB_SKIP_TZINFO: ${SQ_MYSQL_INITDB_SKIP_TZINFO:-} ports: - "3306" # Random ephemeral port to avoid conflicts + volumes: + - type: ${SQ_MYSQL_DATA_MOUNT_TYPE:-volume} + target: /var/lib/mysql healthcheck: # Use 127.0.0.1 (TCP) instead of localhost (Unix socket). MySQL treats # "localhost" as a socket connection, which can be ready before the TCP # listener — causing dependent services that connect over TCP to fail. test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"] - interval: 5s - timeout: 5s - retries: 10 + interval: 1s + timeout: 2s + retries: 60 # Queue Database - Messaging infrastructure (messages, offsets, partition leases) # Separate from app DB to demonstrate queue is pluggable infrastructure @@ -33,15 +37,20 @@ services: environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: submitqueue + MYSQL_INITDB_SKIP_TZINFO: ${SQ_MYSQL_INITDB_SKIP_TZINFO:-} ports: - "3306" # Random ephemeral port to avoid conflicts + volumes: + - type: ${SQ_MYSQL_DATA_MOUNT_TYPE:-volume} + target: /var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"] - interval: 5s - timeout: 5s - retries: 10 + interval: 1s + timeout: 2s + retries: 60 gateway-service: + image: ${SQ_DOCKER_IMAGE_PREFIX:-submitqueue}-gateway-service:latest build: context: ${REPO_ROOT} dockerfile: service/submitqueue/gateway/server/Dockerfile diff --git a/service/submitqueue/orchestrator/server/docker-compose.yml b/service/submitqueue/orchestrator/server/docker-compose.yml index 90db979f..b9e20150 100644 --- a/service/submitqueue/orchestrator/server/docker-compose.yml +++ b/service/submitqueue/orchestrator/server/docker-compose.yml @@ -15,16 +15,20 @@ services: environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: submitqueue + MYSQL_INITDB_SKIP_TZINFO: ${SQ_MYSQL_INITDB_SKIP_TZINFO:-} ports: - "3306" # Random ephemeral port to avoid conflicts + volumes: + - type: ${SQ_MYSQL_DATA_MOUNT_TYPE:-volume} + target: /var/lib/mysql healthcheck: # Use 127.0.0.1 (TCP) instead of localhost (Unix socket). MySQL treats # "localhost" as a socket connection, which can be ready before the TCP # listener — causing dependent services that connect over TCP to fail. test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"] - interval: 5s - timeout: 5s - retries: 10 + interval: 1s + timeout: 2s + retries: 60 # Queue Database - Messaging infrastructure (messages, offsets, partition leases) # Separate from app DB to demonstrate queue is pluggable infrastructure @@ -33,15 +37,20 @@ services: environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: submitqueue + MYSQL_INITDB_SKIP_TZINFO: ${SQ_MYSQL_INITDB_SKIP_TZINFO:-} ports: - "3306" # Random ephemeral port to avoid conflicts + volumes: + - type: ${SQ_MYSQL_DATA_MOUNT_TYPE:-volume} + target: /var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"] - interval: 5s - timeout: 5s - retries: 10 + interval: 1s + timeout: 2s + retries: 60 orchestrator-service: + image: ${SQ_DOCKER_IMAGE_PREFIX:-submitqueue}-orchestrator-service:latest build: context: ${REPO_ROOT} dockerfile: service/submitqueue/orchestrator/server/Dockerfile diff --git a/test/integration/extension/counter/mysql/docker-compose.yml b/test/integration/extension/counter/mysql/docker-compose.yml index 9b38b659..8dcde901 100644 --- a/test/integration/extension/counter/mysql/docker-compose.yml +++ b/test/integration/extension/counter/mysql/docker-compose.yml @@ -8,13 +8,17 @@ services: environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: submitqueue + MYSQL_INITDB_SKIP_TZINFO: ${SQ_MYSQL_INITDB_SKIP_TZINFO:-} ports: - "3306" # Random ephemeral port to avoid conflicts + volumes: + - type: ${SQ_MYSQL_DATA_MOUNT_TYPE:-volume} + target: /var/lib/mysql healthcheck: # Use 127.0.0.1 (TCP) instead of localhost (Unix socket). MySQL treats # "localhost" as a socket connection, which can be ready before the TCP # listener — causing dependent services that connect over TCP to fail. test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"] - interval: 5s - timeout: 5s - retries: 10 + interval: 1s + timeout: 2s + retries: 60 diff --git a/test/integration/extension/messagequeue/mysql/docker-compose.yml b/test/integration/extension/messagequeue/mysql/docker-compose.yml index 4ae98dc7..a4862bb2 100644 --- a/test/integration/extension/messagequeue/mysql/docker-compose.yml +++ b/test/integration/extension/messagequeue/mysql/docker-compose.yml @@ -8,13 +8,17 @@ services: environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: submitqueue + MYSQL_INITDB_SKIP_TZINFO: ${SQ_MYSQL_INITDB_SKIP_TZINFO:-} ports: - "3306" # Random ephemeral port to avoid conflicts + volumes: + - type: ${SQ_MYSQL_DATA_MOUNT_TYPE:-volume} + target: /var/lib/mysql healthcheck: # Use 127.0.0.1 (TCP) instead of localhost (Unix socket). MySQL treats # "localhost" as a socket connection, which can be ready before the TCP # listener — causing dependent services that connect over TCP to fail. test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"] - interval: 5s - timeout: 5s - retries: 10 + interval: 1s + timeout: 2s + retries: 60 diff --git a/test/integration/extension/messagequeue/mysql/queue_test.go b/test/integration/extension/messagequeue/mysql/queue_test.go index 4bebfe72..ed006a6e 100644 --- a/test/integration/extension/messagequeue/mysql/queue_test.go +++ b/test/integration/extension/messagequeue/mysql/queue_test.go @@ -291,6 +291,40 @@ func waitForCondition(t *testing.T, signalCh <-chan queueMySQL.HookSignal, condi } } +// waitForLag waits for subscriber polls until the selected consumer lag +// converges to expected. Lag must not advance past the expected value. +func waitForLag( + t *testing.T, + ctx context.Context, + admin *queueAdmin.AdminStore, + signalCh <-chan queueMySQL.HookSignal, + topic string, + consumerGroup string, + partitionKey string, + expected int64, +) int64 { + t.Helper() + + for { + lags, err := admin.ConsumerLag(ctx, topic) + require.NoError(t, err) + + var actual int64 = -1 + for _, lag := range lags { + if lag.ConsumerGroup == consumerGroup && lag.PartitionKey == partitionKey { + actual = lag.Lag + break + } + } + + require.GreaterOrEqual(t, actual, expected, "watermark advanced past expected lag") + if actual == expected { + return actual + } + waitForSignal(t, signalCh, queueMySQL.SignalDeliveryCheck) + } +} + func (s *SQLQueueIntegrationSuite) TestPublishAndSubscribe() { t := s.T() @@ -2396,18 +2430,9 @@ func (s *SQLQueueIntegrationSuite) TestWatermarkAdvancesContiguously() { admin := queueAdmin.NewAdminStore(s.db) - // Helper to get consumer lag - getLag := func() int64 { - lags, err := admin.ConsumerLag(s.ctx, topic) - require.NoError(t, err) - for _, lag := range lags { - if lag.ConsumerGroup == "watermark-cg" && lag.PartitionKey == "wm-part" { - return lag.Lag - } - } - return -1 - } - + // Watermark advancement is incremental and runs in the subscriber poll loop. + // A delivery-check signal proves one poll completed, but the watermark may + // need another poll to converge after several acknowledgements. // Ack message 3 first (out of order) require.NoError(t, deliveries["wm-msg-3"].Ack(s.ctx)) t.Logf("Acked msg-3") @@ -2417,11 +2442,8 @@ func (s *SQLQueueIntegrationSuite) TestWatermarkAdvancesContiguously() { require.NoError(t, deliveries["wm-msg-2"].Ack(s.ctx)) t.Logf("Acked msg-1 and msg-2") - // Wait for poll loop to advance watermark - waitForSignal(t, signalCh, queueMySQL.SignalDeliveryCheck) - // After acking 1,2,3: watermark should advance to 3, lag should be 2 (msg-4, msg-5) - lag := getLag() + lag := waitForLag(t, s.ctx, admin, signalCh, topic, "watermark-cg", "wm-part", 2) assert.Equal(t, int64(2), lag, "lag should be 2 after acking 1,2,3 (4 and 5 remain)") t.Logf("After acking 1,2,3: lag=%d", lag) @@ -2429,9 +2451,7 @@ func (s *SQLQueueIntegrationSuite) TestWatermarkAdvancesContiguously() { require.NoError(t, deliveries["wm-msg-5"].Ack(s.ctx)) t.Logf("Acked msg-5 (skipping msg-4)") - waitForSignal(t, signalCh, queueMySQL.SignalDeliveryCheck) - - lag = getLag() + lag = waitForLag(t, s.ctx, admin, signalCh, topic, "watermark-cg", "wm-part", 2) assert.Equal(t, int64(2), lag, "lag should still be 2 after acking 5 but not 4") t.Logf("After acking 5 (not 4): lag=%d", lag) @@ -2439,9 +2459,7 @@ func (s *SQLQueueIntegrationSuite) TestWatermarkAdvancesContiguously() { require.NoError(t, deliveries["wm-msg-4"].Ack(s.ctx)) t.Logf("Acked msg-4") - waitForSignal(t, signalCh, queueMySQL.SignalDeliveryCheck) - - lag = getLag() + lag = waitForLag(t, s.ctx, admin, signalCh, topic, "watermark-cg", "wm-part", 0) assert.Equal(t, int64(0), lag, "lag should be 0 after acking all 5 messages") t.Logf("After acking all 5: lag=%d", lag) diff --git a/test/integration/stovepipe/extension/storage/mysql/docker-compose.yml b/test/integration/stovepipe/extension/storage/mysql/docker-compose.yml index 79d574b3..e423a5fd 100644 --- a/test/integration/stovepipe/extension/storage/mysql/docker-compose.yml +++ b/test/integration/stovepipe/extension/storage/mysql/docker-compose.yml @@ -8,13 +8,17 @@ services: MYSQL_ROOT_PASSWORD: root # Database name must match the shared testutil DSN (ConnectMySQLService). MYSQL_DATABASE: submitqueue + MYSQL_INITDB_SKIP_TZINFO: ${SQ_MYSQL_INITDB_SKIP_TZINFO:-} ports: - "3306" # Random ephemeral port to avoid conflicts + volumes: + - type: ${SQ_MYSQL_DATA_MOUNT_TYPE:-volume} + target: /var/lib/mysql healthcheck: # Use 127.0.0.1 (TCP) instead of localhost (Unix socket). MySQL treats # "localhost" as a socket connection, which can be ready before the TCP # listener — causing dependent services that connect over TCP to fail. test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"] - interval: 5s - timeout: 5s - retries: 10 + interval: 1s + timeout: 2s + retries: 60 diff --git a/test/integration/submitqueue/core/consumer/docker-compose.yml b/test/integration/submitqueue/core/consumer/docker-compose.yml index 9268a9c1..86cdb42a 100644 --- a/test/integration/submitqueue/core/consumer/docker-compose.yml +++ b/test/integration/submitqueue/core/consumer/docker-compose.yml @@ -8,13 +8,17 @@ services: environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: submitqueue + MYSQL_INITDB_SKIP_TZINFO: ${SQ_MYSQL_INITDB_SKIP_TZINFO:-} ports: - "3306" # Random ephemeral port to avoid conflicts + volumes: + - type: ${SQ_MYSQL_DATA_MOUNT_TYPE:-volume} + target: /var/lib/mysql healthcheck: # Use 127.0.0.1 (TCP) instead of localhost (Unix socket). MySQL treats # "localhost" as a socket connection, which can be ready before the TCP # listener — causing dependent services that connect over TCP to fail. test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"] - interval: 5s - timeout: 5s - retries: 10 + interval: 1s + timeout: 2s + retries: 60 diff --git a/test/integration/submitqueue/extension/storage/mysql/docker-compose.yml b/test/integration/submitqueue/extension/storage/mysql/docker-compose.yml index 9ccb3fcc..62bb5cf3 100644 --- a/test/integration/submitqueue/extension/storage/mysql/docker-compose.yml +++ b/test/integration/submitqueue/extension/storage/mysql/docker-compose.yml @@ -8,13 +8,17 @@ services: environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: submitqueue + MYSQL_INITDB_SKIP_TZINFO: ${SQ_MYSQL_INITDB_SKIP_TZINFO:-} ports: - "3306" # Random ephemeral port to avoid conflicts + volumes: + - type: ${SQ_MYSQL_DATA_MOUNT_TYPE:-volume} + target: /var/lib/mysql healthcheck: # Use 127.0.0.1 (TCP) instead of localhost (Unix socket). MySQL treats # "localhost" as a socket connection, which can be ready before the TCP # listener — causing dependent services that connect over TCP to fail. test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"] - interval: 5s - timeout: 5s - retries: 10 + interval: 1s + timeout: 2s + retries: 60 diff --git a/test/testutil/compose.go b/test/testutil/compose.go index 8f0e0663..9538beb0 100644 --- a/test/testutil/compose.go +++ b/test/testutil/compose.go @@ -16,6 +16,7 @@ package testutil import ( "context" + "crypto/sha256" "database/sql" "fmt" "os" @@ -39,6 +40,7 @@ type ComposeStack struct { log *TestLogger ctx context.Context composeCmd []string // docker-compose command (either ["docker-compose"] or ["docker", "compose"]) + composeEnv []string // environment shared by compose commands logCmd *exec.Cmd // background "docker compose logs -f" process } @@ -79,6 +81,7 @@ func NewComposeStack(t *testing.T, log *TestLogger, ctx context.Context, compose log: log, ctx: ctx, composeCmd: getDockerComposeCommand(), + composeEnv: composeEnvironment(t, absPath), } // Register cleanup @@ -89,7 +92,7 @@ func NewComposeStack(t *testing.T, log *TestLogger, ctx context.Context, compose log.Logf("SKIP_CLEANUP=true - keeping containers for inspection") log.Logf("Container prefix: %s", projectName) composeCmd := strings.Join(stack.composeCmd, " ") - log.Logf("Clean up manually: %s -f %s -p %s down -v --rmi local", composeCmd, absPath, projectName) + log.Logf("Clean up manually: %s -f %s -p %s down -v", composeCmd, absPath, projectName) return } @@ -108,6 +111,7 @@ func (s *ComposeStack) Up() error { args := append(s.composeCmd[1:], "-f", s.composeFile, "-p", s.projectName, "up", "-d", "--build", "--wait") cmd := exec.CommandContext(s.ctx, s.composeCmd[0], args...) + cmd.Env = s.composeEnv cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -120,13 +124,15 @@ func (s *ComposeStack) Up() error { return nil } -// down stops and removes all services in the compose stack. -// Also removes locally built images to prevent accumulation. +// 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. func (s *ComposeStack) down() { - s.log.Logf("Stopping compose stack and removing images") + s.log.Logf("Stopping compose stack") - args := append(s.composeCmd[1:], "-f", s.composeFile, "-p", s.projectName, "down", "-v", "--rmi", "local") + args := append(s.composeCmd[1:], "-f", s.composeFile, "-p", s.projectName, "down", "-v") cmd := exec.CommandContext(s.ctx, s.composeCmd[0], args...) + cmd.Env = s.composeEnv cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -141,6 +147,7 @@ func (s *ComposeStack) down() { func (s *ComposeStack) tailLogs() { args := append(s.composeCmd[1:], "-f", s.composeFile, "-p", s.projectName, "logs", "-f") cmd := exec.Command(s.composeCmd[0], args...) + cmd.Env = s.composeEnv cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr @@ -165,6 +172,7 @@ func (s *ComposeStack) ServicePort(serviceName string, containerPort int) (int, args := append(s.composeCmd[1:], "-f", s.composeFile, "-p", s.projectName, "port", serviceName, fmt.Sprintf("%d", containerPort)) cmd := exec.CommandContext(s.ctx, s.composeCmd[0], args...) + cmd.Env = s.composeEnv output, err := cmd.Output() if err != nil { @@ -293,6 +301,7 @@ func (s *ComposeStack) StopService(serviceName string, timeoutSec int) error { args := append(s.composeCmd[1:], "-f", s.composeFile, "-p", s.projectName, "stop", "-t", fmt.Sprintf("%d", timeoutSec), serviceName) cmd := exec.CommandContext(s.ctx, s.composeCmd[0], args...) + cmd.Env = s.composeEnv cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -313,6 +322,7 @@ func (s *ComposeStack) ServiceExitCode(serviceName string) (int, error) { args := append(s.composeCmd[1:], "-f", s.composeFile, "-p", s.projectName, "ps", "-a", "-q", serviceName) cmd := exec.CommandContext(s.ctx, s.composeCmd[0], args...) + cmd.Env = s.composeEnv output, err := cmd.Output() if err != nil { return 0, fmt.Errorf("failed to get container ID for service %s: %w", serviceName, err) @@ -351,6 +361,46 @@ func setupDockerEnv(t *testing.T) { } } +// composeEnvironment returns the environment used by every compose command. +// The image prefix is stable within a worktree so --build can reuse images +// between test runs, but differs across worktrees to avoid stale cross-checkout +// images when multiple changes are tested on the same Docker daemon. +func composeEnvironment(t *testing.T, composeFile string) []string { + t.Helper() + + repoRoot := composeRepoRoot(t, composeFile) + sum := sha256.Sum256([]byte(repoRoot)) + imagePrefix := fmt.Sprintf("sq-test-%x", sum[:6]) + + env := os.Environ() + env = append(env, "SQ_DOCKER_IMAGE_PREFIX="+imagePrefix) + env = append(env, "SQ_MYSQL_DATA_MOUNT_TYPE=tmpfs") + env = append(env, "SQ_MYSQL_INITDB_SKIP_TZINFO=1") + return env +} + +// composeRepoRoot resolves the repository containing composeFile. Bazel exposes +// data files through runfile symlinks, so resolving the compose file itself +// provides a source-tree path without requiring repository markers as test data. +func composeRepoRoot(t *testing.T, composeFile string) string { + t.Helper() + + if repoRoot := os.Getenv("REPO_ROOT"); repoRoot != "" { + return repoRoot + } + + realComposeFile, err := filepath.EvalSymlinks(composeFile) + require.NoError(t, err, "failed to resolve compose file") + + cmd := exec.Command("git", "-C", filepath.Dir(realComposeFile), "rev-parse", "--show-toplevel") + output, err := cmd.Output() + require.NoError(t, err, "failed to find repository containing compose file") + + repoRoot := strings.TrimSpace(string(output)) + require.NotEmpty(t, repoRoot, "repository containing compose file is empty") + return repoRoot +} + // FindRepoRoot finds the repository root. // Checks REPO_ROOT env var, then git, then walks up to find marker files. func FindRepoRoot(t *testing.T) string {