Mini Shell
import json
from datetime import datetime, timedelta
from os import kill
from pathlib import Path
import signal
from subprocess import PIPE, CalledProcessError, Popen
import sys
import time
import traceback
import multiprocessing
from itertools import chain
from threading import Thread
from typing import List
from flask import current_app as app
from flask import render_template, request, jsonify, abort
from bakmgr.api.restic import Restic, ResticError
from bakmgr.api.bakauth import BakAuthError
from bakmgr.configs import Conf
from bakmgr.dash.dash_helpers import get_reg, iter_dir, login
RUN_DIR = Path('/run/bakmgr/restores')
RESTORE_DIR = Path('/var/lib/bakmgr/restores')
AUTO_DISMISS = timedelta(days=14).total_seconds()
@app.route('/restore', methods=['GET', 'POST', 'DELETE'])
@login
def restore_page():
if request.method == 'GET':
return render_template("restore.html.jinja", page='restore')
if request.method == 'DELETE':
if 'name' in request.form:
# dismissing a completed restore
try:
float(request.form['name'])
except ValueError:
app.logger.error(
"Invalid restore name: %s", request.form['name']
)
abort(400)
path = RESTORE_DIR / f"{request.form['name']}.json"
path.unlink(missing_ok=True)
else:
# killing a pending restore
try:
pid = int(request.form['pid'])
except ValueError:
app.logger.error("Invalid restore pid: %s", request.form['pid'])
path = RUN_DIR / str(pid)
if not path.is_file():
return 'OK' # already ended
app.logger.warning('killing restore PID %s', pid)
kill(pid, signal.SIGUSR1)
return 'OK'
# requesting a restore
if request.form['task'] not in ('files', 'pgsql', 'mysql'):
abort(400)
try:
if request.form['task'] == 'files':
proc = FileRestoreProc(
snapshot=request.form['snapshot'],
include=[Path(x) for x in request.form['include'].splitlines()],
dest=Path(request.form['dest']),
date=request.form['date'],
)
else:
proc = SqlRestoreProc(
task_name=request.form['task'],
snapshot=request.form['snapshot'],
compress=int(request.form['compress']) == 1,
dest=Path(request.form['dest']),
date=request.form['date'],
)
Thread(target=proc.join, daemon=True).start()
except (BakAuthError, ValueError) as exc:
return jsonify(success=False, text=str(exc))
return jsonify(success=True, text="Started")
@app.route('/list_backups')
@login
def list_backups():
try:
restic = Restic(Conf(), reg=get_reg())
backups = {k: v for k, v in restic.get_backups().items() if v}
except (ResticError, BakAuthError) as exc:
app.logger.error(exc)
return render_template("error_div.html.jinja", error=str(exc))
except Exception as exc:
app.logger.error(traceback.format_exc())
return render_template(
"error_div.html.jinja", error=f"{type(exc).__name__}: {exc}"
)
return render_template("list_backups.html.jinja", backups=backups)
@app.route('/list_restores')
@login
def list_restores():
pids = []
for path in iter_dir(RUN_DIR):
try:
pid = int(path.name)
except ValueError:
path.unlink()
continue
try:
kill(pid, 0)
except OSError:
path.unlink()
continue
pids.append(pid)
finished = {}
dismiss = time.time() - AUTO_DISMISS
for path in iter_dir(RESTORE_DIR):
if not path.name.endswith('.json'):
path.unlink(missing_ok=True)
continue
try:
fin_time = float(path.name[:-5])
if fin_time < dismiss:
path.unlink(missing_ok=True)
continue
finished[fin_time] = json.loads(path.read_text('utf-8'))
except ValueError:
path.unlink(missing_ok=True)
continue
return render_template(
'list_restores.html.jinja', pids=pids, finished=finished
)
class RestoreProc(multiprocessing.Process):
def __init__(self, snapshot: str, dest: Path, task_name: str, date: str):
signal.signal(signal.SIGUSR1, self.kill_restore)
self.restic_pids = set()
self.date = date
self.task_name = task_name
self.snapshot = snapshot
self.restic = Restic(Conf(), reg=get_reg())
self._log = []
if not dest.is_absolute():
raise ValueError(f"{dest} is not an absolute path")
self.dest = dest
# TODO: evaluate if daemon=True or False is better here
super().__init__(target=self.restore_task, daemon=True)
self.start()
def kill_restore(self, *_):
self.log("killing restore process")
for pid in self.restic_pids:
try:
kill(pid, signal.SIGINT)
except OSError:
pass
def restore_task(self):
RUN_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
pid_path = RUN_DIR / str(self.pid)
pid_path.touch(mode=0o644, exist_ok=True)
try:
success = self.task()
except ResticError as exc:
success = False
self.log(str(exc))
except Exception as exc:
success = False
print(traceback.format_exc(), file=sys.stderr)
self.log(f"Restore crashed with {type(exc).__name__}: {exc}")
if success:
self.log("Success")
RESTORE_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
path = RESTORE_DIR / f"{time.time()}.json"
data = {
'success': success,
'log': self._log,
'task': self.task_name,
'date': self.date,
}
with open(path, 'w', encoding='utf-8') as file:
json.dump(data, file, indent=4)
def log(self, msg: str):
when = datetime.now().strftime("%d/%m/%y %I%p")
line = f"{when}: {msg}"
self._log.append(line)
print(line, file=sys.stderr)
def task(self) -> bool:
raise NotImplementedError
class FileRestoreProc(RestoreProc):
def __init__(
self, *, snapshot: str, include: List[Path], dest: Path, date: str
):
for path in include:
if not path.is_absolute():
raise ValueError(f"{path} is not an absolute path")
self.include = include
super().__init__(snapshot, dest, 'files', date)
def task(self) -> bool:
incl = chain(*[['--include', str(x)] for x in self.include])
proc = self.restic.proc(
'restore',
'--target',
str(self.dest),
*incl,
self.snapshot,
mon=False,
)
self.restic_pids.add(proc.pid)
try:
proc.complete(check=True)
except CalledProcessError as exc:
raise ResticError(exc, self.restic) from exc
return True
class SqlRestoreProc(RestoreProc):
def __init__(
self,
*,
task_name: str,
snapshot: str,
compress: bool,
dest: Path,
date: str,
):
self.compress = compress
super().__init__(snapshot, dest, task_name, date)
def task(self) -> bool:
args = ('dump', self.snapshot, f'/root/{self.task_name}_dump.sql')
kwargs = dict(mon=False, encoding=None)
with self.dest.open('wb') as file:
try:
if self.compress:
with Popen(
['gzip', '--stdout'], stdout=file, stdin=PIPE
) as gzip:
proc = self.restic.proc(
*args, **kwargs, stdout=gzip.stdin
)
self.restic_pids.add(proc.pid)
proc.complete(check=True)
else:
proc = self.restic.proc(*args, **kwargs, stdout=file)
proc.complete(check=True)
except CalledProcessError as exc:
raise ResticError(exc, self.restic) from exc
return True
Zerion Mini Shell 1.0