# Swarm deploy test harness
Simulates a full Docker Swarm cluster on a single CI runner (or host via
`make swarm-zombie` / `make roundtrip`) and deploys one application
through it end to end: provision an inventory, deploy every variant
round with the in-deploy Playwright e2e, run the backup+restore DR drill
between rounds, then prove state survives a worker reschedule.
The cluster consists of privileged systemd containers on a dedicated lab
bridge (`192.168.244.0/24`, MTU 1400): three swarm nodes (1 manager +
2 workers), a non-swarm NFSv4 server serving the shared volume storage,
and a non-swarm backup host for the DR drill. The node image
([`compose/swarm/Dockerfile`](../../../../compose/swarm/Dockerfile))
bakes python3 + dnsmasq and masks the zfs boot units the parent image's
userland enables; the containers and the lab network are declared
in [`compose/swarm/compose.yml`](../../../../compose/swarm/compose.yml) (project
`${SWARM_NAME}`, backup host behind the `drill` profile) and started by
one `docker compose up` in `routine/01_bootstrap.sh`. Lab DNS is provisioned
by `compose/swarm/playbook.yml` over `ansible_connection: docker` (Ansible never
uses ssh here); swarm init/join itself happens inside the deploy via the
`svc-swarm-node` role, not in any bring-up script.
## Flow
```mermaid
flowchart TB
subgraph infra["π₯Ύ Cluster bring-up (per matrix app)"]
direction TB
topo["πΊοΈ utils/topology/base + export
SPOT node names + NFS paths β GITHUB_ENV"]:::infra
cluster["π 01_bootstrap.sh
host side: pre-clean, compose build + up
all 5 nodes + lab network, sudo .deb build"]:::infra
boot["π§± compose/swarm/playbook.yml (over docker connection)
systemd wait, NFS-export wipe,
lab DNS, repo + .deb install on every node"]:::infra
topo --> cluster --> boot
end
subgraph deploy["π Matrix deploy (per variant round)"]
direction TB
prov["π 02_provision_inventory
per-round inventory + extend_inventory + write_extras"]:::deploy
dep["π¦ deploy round N: swarm init/join (svc-swarm-node)
+ docker stack deploy + Playwright e2e"]:::deploy
conv["β
03_wait_converge + 04_verify_reachable"]:::deploy
purge["π§½ purge_stacks (between rounds)"]:::deploy
subgraph drill["π routine/backup/base.sh (DR drill, once, after round 1)"]
direction TB
s1["1οΈβ£ seed markers into the live NFS volume
and the manager secrets"]:::drill
s2["2οΈβ£ trigger backup units
volume + secrets @ manager, nfs-2-local @ NFS server"]:::drill
s3["3οΈβ£ locate the generation holding the marker
3οΈβ£b assert the volume repo is fresh + non-empty
(its NFS-backed volumes are excluded by design)"]:::drill
s4["4οΈβ£ pull to the backup host
remote-2-local over the user-backup ssh wrapper"]:::drill
s5["5οΈβ£ space gate: pulled tree + watchdog floor,
du breakdown on failure; then LUKS loop 'USB'
via the local-2-device unit"]:::drill
s6["6οΈβ£ full disaster: stack rm"]:::drill
s7["7οΈβ£ wipe the pulled tree, recover device β local root
(luksOpen; the device holds the only copy left)"]:::drill
s8["8οΈβ£ wipe the live export (marker proven gone),
recover root β export, restore NFS coherence"]:::drill
s9["9οΈβ£ delete the marker server-side, recover the volume
through its NFS-mounted mountpoint, re-verify on the
server; recover the host secrets"]:::drill
s1 --> s2 --> s3 --> s4 --> s5 --> s6 --> s7 --> s8 --> s9
end
prov --> dep --> conv --> drill --> purge
end
subgraph chaos["πͺ Reschedule chaos (after the matrix)"]
direction TB
seed["π± 05_seed_content
marker on the NFS volume"]:::chaos
drain["π©Έ 06_drain_worker
drain the app's worker, force reschedule"]:::chaos
assert["π 07_assert_state
marker survived + app reachable on new node"]:::chaos
seed --> drain --> assert
end
subgraph teardown["π§Ή Always"]
direction TB
diag["π collect: diagnostics / playwright reports +
workflow-level recursive container.py rescue snapshot"]:::teardown
clean["ποΈ utils/clean/teardown (kill nodes + lab network)"]:::teardown
diag --> clean
end
subgraph lab["π Lab bridge 192.168.244.0/24 (MTU 1400)"]
direction LR
nMgr["π swarm-mgr-01 (manager)
stacks, volume-2-local, user-backup, sshd"]:::mgr
nW1["βοΈ swarm-wrk-01 (worker)"]:::wrk
nW2["βοΈ swarm-wrk-02 (worker)"]:::wrk
nNfs["π¦ nfs-server (non-swarm)
NFS export, nfs-2-local, sshd"]:::nfsnode
nBkp["π½ backup-host (non-swarm)
deployed remote-2-local + local-2-device units,
LUKS device"]:::bkpnode
end
infra --> deploy --> chaos --> teardown
cluster -->|βΆοΈ compose up all 5| nMgr & nW1 & nW2 & nNfs & nBkp
boot -->|π hosts + dnsmasq| nMgr & nW1 & nW2 & nNfs
boot -->|π₯ repo + .deb install| nMgr & nW1 & nW2 & nNfs & nBkp
dep -->|π swarm init/join + docker stack deploy| nMgr
s2 -->|β±οΈ trigger volume + nfs units| nMgr & nNfs
s4 -->|β¬οΈ pull backup@source| nBkp
s7 -->|β»οΈ wipe + recover export| nNfs
s8 -->|π redeploy| nMgr
seed -->|π± marker on export| nNfs
drain -->|π©Έ drain a worker| nMgr
clean -->|ποΈ kill + remove| nMgr & nW1 & nW2 & nNfs & nBkp
nMgr -. π¨ schedules app tasks .-> nW1 & nW2
nMgr -. πΎ NFS-backed volumes .-> nNfs
nW1 -. π NFS mount .-> nNfs
nW2 -. π NFS mount .-> nNfs
nBkp -. π pull over ssh .-> nMgr & nNfs
classDef infra fill:#f3f7fb,stroke:#d6e2ee,color:#455a64;
classDef deploy fill:#f3f9f4,stroke:#d9ecdc,color:#455a64;
classDef drill fill:#fbf6f0,stroke:#eee1cf,color:#455a64;
classDef chaos fill:#fbf4f4,stroke:#eed9d9,color:#455a64;
classDef teardown fill:#f7f8f9,stroke:#e3e6e9,color:#455a64;
classDef mgr fill:#f6f3fb,stroke:#e0d7ee,color:#455a64;
classDef wrk fill:#f8f4fa,stroke:#e6dcec,color:#455a64;
classDef nfsnode fill:#f1f8f6,stroke:#d5e7e2,color:#455a64;
classDef bkpnode fill:#fbf3f6,stroke:#eed9e2,color:#455a64;
style infra fill:#ffffff,stroke:#e6e6e6;
style deploy fill:#ffffff,stroke:#e6e6e6;
style drill fill:#ffffff,stroke:#e6e6e6;
style chaos fill:#ffffff,stroke:#e6e6e6;
style teardown fill:#ffffff,stroke:#e6e6e6;
style lab fill:#ffffff,stroke:#e6e6e6;
```
The drill proves the `svc-bkp-*` chain forward through the DEPLOYED
systemd units (nfs on the export host, volume + secrets on the manager when
the app's include closure pulls those roles in, remote pull + device sync on
the backup host) and every
`recover.py` back (device -> local root -> NFS export, docker volume and
host secrets into the live system paths), with marker files that must
survive the whole loop: the matrix update pass boots the recovered stack
and `verify_recovered_marker.sh` asserts the marker on the live volume. It
reuses the round-1 stack instead of spinning a dedicated cluster, and skips
cleanly when the app declares no NFS-flagged volume. The backup host is started by `routine/01_bootstrap.sh` (drill
profile) and receives its two roles via `extend_inventory`; the pull
trust (backup keypair) and the device mount/target/source come from
`utils/tests/swarm/write/extras.py`, while
`remote-2-local.backup_providers` is derived per round by
`utils/tests/swarm/backup_repos.py` from the same placements
`extend_inventory` turns into inventory groups, and reaches the deploy
through the provisioner's host_vars merge.
## Scripts
The sequenced flow lives in `routine/`, the naming SPOT in `utils/topology/`,
shared helpers in `utils/`, and
the cluster declaration (image, containers, network, DNS play) in
`compose/swarm/` + `compose/swarm/compose.yml`.
| Stage | Script | Purpose |
|---|---|---|
| loop | `scripts/tests/deploy/distros.sh` | SPOT: run the drill once per distro in random order under the shared time budget, then table every outcome (β
passed / β failed / π¦ skipped) into the job summary |
| loop | `routine/00_one.sh` | the whole drill for one distro; collect + teardown run from its `EXIT` trap |
| bring-up | `utils/topology/base.sh` | SPOT: node names + NFS export/state paths (sourced, not run) |
| bring-up | `utils/topology/export.sh` | write the topology SPOT into `$GITHUB_ENV` |
| bring-up | `compose/swarm/compose.yml` + `compose/swarm/Dockerfile` | declare the 5 node containers, node image + lab network (compose SPOT) |
| bring-up | `routine/01_bootstrap.sh` | host side: pre-clean, `compose build` + one `compose up`, sudo `.deb` build, then the play |
| bring-up | `compose/swarm/playbook.yml` | node side over docker connection: systemd wait, NFS-export wipe, lab DNS, repo + `.deb` install |
| deploy | `routine/02_provision_inventory.sh` | provision the per-round inventory |
| deploy | `routine/03_wait_converge.sh` | wait for every stack service to converge |
| deploy | `routine/04_verify_reachable.sh` | probe the app is reachable in-cluster |
| deploy | `routine/backup/base.sh` (+ per-host routines in `routine/backup/`) | backup+restore DR drill between rounds |
| deploy | `utils/clean/purge_stacks.sh` | remove prior-round stacks between rounds |
| chaos | `routine/05_seed_content.sh` | seed a marker on the NFS volume |
| chaos | `routine/06_drain_worker.sh` | drain the app's worker + force reschedule |
| chaos | `routine/07_assert_state.sh` | assert the marker + reachability survived |
| teardown | `utils/collect/diagnostics.sh` | write stack/service diagnostics on failure, one file per topic under the rescue dir |
| teardown | `../utils/rescue_index.sh` | list the collected rescue tree so a reader knows what the artifact holds |
| teardown | `utils/collect/playwright_reports.sh` | pull Playwright reports from the manager into `/tmp/playwright-artifacts//` |
| teardown | `utils/collect/topology_summary.sh` | render the node list + service placement into `$GITHUB_STEP_SUMMARY` |
| teardown | `utils/clean/teardown.sh` | kill the nodes + remove the lab network |
| helper | `utils/_context.sh` | per-app facts (entity, service, NFS volume, probes) |
| helper | `utils/unmount/nfs_mounts.sh` | best-effort NFS unmount before node removal |
| helper | `utils/unmount/host_state.sh` | release the host mount of the shared state so the next cycle cannot inherit it |
| recovery | `utils/clean/all.sh` | reap leftover clusters across every cluster id |
| recovery | `utils/clean/stale_nfs.sh` | recover stale in-namespace NFS mounts |
The matrix orchestrator
(`utils/tests/swarm/matrix.py`) drives the deploy stage
per variant round; `routine/00_one.sh` drives the surrounding stages for one
distro, and the workflow `.github/workflows/call-test-deploy.yml` drives the
distro loop. Run one app locally with
`make swarm-zombie app=` (keeps the cluster for inspection) or the
whole matrix via `make roundtrip`.