ADR-015: Containerized Integration Tests
On this page
Context
Canopy’s Rust integration test suite (~1500+ tests across 14 services + shared crates) currently executes on the developer’s host, reaching devstack services through localhost:<ephemeral-port> mappings written to .ports.env by cargo xtask dev start. The host-execution model produces three operational gaps that surface inconsistently across developer environments and CI runners:
-
Linux Docker Engine vs Docker Desktop divergence. Three
canopy-web::session_testcases follow a 303 redirect tohost.docker.internal:8180(Keycloak).host.docker.internalis auto-injected into the host’s resolver by Docker Desktop on Mac/Windows but not by Docker Engine on Linux. Tests that resolve the redirect target on the host pass on Desktop and fail on Engine withfailed to lookup address information: Name or service not known. The immediate workaround (TestClient::new_no_redirect()+assert_status(303)shipped in MR !65) sidesteps the resolution but blocks any redirect-following coverage. -
Hardcoded
localhostin URL builders.canopy_test_lib::infrastructure::infrastructure_available(),canopy-db::pg_url(), andcanopy-mq::amqp_url()readCANOPY_PORT_POSTGRES_5432/CANOPY_PORT_RABBITMQ_5672for the port but hardcodelocalhostas the hostname. Inside the docker network the canonical hostname ispostgres/rabbitmq— there is no env hook that lets a containerized test runner override the host without rewriting these call sites. -
Host-side toolchain reproducibility. Running the suite at all requires
cargo+cargo-nexteston every developer machine and every CI runner image, with host-side resolver quirks (systemd-resolved synthetic records, IPv6 preferences,/etc/hostsoverrides) affecting reproducibility. ADR-001 program-service-isolation already mandates that program services see each other only by compose service name; the integration runner should sit on the same network for the same parity guarantee.
The pattern precedent already exists in the repo: docker-compose.yml defines canopy-e2e (Playwright) under profiles: [e2e], run via docker compose --profile e2e run --rm canopy-e2e. The Playwright suite has had zero environment-divergence failures since it shipped because every test runs against in-network DNS names.
A counter-pattern also exists: the testcontainers-rs crate is used inside unit-level tests for crates/canopy-db (PostgreSQL) and crates/canopy-mq (RabbitMQ) to spin per-test ephemeral containers. Those tests test individual crate behaviour against an isolated dependency. The full-stack HTTP integration tests in services/canopy-/tests/.rs and crates/canopy-test-lib-using crates are different in kind: they exercise the running canopy service mesh, not isolated infrastructure.
Decision
Run the Rust integration test suite in the docker network, against compose-service-name targets, by default — same as canopy-e2e.
Specifically:
-
Add a
Dockerfile.integrationat the workspace root: multi-stage Alpine, non-rootappuser, pre-built nextest, sourceCOPY`ed in. ENTRYPOINT runs `cargo nextest run --workspace --test '*' --profile integration. Per coding-conventions Container Runtime section. -
Add a
canopy-integrationservice todocker-compose.ymlunderprofiles: [integration]. Environment block hardcodes everyCANOPY_TEST__*_URLto in-network address (http://canopy-rules:8001, …,postgres://canopy:canopy@postgres:5432/canopy,amqp://canopy:canopy@rabbitmq:5672/%2f).depends_onlists every service the suite probes withcondition: service_healthy. Cache volumecanopy-integration-target. -
Refactor three URL builders (
pg_url,amqp_url,infrastructure_available) to read full URL env vars (CANOPY_TESTDATABASE_URL,CANOPY_TESTRABBITMQ_URL) with the existinglocalhost:<port>fallback.xtask::docker::write_ports_envemits the new vars for host-side runs (parity). -
xtask/src/cmd/{test,validate}.rsroute the integration nextest step throughdocker compose --profile integration run --build --rm canopy-integrationby default. A new--hostflag preserves the existing host-side path for IDE iteration or single-test debugging. -
.gitlab-ci.ymladdsintegration-testsunder theteststage, taggeddhs-aws-autoscaler-docker.xlarge, with DinD service,CANOPY_CI=true, JUnit artifact collection. -
testcontainers-rscontinues to be the right choice for unit-level integration tests against isolated DB / broker dependencies (e.g.canopy-db,canopy-mqinternals). Full-stack HTTP suites against the canopy mesh use docker-compose. ADR-015 documents this split so future contributors don’t conflate the two patterns.
Cross-cutting decisions
-
No shared
target/between host and container. Cache lives in the named volumecanopy-integration-targetto keep host-vs-musl artefacts segregated. First container build is slow; subsequent runs hit the cache. -
Filesystem-bound tests.
canopy-typstreads system fonts →Dockerfile.integrationaddsapk add --no-cache font-noto.canopy-seedusestempfile::tempdir(), works in-container unchanged. -
.dockerignoreat workspace root excludestarget/,.git/,node_modules/,test-results/,.devstack/,.ports.env. Keeps the build context lean.
Consequences
Positive
-
Production parity. Tests reach services by compose service name — the same DNS path program services use to reach each other (ADR-001). Host-vs-container resolution divergence stops being a class of failure.
-
No host toolchain bootstrap. Developer machines and CI runners need only Docker.
cargo+cargo-nextestmove into the test container’s build stage. -
Linux/Mac/Windows parity.
host.docker.internalresolution differences disappear because tests no longer touch the host DNS. -
Aligns with
canopy-e2e. Same shape —--profile X --rm, env-var URL overrides, depends-on health gates. Reduces compose surface novelty. -
No public-API changes. The
TestClientAPI, theTestConfig::from_env()schema, and the per-test code shape stay identical. Internal env-var sourcing is the only thing that changes.
Negative
-
Slower first run.
cargo nextest list --workspaceinside the container takes 3-5 minutes uncached; the named cache volume amortises subsequent runs, but a--buildafter aCargo.tomlchange pays the cost again. CI runners get a fresh image per pipeline. -
--hostescape hatch. IDE-driven iteration (run-one-test, attach debugger) wants the host runner. The--hostflag preserves it but adds a code path divergence the fix needs to cover. Documented in the plan’s Verification section. -
DinD in CI. GitLab CI’s existing pipeline uses container jobs without DinD. Adding
services: [docker:dind]is supported but increases per-job cost and image-pull time. The trade is paid once per pipeline. -
Re-auditing
host.docker.internalreferences. Any leftover host-only assumptions surface only after the cutover. Risk mitigated by leaving--hostas an opt-back path during the migration window.
Alternatives Considered
-
Inject
host.docker.internalviaextra_hostsin compose. Works on Mac/Windows but Linux Docker Engine’shost-gatewaysubstitution is opt-in per-service, fragile across Docker versions, and doesn’t address the toolchain-bootstrap or hostname-hardcoding problems. Rejected. -
Migrate the full integration suite to
testcontainers-rs. Per-test ephemeral DB/broker plus dynamically-spawnedcanopy-snapetc. would replace docker-compose entirely. Disproportionate effort for a suite that’s already aligned with compose semantics — every program service has its own health-gated lifecycle that compose orchestrates. Reserved for unit-level integration tests where per-test isolation is the value. -
Keep host-only with documentation. "Run on Docker Desktop only" is not a viable stance — Linux is the canonical CI environment. Rejected.
Amendment — CI image sourcing (2026-07-15, #1073)
The in-network model is unchanged, but CI no longer compiles the devstack
inside its docker daemon: the from-source build (service build stage
dioxus portal toolchain, in parallel under dind) exceeded every runner
disk. The integration-tests job (now tagged 2xlarge, main + tag
pipelines only) sets COMPOSE_FILE=docker-compose.yml:docker-compose.prebuilt.yml
+ CANOPY_PREBUILT_IMAGES=true, which maps every service’s build: block
to the immutable commit-SHA staging refs the pipeline’s build jobs already
pushed (ADR-040
build-once, extended to test consumption) — so the integration suite
exercises the exact digests docker-promote later retags. xtask
(devstack_guard) pulls instead of --build under that flag. Only the
test-runner image (Dockerfile.integration, which compiles nothing at
build time) still builds in-job; local development keeps building from
source.
Related ADRs
-
ADR-001 (Program Service Isolation) — establishes that program services see each other only via compose service names. ADR-015 extends the same network model to the integration runner.
-
ADR-005 (Modular Deployment Profiles) — the
integrationprofile fits the existing profile model alongsidesnap-only,e2e, etc. -
ADR-040 (Build-once Artifact Promotion) — the staging refs the CI integration stack consumes since the 2026-07-15 amendment.