Files
dtf-system/tests/backup_test.py
Cauê Faleiros 20403c5132
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 3m38s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m2s
feat: daily encrypted database backup to a bucket of its own
A backup service runs pg_dump every day at 03:00 Brasília, checks the archive,
encrypts it with age to a public key and uploads it with a token for that
bucket only. The server cannot read or delete backups: the private key stays
with the owner, the bucket's lifecycle rule expires copies and its lock stops
early deletion. Each run is recorded and shown on the Kanban's Integrations
tab. tests/backup_test.py backs up, restores into a scratch database and
compares the rows in CI. Setup and restore: docs/BACKUP.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:49:21 -03:00

80 lines
3.1 KiB
Python

"""The database backup against the local stack: dump, encrypt, upload, restore.
docker compose -f compose.local.yaml exec -T backup python -m tests.backup_test
Uses a throwaway age key made here, so nothing secret is kept in the repository.
"""
import os
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from botocore.exceptions import ClientError
from app.bootstrap import admin_connect
from ops import db_backup
def check(condition, message):
if not condition:
raise AssertionError(message)
print('PASS:', message)
def main():
with tempfile.TemporaryDirectory() as work:
identity = Path(work, 'identity.txt')
subprocess.run(['age-keygen', '-o', str(identity)], check=True, capture_output=True)
public = next(line.split(': ', 1)[1] for line in identity.read_text().splitlines()
if line.startswith('# public key: '))
os.environ['BACKUP_AGE_RECIPIENT'] = public
check(db_backup.configured(), 'the local backup service is configured once a recipient is set')
live = db_backup.counts(None)
key = db_backup.run_once()
with admin_connect() as c:
row = c.execute('SELECT status,bytes FROM dtf_local.backups WHERE object_key=%s', (key,)).fetchone()
check(row is not None and row[0] == 'ok' and row[1] > 0, 'the run is recorded for the Kanban')
client, bucket = db_backup.bucket()
head = client.get_object(Bucket=bucket, Key=key, Range='bytes=0-20')['Body'].read()
check(head.startswith(b'age-encryption.org/v1'), 'what leaves the server is encrypted')
check(key in [k for k, _, _ in db_backup.listing()], 'the backup is listed in its bucket')
try:
client.delete_object(Bucket=bucket, Key=key)
deleted = True
except ClientError:
deleted = False
check(not deleted and key in [k for k, _, _ in db_backup.listing()],
'the backup credential cannot delete backups')
restored = db_backup.verify(str(identity), key)
check(restored == live, f'the restore has the same rows as the live database ({restored})')
live_name = db_backup.admin_params()['dbname']
try:
db_backup.restore(key, str(identity), live_name)
refused = False
except SystemExit:
refused = True
check(refused, 'a restore over the live database is refused')
other = Path(work, 'other.txt')
subprocess.run(['age-keygen', '-o', str(other)], check=True, capture_output=True)
try:
db_backup.verify(str(other), key)
opened = True
except RuntimeError:
opened = False
check(not opened, 'another key cannot open the backup')
brt = lambda h, m=0: datetime(2026, 9, 29, h + 3, m, tzinfo=timezone.utc)
check(db_backup.next_run(brt(2), 3) == brt(3), 'before 03:00 in Brasília the run is that day')
check(db_backup.next_run(brt(3), 3) == datetime(2026, 9, 30, 6, tzinfo=timezone.utc),
'at or after 03:00 the run is the next day')
if __name__ == '__main__':
main()