Acceptance record: Ansible Compose deployment (deploy.yml)
This records the proofs for the Compose deployment wrapper of the Ansible
re-platform (the behavior contract). It
implements the COMPOSE DEPLOY section of
the behavior contract (DEP-1..5): playbooks/deploy.yml plus the
compose_deploy role. With the eleven current host.yml roles from slices
1-3 (rehearsed as twelve; homepage_config was retired when Grafana became
the service launcher), this completes the playbook surface; install.sh
stayed frozen as the rollback at the time and nothing here ran on production
(it was retired on 2026-07-14 once every proof closed).
Per the contract's proof strategy, the evidence stays split into VM-simulation proofs (all green below) and PROD-validation proofs (deferred to the scheduled cutover).
Safety design
Ownership split, enforced both ways
Compose remains authoritative for container lifecycle: the role shells out to
the Compose CLI (docker compose config/pull/up) from each project directory
and uses no community.docker or container modules. In the other direction,
deploy.yml refuses to grow host-convergence side effects: the Docker daemon
and the external proxy network are asserted, never created (creation is
DOCKER-5 in host.yml), and a root-run deploy is refused outright because
SEC-1 keys the .env files to the operator precisely so that Compose runs
non-root.
Gate before mutation
deploy.yml reuses role: preflight unchanged, so PRE-1..PRE-5 gate a deploy
exactly as they gate a converge. The role then validates every project's
Compose configuration before any project deploys: one invalid project
aborts the run with zero container changes, instead of leaving the fleet
half-converged behind a mid-loop failure.
No /run pending-work marker is needed here, unlike DOCKER-2: the role has no
handlers whose loss would strand a deferred action, and docker compose up
--detach is itself the convergence, so a re-run is the recovery.
Rehearsal harness extensions
The VM cannot run the real fleet, so fixtures.sh compose-good seeds three
pinned-BusyBox stand-in projects mirroring the real repo shape (a root project
plus monitoring/ and actual/, giving the same dir-derived project names as
production); the root stand-in declares the external proxy network, which is
what makes the absent-network negative proof load-bearing, and the monitoring
stand-in is a buildable service with a local-only image tag (mirroring the real
intel-gpu-exporter), which is what makes the DEP-5 --ignore-buildable pull
proof load-bearing.
ansible/tests/vm/vm-overrides.yml points compose_deploy_projects at the
stand-ins; production always uses the role defaults. Bootstrap now also
installs a Compose v2 CLI on the VM (Debian's docker.io does not ship the
plugin).
Reproduce from ansible/tests/vm:
./rehearse.sh deploy # the DEP-1..5 stage alone
./rehearse.sh all # full acceptance sequence
VM-simulation proofs - ALL PASSING
Deploy stage on Debian 13 (trixie), ansible-core 2.19.4, Docker 26.1.5, Compose v5.1.4: 12 checks passed, 0 failed, and the full rehearsal (slices 1-3 plus the deploy stage, re-run after rebasing onto the Homepage retirement) reports 84 passed, 0 failed, so the eleven current host roles show no regression.
Negative proofs
| Contract | Precondition | Result |
|---|---|---|
| DEP-1 | Deploy invoked with sudo | refused before any probe (the operator must be exactly homelab_user, so root or any other user is rejected) |
| PRE-3 (inherited) | A secret .env removed |
deploy aborts in preflight before touching Docker |
PRE-3 via --tags compose_deploy |
Same, with a tag-scoped invocation | preflight is always-tagged in deploy.yml, so the gate cannot be tag-skipped |
| DEP-3 | Docker daemon stopped | aborts on the daemon probe; nothing deployed |
| DEP-3 | External proxy network removed |
aborts pointing at host.yml; nothing deployed |
| DEP-2 | Second project's Compose file invalid | whole deploy aborts on the validation assert |
| DEP-2 | Same, checked after the abort | zero containers exist for any of the three project names |
Positive proofs
| Contract | Evidence on VM |
|---|---|
| DEP-1 | Unprivileged deploy as homelab_user converges all three stand-in projects; one running container per project name |
| DEP-4 | Immediate second deploy reports changed=0 |
| DEP-5 | Deploy with -e compose_deploy_pull=true succeeds and stays changed=0 with the pinned images already present |
PROD-validation proofs - CONFIRMED at the 2026-07-13 production cutover
| Contract | Production proof | Result (2026-07-13 cutover, 2026-07-14 DEP-5) |
|---|---|---|
| DEP-1 | The three real projects converge with today's project names; docker compose ps per project matches the pre-cutover fleet |
✅ first real deploy.yml run as homelab_user (no sudo): ok=16 changed=0 failed=0; container IDs and creation times identical to the pre-cutover snapshot |
| DEP-2 | Gate exercised only if a bad Compose file ever lands (CI already validates all three projects per PR) | ✅ gate ran and passed for all three projects; failure branch intentionally not exercised (CI covers it) |
| DEP-3 | proxy exists from host.yml (DOCKER-5); daemon active |
✅ proxy present, daemon active |
| DEP-4 | A routine re-deploy on the converged fleet reports changed=0 |
✅ changed=0, zero containers recreated |
| DEP-5 | The post-Renovate flow (-e compose_deploy_pull=true) pulls exactly the bumped images and recreates only their containers |
✅ 2026-07-14, first post-cutover Renovate bump (PR #113, tautulli-exporter 0.2.6→0.2.7): deploy.yml -e compose_deploy_pull=true as homelab_user ran ok=19 changed=2 failed=0 (only the monitoring project changed); before/after docker ps --no-trunc diff shows exactly one container recreated (tautulli-exporter on the new tag), the other 39 container IDs identical; new exporter healthy (up{job="tautulli"} == 1) |
The deferred proofs from slices 1, 2, and 3 remain part of the same cutover checklist.