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

65 lines
3.3 KiB
Python

"""Closed-file operator backup. Shared Deployment helpers are injected by the caller."""
def backup(deployment, output):
deployment.preflight()
if deployment.pending.exists():
raise RuntimeError('Resolve the pending deployment before creating a backup')
expected = manifest(deployment.app / 'current')
if expected != json.loads((deployment.app / 'deployed.json').read_text()):
raise RuntimeError('Current release differs from the verified deployment manifest')
state = deployment.command('systemctl', '--user', 'show', deployment.service, '--property=ActiveState', '--value')
if state not in {'active', 'inactive', 'failed'}:
raise RuntimeError('Service is transitioning; retry when its state is stable')
was_running = state == 'active'
if not was_running and deployment.command('systemctl', '--user', 'show', deployment.service, '--property=MainPID', '--value') != '0':
raise RuntimeError('Stopped service still has a process')
had_maintenance = deployment.flag.exists()
with tempfile.TemporaryDirectory(prefix='manual-backup-', dir=deployment.app) as temporary:
snapshot = Path(temporary)
(snapshot / 'release').mkdir(mode=0o700)
# Immutable release files can be copied before stopping, under the deployment lock.
for name in ('werkjournal.jar', 'manifest.json'):
shutil.copyfile(deployment.app / 'current' / name, snapshot / 'release' / name)
if not had_maintenance:
deployment.maintenance()
if was_running:
deployment.stop()
try:
shutil.copytree(deployment.app / 'data', snapshot / 'data')
files = {}
for path in sorted(snapshot.rglob('*')):
if path.is_file():
with path.open('rb') as stream:
os.fsync(stream.fileno())
files[path.relative_to(snapshot).as_posix()] = checksum(path)
atomic(snapshot / 'backup.json', json.dumps({
'schemaVersion': 1, 'application': 'werkjournal', 'createdAt': int(time.time()),
'release': expected, 'files': files}, indent=2) + '\n')
finally:
if was_running:
deployment.start()
deployment.ready(expected)
if not had_maintenance:
deployment.flag.unlink()
sync_directory(deployment.site)
# Transfer only after the former operating state has been restored.
with tarfile.open(fileobj=output, mode='w|gz') as archive:
for name in ['backup.json', *sorted(files)]:
archive.add(snapshot / name, arcname=name, recursive=False)
if __name__ == '__main__':
os.umask(0o077)
deployment = Deployment(Path.home())
try:
with (deployment.app / '.deploy.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
backup(deployment, sys.stdout.buffer)
except BlockingIOError:
print('Backup refused: another deployment or maintenance operation holds the lock.', file=sys.stderr)
raise SystemExit(1)
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as error:
print('Backup failed: ' + str(error), file=sys.stderr)
raise SystemExit(1)