Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).
This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.
Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:
* 7 `src:` values built from a role variable holding a bare filename
(svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
The variable doubles as the destination name inside the container, so the
language segment belongs at the call site, not in the variable.
* `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
18 roles ship exactly that and have no template fallback: the play would
have died on each.
* `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
interface now reads `files/javascript/javascript.js`.
* `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
path through `path_absolute(... 'files', ...)`.
* 4 `ansible.builtin.script:` invocations naming a bare command
(pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
* `dev-python/files/shell/install.sh` pointed at a sibling role's script.
Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5.6 KiB
Backup and Recover Schema (svc-bkp-* role family)
End-to-end flow of the backup chain and its recover counterparts. Solid
arrows run on schedule (systemd units + pull); dashed arrows are the
recover direction (files/python/recover.py of each role, enforced by
tests/lint/ansible/roles/test_bkp_roles_have_recover.py). The swarm
test pipeline exercises the full chain in
scripts/tests/deploy/swarm/routine/backup/base.sh.
flowchart TB
subgraph source["Source host (e.g. arch-server1 / swarm manager + NFS server)"]
direction LR
volumes["Docker volumes + databases"]
export["NFS export<br/><export-base>/state/<volume>"]
secrets["Host secrets + CA + ACME + node identity<br/>/var/lib/infinito/secrets, /etc/<domain>/ca,<br/>/etc/letsencrypt, /etc/certbot, ssh host keys, machine-id"]
unit_vol["svc-bkp-volume-2-local.service<br/>(baudolo, nightly)"]
unit_nfs["svc-bkp-nfs-2-local.service<br/>(rsync --link-dest, nightly)"]
unit_sec["svc-bkp-secrets-2-local.service<br/>(rsync --link-dest per subtree, nightly)"]
src_backups["/var/lib/infinito/backup<br/><hash>/backup-docker-to-local/<gen>/<volume>/files<br/><hash>/backup-nfs-to-local/<gen>/files<br/><hash>/backup-secrets-to-local/<gen>/files/<subtree>"]
volumes -->|"stop-aware dump + rsync"| unit_vol --> src_backups
export -->|"differential snapshot"| unit_nfs --> src_backups
secrets -->|"differential snapshot"| unit_sec --> src_backups
end
subgraph backuphost["Backup host (e.g. arch-server2)"]
direction LR
unit_pull["svc-bkp-remote-2-local.service<br/>(pull_specific_host.py, backup@source,<br/>forced-command ssh wrapper)"]
bkp_backups["/var/lib/infinito/backup<br/>(pulled generations = recovery store)"]
unit_dev["svc-bkp-local-2-device.service<br/>(script.py, plug-triggered via .mount)"]
unit_pull --> bkp_backups
bkp_backups --> unit_dev
end
subgraph device["Encrypted device (LUKS)"]
stick["<mount>/<hash>/svc-bkp-local-2-device/<ts>/backup"]
end
source --> backuphost --> device
src_backups -->|"rsync over ssh (pull only)"| unit_pull
unit_dev -->|"rsync --link-dest"| stick
stick -.->|"local-2-device recover.py<br/>luksOpen + mount + newest snapshot<br/>rsync --delete"| bkp_backups
bkp_backups -.->|"operator rsync<br/>(backup user is pull-only)"| src_backups
src_backups -.->|"nfs-2-local recover.py<br/>backup unit first, then rsync --delete"| export
src_backups -.->|"volume-2-local recover.py<br/>backup unit first, then rsync --delete<br/>into the volume mountpoint"| volumes
src_backups -.->|"volume-2-local recover.py --databases<br/>baudolo-restore <engine> --empty per dump,<br/>consumers must be down"| volumes
src_backups -.->|"secrets-2-local recover.py<br/>backup unit first, then rsync --delete<br/>per subtree to its fixed system path"| secrets
Databases travel as sql dumps, not as a file tree (--dump-only-sql), so
they have their own recover leg: recover database <generation>, which
recover full plans right after the volume steps. That leg is the one part
of the chain that cannot run on a bare host: a dump is replayed through
docker exec, so the database service has to be deployed and up, with its
consumers stopped. The engine of a dump is
resolved from the consuming role's meta/services.yml service key in
utils/recovery/databases.py — the same value the backup seed used — and a
replay refuses to run while a consumer of that database is up, because
--empty pre-cleans the schema and a booting consumer would recreate it
underneath the replay.
Per-role recover procedures live in each role's ## Recover README
section; svc-bkp-remote-2-local documents its one-way opt-out in
files/python/recover.py.nocheck.
Deploy-time trigger flow
When the backup units actually run during a deploy, keyed on MODE_BACKUP
and whether this is a first install or an update. The nightly systemd
timers fire independently of every path below.
flowchart TB
start["Deploy starts (tasks/stages/01_constructor.yml)"] --> boot["Pre-preload bootstrap:<br/>docker + NFS export + token store ready"]
boot --> mode{"MODE_BACKUP?"}
mode -->|true| pre["Preload pass instant-starts the pre-state snapshot<br/>(force_flush_instant + state started):<br/>svc-bkp-secrets, svc-bkp-volume (pulls sys-svc-container for a<br/>live dockerd + ensures /opt/compose first), and svc-bkp-nfs<br/>after svc-storage-nfs-server (export must exist)"]
pre --> kind{"install or update?"}
kind -->|"1st install"| baseline["Source still empty (no volumes/export yet)<br/>-> near-empty baseline generation"]
kind -->|update| anchor["Captures the live pre-update state<br/>-> rollback anchor before any mutation"]
baseline --> apps
anchor --> apps
apps["App pass deploys / updates the stacks"] --> term["Terminator (end of play): svc-bkp-remote-2-local<br/>forced pull (force_flush_final)"]
term --> done["Deploy done"]
mode -->|false| install_only["Units installed + enabled, never started here"]
install_only --> apps_nb["App pass deploys / updates the stacks"] --> done
done --> timers["Nightly timers keep running on schedule<br/>(both MODE_BACKUP paths)"]
A failing pre-state snapshot aborts the deploy (no suppress_flush):
no update proceeds without a successful backup of the state it is about
to change. svc-bkp-local-2-device is never started at deploy time (no
device present); it stays plug-triggered via its .mount unit.