Files
mhoennig 80f9074cd8
werkator/jvm build succeeded after 03:42
Add verified consistent operator backups
2026-09-09 14:14:13 +02:00

95 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Download a private backup and publish its filename only after full verification."""
import base64
import datetime
import hashlib
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
import tarfile
import tempfile
import uuid
def verify(path):
hashes = {}
documents = {}
with tarfile.open(path, mode='r|gz') as archive:
for entry in archive:
name = entry.name
if (not entry.isfile() or name in hashes or name in {'data/.', 'data/..'} or
not (name in {'backup.json', 'release/manifest.json', 'release/werkjournal.jar'}
or re.fullmatch(r'data/[A-Za-z0-9_.-]+', name))):
raise ValueError('Unexpected or duplicate backup member')
with archive.extractfile(entry) as stream:
if name in {'backup.json', 'release/manifest.json'}:
if entry.size > 1024 * 1024:
raise ValueError('Backup metadata is too large')
content = stream.read()
documents[name] = json.loads(content)
hashes[name] = hashlib.sha256(content).hexdigest()
else:
hashes[name] = hashlib.file_digest(stream, 'sha256').hexdigest()
metadata = documents.get('backup.json', {})
release = documents.get('release/manifest.json', {})
hashes.pop('backup.json', None)
if (metadata.get('schemaVersion') != 1 or metadata.get('application') != 'werkjournal'
or metadata.get('files') != hashes or metadata.get('release') != release
or not {'data/werkjournal.mv.db', 'release/manifest.json', 'release/werkjournal.jar'} <= hashes.keys()
or release.get('schemaVersion') != 1
or release.get('runtime') != 'java-25' or not re.fullmatch('[0-9a-f]{40}', release.get('commit', ''))
or hashes.get('release/werkjournal.jar') != release.get('sha256')
or release.get('files') != {'werkjournal.jar': release.get('sha256')}):
raise ValueError('Backup contents or checksums do not match its metadata')
return metadata
def download(target, destination):
destination = Path(destination)
if destination.exists() or destination.is_symlink():
raise FileExistsError('Backup destination already exists')
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
root = Path(__file__).parent
helper = base64.b64encode((root / 'deploy-update-remote.py').read_bytes()).decode('ascii')
backup = base64.b64encode((root / 'backup-remote.py').read_bytes()).decode('ascii')
program = ("import base64; scope={'__name__':'backup_helpers'}; "
"exec(compile(base64.b64decode('" + helper + "'),'deployment','exec'),scope); "
"scope['__name__']='__main__'; "
"exec(compile(base64.b64decode('" + backup + "'),'backup','exec'),scope)")
fd, temporary = tempfile.mkstemp(prefix='.werkjournal-backup-', dir=destination.parent)
try:
with os.fdopen(fd, 'wb') as output:
result = subprocess.run(['ssh', '-o', 'BatchMode=yes', target,
shlex.join(['python3', '-c', program])], stdout=output)
output.flush()
os.fsync(output.fileno())
if result.returncode != 0:
raise RuntimeError('Remote backup failed; no local backup was published')
metadata = verify(temporary)
# Atomic and never overwrites a backup created meanwhile by another process.
os.link(temporary, destination)
directory = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
return metadata
finally:
Path(temporary).unlink(missing_ok=True)
if __name__ == '__main__':
stamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
destination = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(__file__).resolve().parents[1] / 'backups' / f'werkjournal-{stamp}-{uuid.uuid4().hex[:8]}.tgz'
try:
metadata = download(sys.argv[1], destination)
except (OSError, RuntimeError, ValueError, tarfile.TarError) as error:
print('Backup failed: ' + str(error), file=sys.stderr)
raise SystemExit(1)
print('Verified private backup: ' + str(destination))
print('Application commit: ' + metadata['release']['commit'])