personal_infra/ansible/roles/backup_store/tasks/main.yml

90 lines
2.6 KiB
YAML
Raw Normal View History

2026-09-12 16:02:00 +02:00
---
- name: Assert backup_store sources are sane
ansible.builtin.assert:
that:
- backup_store_sources | length > 0
- backup_store_sources | map(attribute='name') | list | length == backup_store_sources | length
- backup_store_sources | map(attribute='source') | list | length == backup_store_sources | length
- backup_store_sources | map(attribute='retention_days') | list | length == backup_store_sources | length
fail_msg: "backup_store: every source needs name, source and retention_days"
quiet: true
- name: Ensure rsync is installed
ansible.builtin.apt:
name: rsync
state: present
update_cache: yes
cache_valid_time: 3600
become: yes
- name: Ensure the backup store directory exists
ansible.builtin.file:
path: "{{ backup_store_dir }}"
state: directory
mode: '0700'
- name: Install the pull-backups script
ansible.builtin.template:
src: pull-backups.sh.j2
dest: /usr/local/bin/pull-backups.sh
owner: root
group: root
mode: '0755'
validate: "bash -n %s"
become: yes
backups: monitor both the dump and the pull, per source Twelve endpoints in two groups, because they answer different questions and fail for different reasons: backup-dump_<svc> pushed by the SOURCE right after its dump runs backup-store_<svc> pushed by the BOX at 05:30, per source backup-store_pull-job pushed by the BOX, about the box itself The store alone could catch almost everything, because the artefact filename carries the source's dump timestamp - a source whose timer died still pulls "ok" forever, but the timestamp gives it away. What the source side adds is LATENCY and DIAGNOSIS: the store only learns at the next 04:00 pull, and it cannot tell you whether the dump broke or the pull did. pull-job is separate from the per-source checks because it is a LEADING indicator where those are lagging ones. A disabled pull timer, a failed pull job, or a filling disk are all visible immediately, while the per-source checks only fire once an artefact is >26h stale - about a day later. Disable the timer at 10:00 and every source stays green until tomorrow; pull-job goes red this morning and names the cause instead of showing six stale sources. Frequencies: dumps 02:00-02:30 staggered, pull 04:00, verification 05:30, all daily and Persistent. 26h staleness decides red; a 30h Gatus heartbeat catches the verification itself having stopped, so a dead check-backups.timer cannot hide a stale backup. arbret has no dump endpoint: prd-arbret is in [arbret], which `managed` deliberately excludes, so nothing of ours runs there. Store-checked only. check-backups.sh was manual-only; it now runs on a timer and reports per source rather than only printing. The human-readable report is unchanged. A SERIOUS bug introduced and fixed in this change, recorded because the shape is easy to repeat: the reporting hook was added to backup.sh as a second `trap ... EXIT`. Bash REPLACES the EXIT handler rather than adding to it, so that silently deleted the trap which restarts the stopped service - the one the script's own comment calls "the point", and the bug the role was written to eliminate. Every backup then stopped its service and left it stopped. It took forgejo, lnbits, headscale and memos down for several minutes each, and nothing caught it: the dumps exit 0, the artefacts are correct, the deploy reports failed=0, and liveness only proves the HOST is up. There is now ONE EXIT handler doing both jobs, armed BEFORE the stop so a failure during the stop still restarts. Verified by rendering both variants, asserting exactly one EXIT trap in each, and simulating a mid-way failure to confirm the restart fires. Verified: all 12 endpoints UP; six sources pulled cleanly (arbret 31M, headscale 198K, memos 7.8M, vaultwarden 2.1M, lnbits 30M, forgejo 2.6G), store 16% full, "RESULT: all checks passed". Known gaps, deliberately not closed here: * `yell` warnings - disk 75-90%, an artefact under half the previous size, retention not pruning - never reach Gatus, because a push is binary. * Nothing verifies a backed-up service came back UP. That is the gap that let the trap bug run unnoticed, and it is what the next change addresses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:22:12 +02:00
# An assertion that last night actually worked. Generated from the same source
# list as the puller, so it can never drift out of sync with what is supposed to
# be arriving. Runs on a timer AND is useful by hand.
2026-09-12 17:57:43 +02:00
- name: Install the backup check script
ansible.builtin.template:
src: check-backups.sh.j2
dest: /usr/local/bin/check-backups.sh
owner: root
group: root
mode: '0755'
validate: "bash -n %s"
become: yes
backups: monitor both the dump and the pull, per source Twelve endpoints in two groups, because they answer different questions and fail for different reasons: backup-dump_<svc> pushed by the SOURCE right after its dump runs backup-store_<svc> pushed by the BOX at 05:30, per source backup-store_pull-job pushed by the BOX, about the box itself The store alone could catch almost everything, because the artefact filename carries the source's dump timestamp - a source whose timer died still pulls "ok" forever, but the timestamp gives it away. What the source side adds is LATENCY and DIAGNOSIS: the store only learns at the next 04:00 pull, and it cannot tell you whether the dump broke or the pull did. pull-job is separate from the per-source checks because it is a LEADING indicator where those are lagging ones. A disabled pull timer, a failed pull job, or a filling disk are all visible immediately, while the per-source checks only fire once an artefact is >26h stale - about a day later. Disable the timer at 10:00 and every source stays green until tomorrow; pull-job goes red this morning and names the cause instead of showing six stale sources. Frequencies: dumps 02:00-02:30 staggered, pull 04:00, verification 05:30, all daily and Persistent. 26h staleness decides red; a 30h Gatus heartbeat catches the verification itself having stopped, so a dead check-backups.timer cannot hide a stale backup. arbret has no dump endpoint: prd-arbret is in [arbret], which `managed` deliberately excludes, so nothing of ours runs there. Store-checked only. check-backups.sh was manual-only; it now runs on a timer and reports per source rather than only printing. The human-readable report is unchanged. A SERIOUS bug introduced and fixed in this change, recorded because the shape is easy to repeat: the reporting hook was added to backup.sh as a second `trap ... EXIT`. Bash REPLACES the EXIT handler rather than adding to it, so that silently deleted the trap which restarts the stopped service - the one the script's own comment calls "the point", and the bug the role was written to eliminate. Every backup then stopped its service and left it stopped. It took forgejo, lnbits, headscale and memos down for several minutes each, and nothing caught it: the dumps exit 0, the artefacts are correct, the deploy reports failed=0, and liveness only proves the HOST is up. There is now ONE EXIT handler doing both jobs, armed BEFORE the stop so a failure during the stop still restarts. Verified by rendering both variants, asserting exactly one EXIT trap in each, and simulating a mid-way failure to confirm the restart fires. Verified: all 12 endpoints UP; six sources pulled cleanly (arbret 31M, headscale 198K, memos 7.8M, vaultwarden 2.1M, lnbits 30M, forgejo 2.6G), store 16% full, "RESULT: all checks passed". Known gaps, deliberately not closed here: * `yell` warnings - disk 75-90%, an artefact under half the previous size, retention not pruning - never reach Gatus, because a push is binary. * Nothing verifies a backed-up service came back UP. That is the gap that let the trap bug run unnoticed, and it is what the next change addresses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:22:12 +02:00
# The .service carries the push token, so it is 0600; the .timer is not secret.
- name: Install the check-backups systemd units
ansible.builtin.template:
src: "check-backups.{{ item.unit }}.j2"
dest: "/etc/systemd/system/check-backups.{{ item.unit }}"
owner: root
group: root
mode: "{{ item.mode }}"
loop:
- {unit: service, mode: "0600"}
- {unit: timer, mode: "0644"}
become: yes
# restarted, not started: `started` is a no-op on an already-active timer, so a
# changed schedule would never be picked up.
- name: Enable the check-backups timer
ansible.builtin.systemd:
name: check-backups.timer
enabled: yes
state: restarted
daemon_reload: yes
become: yes
2026-09-12 16:02:00 +02:00
- name: Install the pull-backups systemd units
ansible.builtin.template:
src: "pull-backups.{{ item }}.j2"
dest: "/etc/systemd/system/pull-backups.{{ item }}"
owner: root
group: root
mode: '0644'
loop: [service, timer]
become: yes
notify: Reload systemd for pull-backups
- name: Enable the pull-backups timer
ansible.builtin.systemd:
name: pull-backups.timer
enabled: yes
state: started
daemon_reload: yes
become: yes