ansible-taler-exchange

Ansible playbook to deploy a production Taler Exchange
Log | Files | Refs | README | LICENSE

commit c416ef11cce47fb225d1f0de3490310748123111
parent 35e55f0716866aefb0e72de35f5db9bc38f9dcad
Author: Florian Dold <dold@taler.net>
Date:   Sat,  5 Sep 2026 20:12:02 +0200

Backups: serialize snapshots and preserve repository settings

Share backup and restore repository defaults and use a dedicated SSH
configuration without replacing administrator settings. Lock the full
backup operation, clean up failed dumps, and skip retention after
archive warnings or failures.

Diffstat:
MREADME | 20++++++++++++++++++--
Acontrib/tests/test_backup.py | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minventories/group_vars/all/defaults.yml | 5+++++
Mplaybooks/borg-ssh-export.yml | 3---
Mplaybooks/borg-start.yml | 5-----
Mroles/borg-ssh-export/tasks/main.yml | 2+-
Mroles/borg-start/tasks/main.yml | 45++++++++++++++++++++++++++++++++++-----------
Mroles/borg-start/templates/root/bin/borg-backup.sh | 40+++++++++++++++++++++++++++++-----------
Mroles/database_restore/defaults/main.yml | 5+++--
9 files changed, 270 insertions(+), 35 deletions(-)

diff --git a/README b/README @@ -130,8 +130,8 @@ $ ./extract-borg-key.sh $DEPLOYMENT ``` The resulting SSH public key should be added to the borg-account -of the host storing the backup. The playbook contains the target -hostname! +of the host storing the backup. The borg_host inventory setting selects the target +hostname. Once the SSH key is deployed and the backup has been initialized server-side (see admin-logs/pixel/03-borg.txt), start the daily @@ -329,3 +329,19 @@ $ ./contrib/test-fact-helpers.sh # the /bin helpers that generate local facts ``` Also run by the CI job in "contrib/ci/jobs/001-build". + +## Deployment safety and recovery + +Backup and restore share borg_host and borg_repo inventory defaults, retaining +ssh://borg@pixel.taler-systems.com/~/spec-backup for existing installations. Set +borg_repo in host variables to use another already provisioned repository. +Existing database_restore_borg_repository and database_restore_borg_host overrides +remain supported. No archives are moved automatically. + +The backup script uses /root/.ssh/borg-config and leaves /root/.ssh/config intact. +A lock protects the entire backup operation. Install the updated backup script +between backup runs: an already running older script does not acquire this lock. If cron, backup.sh or reboot.sh +encounters another backup, it exits nonzero (script status 75) without touching +that run's snapshot. Retry after the existing backup finishes; a reboot playbook +aborts before rebooting on this failure. Pruning and compaction are skipped after +an unsuccessful or warning-producing archive creation. diff --git a/contrib/tests/test_backup.py b/contrib/tests/test_backup.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Run the rendered backup script against disposable data and command fixtures.""" +import json +import os +from pathlib import Path +import shlex +import shutil +import signal +import subprocess +import tempfile +import time +import unittest + +from jinja2 import Environment + +REPO = Path(__file__).resolve().parents[2] +SOURCE = REPO / 'roles/borg-start/templates/root/bin/borg-backup.sh' + + +class BackupTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='taler-backup-test-') + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.root = self.base / 'root' + self.root.mkdir() + self.bin = self.base / 'bin' + self.bin.mkdir() + self.env = dict(os.environ, PATH=f'{self.bin}:{os.environ["PATH"]}', + TEST_BASE=str(self.base), TEST_ROOT=str(self.root)) + self.executable('sudo', '''#!/bin/sh +printf '%s\\n' '-- synthetic database snapshot' +exit "${DUMP_EXIT:-0}" +''') + self.executable('borg', '''#!/usr/bin/env python3 +import json, os, pathlib, subprocess, sys, time +base = pathlib.Path(os.environ['TEST_BASE']) +command = sys.argv[1] +with (base / 'calls').open('a') as out: + out.write(command + '\\n') +if command == 'create': + (base / 'entered').touch() + while os.environ.get('BLOCK_CREATE') and not (base / 'release').exists(): + time.sleep(.02) + assert (base / 'root/postgres-backup.sql.gz').exists() + (base / 'passphrase').write_text(os.environ['BORG_PASSPHRASE']) +if os.environ.get('REAL_BORG'): + args = [a.lstrip('/') if a in ['/root', '/etc', '/var/lib/libeufin-nexus', '/var/lib/taler-exchange'] else a for a in sys.argv[1:]] + sys.exit(subprocess.call([os.environ['REAL_BORG'], *args], cwd=base)) +sys.exit(int(os.environ.get(command.upper() + '_EXIT', '0'))) +''') + env = Environment() + env.filters['quote'] = shlex.quote + self.passphrase = "test passphrase with ' and $() characters" + text = env.from_string(SOURCE.read_text()).render( + borg_repo=str(self.base / 'repository'), borg_passphrase=self.passphrase) + # Only filesystem locations are redirected; the locking, command ordering, + # error handling, compression, and cleanup are the production script. + text = text.replace('/run/taler-borg-backup.lock', str(self.base / 'lock')) + text = text.replace('cd /root', 'cd "$TEST_ROOT"') + self.script = self.base / 'backup.sh' + self.script.write_text(text) + + def executable(self, name, contents): + path = self.bin / name + path.write_text(contents) + path.chmod(0o700) + + def run_backup(self, **env): + return subprocess.run(['bash', str(self.script)], env=dict(self.env, **env), + capture_output=True, timeout=20) + + def calls(self): + p = self.base / 'calls' + return p.read_text().splitlines() if p.exists() else [] + + def assert_clean(self): + self.assertFalse((self.root / 'postgres-backup.sql').exists()) + self.assertFalse((self.root / 'postgres-backup.sql.gz').exists()) + + def blocked_backup(self): + p = subprocess.Popen(['bash', str(self.script)], env=dict(self.env, BLOCK_CREATE='1'), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + self.addCleanup(lambda: self.stop_process(p)) + deadline = time.monotonic() + 10 + while not (self.base / 'entered').exists(): + if p.poll() is not None or time.monotonic() > deadline: + self.fail('backup never reached archive creation') + time.sleep(.02) + return p + + @staticmethod + def stop_process(p): + if p.poll() is None: + os.killpg(p.pid, signal.SIGTERM) + p.wait(timeout=5) + + @unittest.skipUnless(shutil.which('shellcheck'), 'ShellCheck is not installed') + def test_rendered_script_shellcheck(self): + result = subprocess.run(['shellcheck', '-S', 'warning', str(self.script)], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_success_and_shell_quoting(self): + result = self.run_backup() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.calls(), ['create', 'prune', 'compact']) + self.assertEqual((self.base / 'passphrase').read_text(), self.passphrase) + self.assertNotIn(self.passphrase.encode(), result.stdout + result.stderr) + self.assert_clean() + + def test_competing_run_cannot_remove_snapshot(self): + first = self.blocked_backup() + before = (self.root / 'postgres-backup.sql.gz').read_bytes() + second = self.run_backup() + self.assertEqual(second.returncode, 75) + self.assertEqual((self.root / 'postgres-backup.sql.gz').read_bytes(), before) + self.assertEqual(self.calls(), ['create']) + (self.base / 'release').touch() + self.assertEqual(first.wait(timeout=10), 0) + self.assert_clean() + + def test_dump_failure(self): + self.assertNotEqual(self.run_backup(DUMP_EXIT='1').returncode, 0) + self.assertEqual(self.calls(), []) + self.assert_clean() + + def test_compression_failure(self): + self.executable('gzip', '#!/bin/sh\nexit 1\n') + self.assertNotEqual(self.run_backup().returncode, 0) + self.assertEqual(self.calls(), []) + self.assert_clean() + + def test_create_warning_and_failure_skip_retention(self): + for code in [1, 2]: + with self.subTest(code=code): + (self.base / 'calls').unlink(missing_ok=True) + self.assertEqual(self.run_backup(CREATE_EXIT=str(code)).returncode, code) + self.assertEqual(self.calls(), ['create']) + self.assert_clean() + + def test_prune_failure_skips_compaction(self): + self.assertEqual(self.run_backup(PRUNE_EXIT='2').returncode, 2) + self.assertEqual(self.calls(), ['create', 'prune']) + self.assert_clean() + + def test_compaction_failure_is_reported(self): + self.assertEqual(self.run_backup(COMPACT_EXIT='2').returncode, 2) + self.assert_clean() + + def test_interrupt_cleans_snapshot_and_releases_lock(self): + first = self.blocked_backup() + os.killpg(first.pid, signal.SIGTERM) + self.assertEqual(first.wait(timeout=10), 2) + self.assert_clean() + self.assertEqual(self.run_backup().returncode, 0) + + @unittest.skipUnless(shutil.which('borg'), 'Borg required for archive round trip') + def test_real_archive_contains_restore_member(self): + borg = shutil.which('borg') + for directory in ['etc', 'var/lib/libeufin-nexus', 'var/lib/taler-exchange']: + (self.base / directory).mkdir(parents=True, exist_ok=True) + repo = str(self.base / 'repository') + env = dict(self.env, BORG_REPO=repo, BORG_PASSPHRASE=self.passphrase) + subprocess.run([borg, 'init', '--encryption=repokey'], env=env, check=True, + capture_output=True) + result = self.run_backup(REAL_BORG=borg) + self.assertEqual(result.returncode, 0, result.stderr) + archives = json.loads(subprocess.check_output([borg, 'list', '--json'], env=env)) + archive = archives['archives'][0]['name'] + dump = subprocess.check_output([borg, 'extract', '--stdout', f'::{archive}', + 'root/postgres-backup.sql.gz'], env=env) + import gzip + self.assertEqual(gzip.decompress(dump), b'-- synthetic database snapshot\n') + self.assert_clean() + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/inventories/group_vars/all/defaults.yml b/inventories/group_vars/all/defaults.yml @@ -49,3 +49,8 @@ taler_repo_suites: "{{ ansible_facts['distribution_release'] }}" # Use letsencrypt by default exchange_use_letsencrypt: true nexus_use_letsencrypt: true + +# Shared backup/restore location. Preserve the repository used by existing +# backup scripts; override borg_repo per host for independently provisioned repos. +borg_host: pixel.taler-systems.com +borg_repo: "ssh://borg@{{ borg_host }}/~/spec-backup" diff --git a/playbooks/borg-ssh-export.yml b/playbooks/borg-ssh-export.yml @@ -3,6 +3,3 @@ hosts: all roles: - borg-ssh-export - vars: - # Hostname where we will store backups - borg_host: pixel.taler-systems.com diff --git a/playbooks/borg-start.yml b/playbooks/borg-start.yml @@ -4,8 +4,3 @@ any_errors_fatal: true roles: - borg-start - vars: - # Hostname where we will store backups - borg_host: pixel.taler-systems.com - # Target for the backup (repo must exist and we must have SSH access). - borg_repo: "ssh://borg@{{ borg_host }}/~/spec-backup" diff --git a/roles/borg-ssh-export/tasks/main.yml b/roles/borg-ssh-export/tasks/main.yml @@ -15,7 +15,7 @@ state: directory owner: root group: root - mode: "0744" + mode: "0700" - name: Create SSH key pair for use for backups by root ansible.builtin.command: diff --git a/roles/borg-start/tasks/main.yml b/roles/borg-start/tasks/main.yml @@ -1,4 +1,29 @@ --- +- name: Install backup prerequisites + ansible.builtin.apt: + name: [borgbackup, gzip, cron, util-linux, sudo] + state: present + update_cache: true + policy_rc_d: 101 + +- name: Ensure the private SSH directory exists + ansible.builtin.file: + path: /root/.ssh + state: directory + owner: root + group: root + mode: "0700" + +- name: Check SSH key for backups exists + stat: + path: "/root/.ssh/borg" + register: have_ssh_key + +- name: Fail if we do not have an SSH key for the backup server + fail: + msg: "You need to first run extract-borg-key.sh" + when: not have_ssh_key.stat.exists + - name: Ensure /root/bin/ directory exists file: path: "/root/bin/" @@ -14,16 +39,13 @@ owner: root group: root mode: "0700" - -- name: Check SSH key for backups exists - stat: - path: "/root/.ssh/borg" - register: have_ssh_key + no_log: true + diff: false - name: Place ssh configuration ansible.builtin.template: src: templates/root/.ssh/config - dest: /root/.ssh/config + dest: /root/.ssh/borg-config owner: root group: root mode: "0600" @@ -40,14 +62,15 @@ cmd: ssh-keyscan {{ borg_host }} >> /root/.ssh/known_hosts when: known_host.rc != 0 -- name: Fail if we do not have an SSH key for the backup server - fail: - msg: "You need to first run extract-borg-key.sh" - when: not have_ssh_key.stat.exists - - name: Create cron job to run daily backups ansible.builtin.cron: name: "perform backup" minute: "43" hour: "2" job: "/root/bin/borg-backup.sh" + +- name: Enable the cron service + ansible.builtin.systemd: + name: cron + state: started + enabled: true diff --git a/roles/borg-start/templates/root/bin/borg-backup.sh b/roles/borg-start/templates/root/bin/borg-backup.sh @@ -1,16 +1,30 @@ #!/bin/bash -export BORG_REPO='{{ borg_repo }}' -export BORG_PASSPHRASE='{{ borg_passphrase }}' - -# some helpers and error handling: -info() { printf "\n%s %s\n\n" "$( date )" "$*" >&2; } -trap 'echo $( date ) Backup interrupted >&2; exit 2' INT TERM - -cd /root +export BORG_REPO={{ borg_repo | quote }} +export BORG_PASSPHRASE={{ borg_passphrase | quote }} +export BORG_RSH='ssh -F /root/.ssh/borg-config' + +umask 077 +info() { printf "\n%s %s\n\n" "$(date)" "$*" >&2; } + +# Acquire the lock before changing any dump files or installing cleanup traps. +# A competing cron/manual/reboot run must not touch the owner's snapshot. +exec 9>/run/taler-borg-backup.lock || exit 2 +if ! flock --exclusive --nonblock 9; then + info "Another backup is running; this run made no changes" + exit 75 +fi +cd /root || exit 2 +cleanup() { + rm -f -- postgres-backup.sql postgres-backup.sql.gz +} +trap cleanup EXIT +trap 'exit 2' HUP INT TERM info "Dumping database" +# The root-owned output is intentionally opened by this shell. +# shellcheck disable=SC2024 sudo -u postgres pg_dumpall > postgres-backup.sql db_exit=$? @@ -56,10 +70,10 @@ backup_exit=$? info "Removing database dump" -rm postgres-backup.sql.gz +rm -f -- postgres-backup.sql.gz || exit 2 -if [[ $backup_exit -gt 1 ]]; then - info "Backup failed, exit status $backup_exit; not pruning" +if [[ $backup_exit -ne 0 ]]; then + info "Backup did not complete cleanly, exit status $backup_exit; not pruning" exit $backup_exit fi @@ -79,6 +93,10 @@ borg prune \ --keep-monthly 6 prune_exit=$? +if [[ $prune_exit -ne 0 ]]; then + info "Prune did not complete cleanly, exit status $prune_exit; not compacting" + exit "$prune_exit" +fi # actually free repo disk space by compacting segments diff --git a/roles/database_restore/defaults/main.yml b/roles/database_restore/defaults/main.yml @@ -1,5 +1,6 @@ --- -database_restore_borg_host: pixel.taler-systems.com +database_restore_borg_host: "{{ borg_host }}" database_restore_borg_repository: >- - ssh://borg@{{ database_restore_borg_host }}/~/{{ inventory_hostname }}-backup + {{ borg_repo | replace('ssh://borg@' ~ borg_host ~ '/', + 'ssh://borg@' ~ database_restore_borg_host ~ '/') }} database_restore_archive_member: root/postgres-backup.sql.gz