import subprocess import json import datetime import tempfile from typing import List from collections.abc import Iterable from json.decoder import JSONDecodeError from os.path import exists, join from os import listdir from time import sleep from selfprivacy_api.backup.util import output_yielder, sync from selfprivacy_api.backup.backuppers import AbstractBackupper from selfprivacy_api.models.backup.snapshot import Snapshot from selfprivacy_api.backup.jobs import get_backup_job from selfprivacy_api.services import get_service_by_id from selfprivacy_api.jobs import Jobs, JobStatus from selfprivacy_api.backup.local_secret import LocalBackupSecret SHORT_ID_LEN = 8 class ResticBackupper(AbstractBackupper): def __init__(self, login_flag: str, key_flag: str, storage_type: str) -> None: self.login_flag = login_flag self.key_flag = key_flag self.storage_type = storage_type self.account = "" self.key = "" self.repo = "" super().__init__() def set_creds(self, account: str, key: str, repo: str) -> None: self.account = account self.key = key self.repo = repo def restic_repo(self) -> str: # https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#other-services-via-rclone # https://forum.rclone.org/t/can-rclone-be-run-solely-with-command-line-options-no-config-no-env-vars/6314/5 return f"rclone:{self.storage_type}{self.repo}" def rclone_args(self): return "rclone.args=serve restic --stdio " + self.backend_rclone_args() def backend_rclone_args(self) -> str: acc_arg = "" key_arg = "" if self.account != "": acc_arg = f"{self.login_flag} {self.account}" if self.key != "": key_arg = f"{self.key_flag} {self.key}" return f"{acc_arg} {key_arg}" def _password_command(self): return f"echo {LocalBackupSecret.get()}" def restic_command(self, *args, tag: str = "") -> List[str]: command = [ "restic", "-o", self.rclone_args(), "-r", self.restic_repo(), "--password-command", self._password_command(), ] if tag != "": command.extend( [ "--tag", tag, ] ) if args: command.extend(ResticBackupper.__flatten_list(args)) return command def mount_repo(self, mount_directory): mount_command = self.restic_command("mount", mount_directory) mount_command.insert(0, "nohup") handle = subprocess.Popen( mount_command, stdout=subprocess.DEVNULL, shell=False, ) sleep(2) if "ids" not in listdir(mount_directory): raise IOError("failed to mount dir ", mount_directory) return handle def unmount_repo(self, mount_directory): mount_command = ["umount", "-l", mount_directory] with subprocess.Popen( mount_command, stdout=subprocess.PIPE, shell=False ) as handle: output = handle.communicate()[0].decode("utf-8") # TODO: check for exit code? if "error" in output.lower(): return IOError("failed to unmount dir ", mount_directory, ": ", output) if not listdir(mount_directory) == []: return IOError("failed to unmount dir ", mount_directory) @staticmethod def __flatten_list(list_to_flatten): """string-aware list flattener""" result = [] for item in list_to_flatten: if isinstance(item, Iterable) and not isinstance(item, str): result.extend(ResticBackupper.__flatten_list(item)) continue result.append(item) return result def start_backup(self, folders: List[str], tag: str) -> Snapshot: """ Start backup with restic """ # but maybe it is ok to accept a union # of a string and an array of strings assert not isinstance(folders, str) backup_command = self.restic_command( "backup", "--json", folders, tag=tag, ) messages = [] service = get_service_by_id(tag) if service is None: raise ValueError("No service with id ", tag) job = get_backup_job(service) try: for raw_message in output_yielder(backup_command): message = self.parse_message( raw_message, job, ) messages.append(message) return ResticBackupper._snapshot_from_backup_messages( messages, tag, ) except ValueError as error: raise ValueError("Could not create a snapshot: ", messages) from error @staticmethod def _snapshot_from_backup_messages(messages, repo_name) -> Snapshot: for message in messages: if message["message_type"] == "summary": return ResticBackupper._snapshot_from_fresh_summary( message, repo_name, ) raise ValueError("no summary message in restic json output") def parse_message(self, raw_message_line: str, job=None) -> dict: message = ResticBackupper.parse_json_output(raw_message_line) if not isinstance(message, dict): raise ValueError("we have too many messages on one line?") if message["message_type"] == "status": if job is not None: # only update status if we run under some job Jobs.update( job, JobStatus.RUNNING, progress=int(message["percent_done"] * 100), ) return message @staticmethod def _snapshot_from_fresh_summary(message: dict, repo_name) -> Snapshot: return Snapshot( # There is a discrepancy between versions of restic/rclone # Some report short_id in this field and some full id=message["snapshot_id"][0:SHORT_ID_LEN], created_at=datetime.datetime.now(datetime.timezone.utc), service_name=repo_name, ) def init(self) -> None: init_command = self.restic_command( "init", ) with subprocess.Popen( init_command, shell=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) as process_handle: output = process_handle.communicate()[0].decode("utf-8") if "created restic repository" not in output: raise ValueError("cannot init a repo: " + output) def is_initted(self) -> bool: command = self.restic_command( "check", "--json", ) with subprocess.Popen( command, stdout=subprocess.PIPE, shell=False, ) as handle: output = handle.communicate()[0].decode("utf-8") if not ResticBackupper.has_json(output): return False # raise NotImplementedError("error(big): " + output) return True def restored_size(self, snapshot_id: str) -> int: """ Size of a snapshot """ command = self.restic_command( "stats", snapshot_id, "--json", ) with subprocess.Popen( command, stdout=subprocess.PIPE, shell=False, ) as handle: output = handle.communicate()[0].decode("utf-8") try: parsed_output = ResticBackupper.parse_json_output(output) return parsed_output["total_size"] except ValueError as error: raise ValueError("cannot restore a snapshot: " + output) from error def restore_from_backup( self, snapshot_id, folders: List[str], verify=True, ) -> None: """ Restore from backup with restic """ if folders is None or folders == []: raise ValueError("cannot restore without knowing where to!") with tempfile.TemporaryDirectory() as temp_dir: if verify: self._raw_verified_restore(snapshot_id, target=temp_dir) snapshot_root = temp_dir else: # attempting inplace restore via mount + sync self.mount_repo(temp_dir) snapshot_root = join(temp_dir, "ids", snapshot_id) assert snapshot_root is not None for folder in folders: src = join(snapshot_root, folder.strip("/")) if not exists(src): raise ValueError(f"No such path: {src}. We tried to find {folder}") dst = folder sync(src, dst) if not verify: self.unmount_repo(temp_dir) def _raw_verified_restore(self, snapshot_id, target="/"): """barebones restic restore""" restore_command = self.restic_command( "restore", snapshot_id, "--target", target, "--verify" ) with subprocess.Popen( restore_command, stdout=subprocess.PIPE, shell=False ) as handle: # for some reason restore does not support # nice reporting of progress via json output = handle.communicate()[0].decode("utf-8") if "restoring" not in output: raise ValueError("cannot restore a snapshot: " + output) assert ( handle.returncode is not None ) # none should be impossible after communicate if handle.returncode != 0: raise ValueError( "restore exited with errorcode", handle.returncode, ":", output, ) def forget_snapshot(self, snapshot_id) -> None: """ Either removes snapshot or marks it for deletion later, depending on server settings """ forget_command = self.restic_command( "forget", snapshot_id, ) with subprocess.Popen( forget_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False, ) as handle: # for some reason restore does not support # nice reporting of progress via json output, err = [ string.decode( "utf-8", ) for string in handle.communicate() ] if "no matching ID found" in err: raise ValueError( "trying to delete, but no such snapshot: ", snapshot_id ) assert ( handle.returncode is not None ) # none should be impossible after communicate if handle.returncode != 0: raise ValueError( "forget exited with errorcode", handle.returncode, ":", output, ) def _load_snapshots(self) -> object: """ Load list of snapshots from repository raises Value Error if repo does not exist """ listing_command = self.restic_command( "snapshots", "--json", ) with subprocess.Popen( listing_command, shell=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) as backup_listing_process_descriptor: output = backup_listing_process_descriptor.communicate()[0].decode("utf-8") if "Is there a repository at the following location?" in output: raise ValueError("No repository! : " + output) try: return ResticBackupper.parse_json_output(output) except ValueError as error: raise ValueError("Cannot load snapshots: ") from error def get_snapshots(self) -> List[Snapshot]: """Get all snapshots from the repo""" snapshots = [] for restic_snapshot in self._load_snapshots(): snapshot = Snapshot( id=restic_snapshot["short_id"], created_at=restic_snapshot["time"], service_name=restic_snapshot["tags"][0], ) snapshots.append(snapshot) return snapshots @staticmethod def parse_json_output(output: str) -> object: starting_index = ResticBackupper.json_start(output) if starting_index == -1: raise ValueError("There is no json in the restic output: " + output) truncated_output = output[starting_index:] json_messages = truncated_output.splitlines() if len(json_messages) == 1: try: return json.loads(truncated_output) except JSONDecodeError as error: raise ValueError( "There is no json in the restic output : " + output ) from error result_array = [] for message in json_messages: result_array.append(json.loads(message)) return result_array @staticmethod def json_start(output: str) -> int: indices = [ output.find("["), output.find("{"), ] indices = [x for x in indices if x != -1] if indices == []: return -1 return min(indices) @staticmethod def has_json(output: str) -> bool: if ResticBackupper.json_start(output) == -1: return False return True