"""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)