68 lines
2.8 KiB
Python
68 lines
2.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Verify a private snapshot, require explicit confirmation, then restore via one SSH call."""
|
|
import argparse
|
|
import base64
|
|
import datetime
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import runpy
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
ROOT = Path(__file__).parent
|
|
verify = runpy.run_path(str(ROOT / 'backup.py'))['verify']
|
|
|
|
|
|
def remote_command(target, recover=False):
|
|
program = "import base64; scope={'__name__':'restore_helpers'}; "
|
|
for name in ('deploy-update-remote.py', 'backup.py', 'restore-remote.py'):
|
|
encoded = base64.b64encode((ROOT / name).read_bytes()).decode('ascii')
|
|
if name == 'restore-remote.py':
|
|
program += "scope['__name__']='__main__'; "
|
|
program += "exec(compile(base64.b64decode('" + encoded + "'),'" + name + "','exec'),scope); "
|
|
arguments = ['python3', '-c', program]
|
|
if recover:
|
|
arguments.append('--recover')
|
|
return ['ssh', '-o', 'BatchMode=yes', target, shlex.join(arguments)]
|
|
|
|
|
|
def restore(target, source, confirm=input):
|
|
# Freeze the verified bytes before asking for confirmation; the original
|
|
# file can change while an operator is reading the prompt.
|
|
with tempfile.NamedTemporaryFile() as snapshot:
|
|
with Path(source).open('rb') as stream:
|
|
shutil.copyfileobj(stream, snapshot)
|
|
snapshot.flush()
|
|
snapshot.seek(0)
|
|
metadata = verify(snapshot.name)
|
|
commit = metadata['release']['commit']
|
|
stamp = datetime.datetime.fromtimestamp(metadata['createdAt'], datetime.timezone.utc).isoformat()
|
|
print('Target: ' + target)
|
|
print('Backup created: ' + stamp + '; application commit: ' + commit)
|
|
print('This replaces all current account and tracking data with this backup, including its access credentials. Newer data will leave the active database; the prior state is retained privately on the server.')
|
|
required = 'RESTORE ' + commit
|
|
if confirm('Type ' + required + ' to confirm: ') != required:
|
|
raise RuntimeError('Restore cancelled; no SSH connection opened')
|
|
snapshot.seek(0)
|
|
return subprocess.call(remote_command(target), stdin=snapshot)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('target')
|
|
choice = parser.add_mutually_exclusive_group(required=True)
|
|
choice.add_argument('--backup', type=Path)
|
|
choice.add_argument('--recover', action='store_true')
|
|
args = parser.parse_args()
|
|
try:
|
|
if args.recover:
|
|
raise SystemExit(subprocess.call(remote_command(args.target, recover=True)))
|
|
raise SystemExit(restore(args.target, args.backup))
|
|
except (OSError, RuntimeError, ValueError, EOFError) as error:
|
|
print('Restore failed: ' + str(error), file=sys.stderr)
|
|
raise SystemExit(1)
|