834 commits from the Tor onion-service branch against 299 of ours, from merge base7b646bdece: 127 conflicted files, 3506 files changed. The two sides are largely orthogonal. Theirs adds svc-net-tor and a `tor` service across the role tree, plus two sweeping refactors: shipped scripts sorted by language (files/ruby/, files/python/, files/shell/, scripts/lint/*.sh) and the unit suite moved under tests/unit/python/. Ours is requirement 036, the native AI gateway, plus the MCP role work. Most conflicts were both sides appending to the same service map, resolved as unions. Resolutions that were not unions: Credentials moved namespace. Theirs renamed meta/schema.yml to meta/secrets.yml and re-addressed credentials as secrets.credentials.<name>. Git carried our litellm_api_key blocks through the rename, but every consumer we added still read 'credentials.<name>', which no longer resolves. All 45 call sites are migrated. plugins/filter/container_volumes.py is deleted by theirs and relocated to plugins/lookup/container_volumes.py, where it carries the same tmpfs defect ourf85996b43had just fixed: a hard-coded {"size": ...} that silently drops a per mount `mode`. The fix is re-applied in the relocated file, as that commit's merge note anticipated. compose.yml keeps only INFINITO_PLAYWRIGHT_REPORTS_BASE_DIR. Theirs also forwards INFINITO_PLAYWRIGHT_STAGE_BASE_DIR; that line is deliberately dropped, per the analysis recorded in45804e9ad-- STAGE has a drift-proof role-vars fallback, while forwarding it promotes a setdefault-pinnable .env value above that SPOT. tasks/stages/01_constructor.yml takes their include, ./tasks/utils/setup/group.yml, because their refactor removed tasks/groups/*-roles.yml, leaving our include target nonexistent. Our task name is kept, since svc-virt is in the loop. Makefile swarm-zombie is a union: their validate plus teardown, then our lab_subnet cleanup, which their side dropped while leaving lab_subnet.sh as the only orphaned reference in the file. 01_bootstrap.sh keeps our deterministic --ip pinning, which distinguishes an already-attached node from a real failure, over their `|| true`. Their language-sorted layout orphaned references our side still held: zammad's two scripts/apply/*.rb, jenkins' java-with-ca-trust.sh, and the notify topics their handlers renamed to hyphenated form. All corrected. roles/sys-svc-compose/files/shell/swarm/stack_ready.sh gains our latched-update block: paused, rollback_paused, rollback_started and rollback_completed exit 2 rather than polling a state that cannot resolve without another deploy. config_from_run is unioned to (title, logged, jobs); the production caller in trigger/__main__.py now passes jobs, without which our suite suppression would have been inert. READMEs are regenerated rather than hand-merged: 15 LiteLLM rows and 91 Tor rows now derive from the merged metadata, which is itself evidence the meta merge is coherent. Verified: 5161 unit tests pass. The single failure, test_query.py TestSortSpec, also fails on fork/feature/svc-net-tor atb51d2f9919, measured in a baseline worktree -- so this merge introduces no new unit-test failure. Two modules are excluded from that run because `baudolo`, a dependency their branch introduces, is not installed on this host. Not verified: no deploy has run against the merged tree, and make quality has not been run end to end here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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)
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 (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
flowchart TB
subgraph infra["🥾 Cluster bring-up (per matrix app)"]
direction TB
topo["🗺️ utils/topology/base + export<br/>SPOT node names + NFS paths → GITHUB_ENV"]:::infra
cluster["🐝 01_bootstrap.sh<br/>host side: pre-clean, compose build + up<br/>all 5 nodes + lab network, sudo .deb build"]:::infra
boot["🧱 compose/swarm/playbook.yml (over docker connection)<br/>systemd wait, NFS-export wipe,<br/>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<br/>per-round inventory + extend_inventory + write_extras"]:::deploy
dep["📦 deploy round N: swarm init/join (svc-swarm-node)<br/>+ 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<br/>and the manager secrets"]:::drill
s2["2️⃣ trigger backup units<br/>volume + secrets @ manager, nfs-2-local @ NFS server"]:::drill
s3["3️⃣ locate the generation holding the marker<br/>3️⃣b assert the volume repo is fresh + non-empty<br/>(its NFS-backed volumes are excluded by design)"]:::drill
s4["4️⃣ pull to the backup host<br/>remote-2-local over the user-backup ssh wrapper"]:::drill
s5["5️⃣ space gate: pulled tree + watchdog floor,<br/>du breakdown on failure; then LUKS loop 'USB'<br/>via the local-2-device unit"]:::drill
s6["6️⃣ full disaster: stack rm"]:::drill
s7["7️⃣ wipe the pulled tree, recover device → local root<br/>(luksOpen; the device holds the only copy left)"]:::drill
s8["8️⃣ wipe the live export (marker proven gone),<br/>recover root → export, restore NFS coherence"]:::drill
s9["9️⃣ delete the marker server-side, recover the volume<br/>through its NFS-mounted mountpoint, re-verify on the<br/>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<br/>marker on the NFS volume"]:::chaos
drain["🩸 06_drain_worker<br/>drain the app's worker, force reschedule"]:::chaos
assert["🔎 07_assert_state<br/>marker survived + app reachable on new node"]:::chaos
seed --> drain --> assert
end
subgraph teardown["🧹 Always"]
direction TB
diag["📊 collect: diagnostics / playwright reports +<br/>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)<br/>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)<br/>NFS export, nfs-2-local, sshd"]:::nfsnode
nBkp["💽 backup-host (non-swarm)<br/>deployed remote-2-local + local-2-device units,<br/>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/<distro>/<app> |
| 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=<id> (keeps the cluster for inspection) or the
whole matrix via make roundtrip.