Files
computer-playbook/docs/architecture/backup.md
kevinveenbirkenbachandClaude Opus 5 575a5a87ac refactor(layout): sort shipped scripts by language and split the unit suite
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>
2026-08-22 01:31:09 +02:00

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/>&lt;export-base&gt;/state/&lt;volume&gt;"]
        secrets["Host secrets + CA + ACME + node identity<br/>/var/lib/infinito/secrets, /etc/&lt;domain&gt;/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/>&lt;hash&gt;/backup-docker-to-local/&lt;gen&gt;/&lt;volume&gt;/files<br/>&lt;hash&gt;/backup-nfs-to-local/&lt;gen&gt;/files<br/>&lt;hash&gt;/backup-secrets-to-local/&lt;gen&gt;/files/&lt;subtree&gt;"]

        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["&lt;mount&gt;/&lt;hash&gt;/svc-bkp-local-2-device/&lt;ts&gt;/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 &lt;engine&gt; --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.