Acceptance record: Ansible slice 3 (docker_host, hardening, ssh_hardening)
This records the proofs for the final host-convergence slice of the Ansible
re-platform (the behavior contract). It implements the last
three roles from the behavior contract: docker_host
(DOCKER-1..6), hardening (HARD-1..3), and ssh_hardening (SSH-1..2).
All twelve host.yml roles were implemented and VM-rehearsed in this run.
Status update (2026-07-13): the previously rehearsed
homepage_configrole was later retired when Grafana replaced Homepage as the service launcher. The current play has eleven roles; the 12-role counts below remain the historical evidence for this rehearsal.
Per the contract's proof strategy, the evidence remains strictly separated:
- VM-simulation proofs - automatable on the disposable Debian 13 VM; all green below.
- PROD-validation proofs - require the real mounts, containers, HBA, and a fresh external SSH login; deferred to the single scheduled cutover.
Nothing in this slice ran on production. install.sh stayed frozen and
unchanged as the rollback until the cutover was accepted (retired 2026-07-14).
Safety design
Docker restart transaction
The role validates the repository daemon.json, semantically compares its JSON
directives, and normalizes the effective systemd drop-in directives before any
mutation. It then queries Docker and its running containers. A real effective
change on an active daemon with containers aborts before any role mutation
unless allow_docker_restart=true was explicitly passed. Equivalent JSON
ordering or whitespace does not schedule a restart.
After the gate passes, handlers reload systemd before restarting Docker. A
fresh daemon with no containers needs no flag. The shared proxy network is
managed through the Docker CLI, not community.docker.
SSH candidate transaction
ssh_hardening remains last in host.yml. It validates the current combined
configuration before treating it as last-known-good, stages the candidate
outside sshd_config.d, runs sshd -t against the candidate, installs it,
then validates the full combined configuration. Only that successful path can
reload ssh.service.
The .lkg copy is outside Debian's *.conf include glob. Any candidate or
combined-config failure restores the prior drop-in, validates the restoration,
fails the role, and leaves SSH unreloaded.
Rehearsal harness extensions
ansible/tests/vm/ remains Ansible-native, with no Galaxy collection or
Molecule dependency:
fixtures.shnow seeds Docker fresh/equivalent/drift states, a pinned BusyBox container, a deliberately invalid sshd candidate, and real systemd.mountunits for/mnt/user/dataand/mnt/cache/appdata.- The negative mount unit uses an existing but invalid ext4 loop source, so it
fails immediately and deterministically. The positive units use real bind
mounts.
docker.socketis stopped while the dependency fixture changes so socket activation cannot race the proof. rehearse.shinstalls the VM-only Docker/OpenSSH prerequisites, detects SSH on port 22 or the configured hardened port, refuses the production host, and recovers correctly after a prior rehearsal reboot removes the disposable loop mounts.- Mount setup now has postconditions and avoids remounting an already-correct stand-in. This prevents asynchronous loop teardown from creating false preflight failures.
Reproduce from ansible/tests/vm:
./rehearse.sh all
The default target is homelab_user@<production-address>; override it with VM= and
SSH_PORT=. the configured SSH port must be reachable after the SSH role converges and after
the reboot proof.
VM-simulation proofs - ALL PASSING
Full rehearsal on Debian 13 (trixie), systemd 257, ansible-core 2.19.4, and Docker 26.1.5: 73 checks passed, 0 failed.
Negative and disruptive-path proofs
| Contract | Precondition | Result |
|---|---|---|
| DOCKER-2 | No daemon.json/drop-in and no containers | fresh converge succeeds without the restart flag |
| DOCKER-1/2 | Reordered/reformatted but equivalent daemon.json; container running | changed=0; dockerd PID unchanged |
| DOCKER-2 | Real valid directive drift; container running; no flag | aborts before mutation; daemon.json hash and dockerd PID unchanged |
| DOCKER-2 | Same real drift with allow_docker_restart=true |
restart succeeds; dockerd PID changes; BusyBox container remains running through live-restore |
| DOCKER-3 | Real required .mount unit backed by an invalid ext4 loop source |
mount unit fails and docker.service refuses to start |
| DOCKER-3 | Both required .mount units backed by valid bind sources |
both mount and Docker starts |
| SSH-2 | Syntactically invalid staged candidate | sshd -t rejects it; role aborts; active drop-in hash is unchanged; active and .lkg copies match; SSH stays valid |
The ten PRE/STO/NET negative tests from slices 1 and 2 also remain green in the same run.
Positive convergence and universal gates
| Proof | Evidence |
|---|---|
| First full converge | succeeds with all twelve roles |
| Idempotence | ok=72 changed=0 failed=0 skipped=16 |
| Check mode | full tagged ansible-playbook --check succeeds on the healthy host |
| Concern tagging | --tags maintenance_jobs selects only that role |
| Destructive-storage guard | clean across all playbooks and role task files |
Per-role state
| Contract | Evidence on VM |
|---|---|
| DOCKER-1 | effective JSON equals the repository candidate; file 0644 root:root; Docker reports live-restore enabled |
| DOCKER-3 | drop-in 0644 root:root; systemctl cat docker includes both required paths |
| DOCKER-4 | wireguard loaded; /etc/modules-load.d/wireguard.conf matches the repository |
| DOCKER-5 | docker network inspect proxy succeeds |
| DOCKER-6 | apt drop-in 0644 root:root; apt-config dump parses the Docker CE origin |
| HARD-1 | udev rule matches; every exposed policy is max_performance; the VM's empty SATA-policy set no-ops cleanly |
| HARD-2 | fail2ban jail matches; service enabled and active |
| HARD-3 | homelab_user is in docker; second converge is unchanged |
| SSH-1 | drop-in matches all six directives; sshd -T reflects the configured SSH port and key-only/no-root state |
| SSH-2 | active drop-in equals its persistent .lkg; sshd -t succeeds; a fresh key-based connection on the configured port succeeds |
Reboot persistence
The harness verifies a changed boot ID before accepting any post-reboot proof. Without another converge:
- all six maintenance and five monitoring timers remain enabled;
smartmontools.serviceremains masked;- WireGuard is loaded through
modules-load.d; - fail2ban remains enabled and active; and
- sshd remains valid and reachable on the configured SSH port.
PROD-validation proofs - CONFIRMED at the 2026-07-13 production cutover
| Contract | Production proof | Cutover result (2026-07-13) |
|---|---|---|
| DOCKER-1 | docker info reports live-restore enabled and effective json-file rotation matches 50m / 3 |
✅ Live Restore Enabled: true, json-file driver, daemon.json carries 50m/3 |
| DOCKER-2 | Run the one cutover with -e allow_docker_restart=true; Docker restarts exactly once and the real container fleet recovers cleanly |
✅ flag passed; the effective daemon.json already matched, so zero restarts fired and the fleet (40 containers) was untouched - the no-op branch is the correct behaviour when there is no config diff |
| DOCKER-3 | systemctl cat docker shows the dependency; a genuinely unavailable data/cache mount keeps Docker down by design |
✅ RequiresMountsFor=/mnt/user/data /mnt/cache/appdata present (fail-closed path not live-fired) |
| DOCKER-4 | WireGuard is loaded and qBittorrent VPN starts unprivileged | ✅ wg0 has a private tunnel address, WebUI answering 200 |
| DOCKER-5 | proxy exists and all three Compose projects can attach |
✅ proxy network present; all three projects converged against it |
| DOCKER-6 | Docker CE packages remain eligible for unattended upgrades | ✅ 51unattended-upgrades-docker origin Docker,label=Docker CE in place |
| HARD-1 | all eight real SATA policies are max_performance; hard-reset log count stays flat |
✅ 8× max_performance; 3 hard resetting link lines this boot, all from 2026-07-09 pre-fix, none since |
| HARD-2 | fail2ban is active and observes SSH on the configured SSH port | ✅ sshd jail enabled with the configured port, responding to fail2ban-client |
| HARD-3 | a fresh homelab_user login has non-root Docker access |
✅ fresh session ran docker ps without sudo |
| SSH-1 | existing cutover session survives; effective config is key-only, no-root, the configured SSH port | ✅ session survived; all six hardening directives asserted effective by the role |
| SSH-2 | before closing the old session, a fresh key-based login on the configured port succeeds | ✅ fresh a fresh SSH command login + sudo -n true verified before proceeding; .lkg copy recorded (one of the four expected changes) |
The deferred proofs from
acceptance-slice1.md and
acceptance-slice2.md remain part of the same cutover
checklist. The separate Compose wrapper in playbooks/deploy.yml was out of
scope for this slice (delivered later; see
acceptance-deploy.md); Compose remains authoritative
for workload lifecycle.