From cf6bbd6bba26d4eb679cc25dd5384d2a5905a9d7 Mon Sep 17 00:00:00 2001 From: Michael Pimmer Date: Thu, 29 Nov 2018 19:07:31 +0000 Subject: [PATCH] Backups, remote repositories: use object-oriented repositories Reviewed-by: James Valleroy --- actions/backups | 63 ++--- plinth/modules/backups/__init__.py | 147 +--------- plinth/modules/backups/location.py | 24 -- plinth/modules/backups/network_storage.py | 68 +++++ plinth/modules/backups/remote_locations.py | 180 ------------ plinth/modules/backups/repository.py | 266 ++++++++++++++++++ plinth/modules/backups/sshfs.py | 38 --- plinth/modules/backups/templates/backups.html | 8 +- ...ps_location.inc => backups_repository.inc} | 18 +- ...ation.html => backups_repository_add.html} | 2 +- ...ry.html => backups_repository_remove.html} | 4 +- ...tory.html => backups_repository_test.html} | 0 plinth/modules/backups/urls.py | 28 +- plinth/modules/backups/views.py | 89 +++--- 14 files changed, 443 insertions(+), 492 deletions(-) delete mode 100644 plinth/modules/backups/location.py create mode 100644 plinth/modules/backups/network_storage.py delete mode 100644 plinth/modules/backups/remote_locations.py create mode 100644 plinth/modules/backups/repository.py delete mode 100644 plinth/modules/backups/sshfs.py rename plinth/modules/backups/templates/{backups_location.inc => backups_repository.inc} (85%) rename plinth/modules/backups/templates/{backups_add_location.html => backups_repository_add.html} (94%) rename plinth/modules/backups/templates/{backups_remove_repository.html => backups_repository_remove.html} (93%) rename plinth/modules/backups/templates/{backups_test_repository.html => backups_repository_test.html} (100%) diff --git a/actions/backups b/actions/backups index 36d164cc6..bb6a39e9e 100755 --- a/actions/backups +++ b/actions/backups @@ -27,7 +27,7 @@ import subprocess import sys import tarfile -from plinth.modules.backups import MANIFESTS_FOLDER, REPOSITORY +from plinth.modules.backups import MANIFESTS_FOLDER TIMEOUT = 5 @@ -41,28 +41,14 @@ def parse_arguments(): parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') - subparsers.add_parser( + setup = subparsers.add_parser( 'setup', help='Create repository if it does not already exist') - info = subparsers.add_parser('info', help='Show repository information') - info.add_argument('--repository', help='Repository path', required=True) - init = subparsers.add_parser('init', help='Initialize a repository') - init.add_argument('--repository', help='Repository path', required=True) init.add_argument('--encryption', help='Enryption of the repository', required=True) - # TODO: ssh commands should be in SSH or a separate action script - mount = subparsers.add_parser('mount', help='mount an ssh filesystem') - mount.add_argument('--mountpoint', help='Local mountpoint', required=True) - umount = subparsers.add_parser('umount', - help='unmount an ssh filesystem') - umount.add_argument('--mountpoint', help='Mountpoint to unmount', - required=True) - is_mounted = subparsers.add_parser('is-mounted', - help='Check whether an sshfs is mouned') - is_mounted.add_argument('--mountpoint', help='Mountpoint to check', - required=True) + info = subparsers.add_parser('info', help='Show repository information') list_repo = subparsers.add_parser('list-repo', help='List repository contents') @@ -77,16 +63,25 @@ def parse_arguments(): export_help = 'Export archive contents as tar on stdout' export_tar = subparsers.add_parser('export-tar', help=export_help) - export_tar.add_argument('--name', help='Archive name)', - required=True) get_archive_apps = subparsers.add_parser( 'get-archive-apps', help='Get list of apps included in archive') - # TODO: add parameters to missing commands (export etc) + # SSH mount actions + mount = subparsers.add_parser('mount', help='mount an ssh filesystem') + mount.add_argument('--mountpoint', help='Local mountpoint', required=True) + umount = subparsers.add_parser('umount', + help='unmount an ssh filesystem') + umount.add_argument('--mountpoint', help='Mountpoint to unmount', + required=True) + is_mounted = subparsers.add_parser('is-mounted', + help='Check whether an sshfs is mouned') + is_mounted.add_argument('--mountpoint', help='Mountpoint to check', + required=True) + for cmd in [info, init, list_repo, create_archive, delete_archive, - get_archive_apps, mount]: + export_tar, get_archive_apps, mount, setup]: cmd.add_argument('--path', help='Repository or Archive path', required=False) # TODO: set required to True! cmd.add_argument('--ssh-keyfile', help='Path of private ssh key', @@ -117,26 +112,26 @@ def parse_arguments(): return parser.parse_args() -def subcommand_setup(_): +def subcommand_setup(arguments): """Create repository if it does not already exist.""" - # TODO: use init() + env = get_env(arguments) try: - subprocess.run(['borg', 'info', REPOSITORY], check=True) + run(['borg', 'info', arguments.path], check=True, env=env) except: - path = os.path.dirname(REPOSITORY) + path = os.path.dirname(arguments.path) if not os.path.exists(path): os.makedirs(path) - run(['borg', 'init', '--encryption', 'none', REPOSITORY]) + init(arguments.path, 'none', env=env) -def init(repository, encryption, env=None): +def init(path, encryption, env=None): """Initialize a local or remote borg repository""" # TODO: verify that the repository does not exist? # TODO: does remote borg also create folders if they don't exist? if encryption != 'none' and 'BORG_PASSPHRASE' not in env: raise ValueError('No encryption passphrase provided') - cmd = ['borg', 'init', '--encryption', encryption, repository] + cmd = ['borg', 'init', '--encryption', encryption, path] run(cmd, env=env) @@ -171,13 +166,13 @@ def get_sshfs_env(arguments): def subcommand_init(arguments): env = get_env(arguments) - init(arguments.repository, arguments.encryption, env=env) + init(arguments.path, arguments.encryption, env=env) def subcommand_info(arguments): """Show repository information.""" env = get_env(arguments) - run(['borg', 'info', '--json', arguments.repository], env=env) + run(['borg', 'info', '--json', arguments.path], env=env) def subcommand_mount(arguments): @@ -286,7 +281,9 @@ def _extract(archive_path, destination, locations=None): def subcommand_export_tar(arguments): """Export archive contents as tar stream on stdout.""" - run(['borg', 'export-tar', REPOSITORY + '::' + arguments.name, '-']) + env = get_env(arguments) + run(['borg', 'export-tar', arguments.path, '-'], + env=env) def _read_archive_file(archive, filepath): @@ -385,14 +382,14 @@ def read_password(): return ''.join(sys.stdin) -def run(cmd, env=None): +def run(cmd, env=None, check=True): """Wrap the command with ssh password or keyfile authentication""" # If the remote server asks for a password but no password is # provided, we get stuck at asking the password. timeout = None if env and 'BORG_RSH' in env and 'SSHPASS' not in env: timeout = TIMEOUT - subprocess.run(cmd, check=True, env=env, timeout=timeout) + subprocess.run(cmd, check=check, env=env, timeout=timeout) def main(): diff --git a/plinth/modules/backups/__init__.py b/plinth/modules/backups/__init__.py index d4c710a1c..da0cb887c 100644 --- a/plinth/modules/backups/__init__.py +++ b/plinth/modules/backups/__init__.py @@ -25,10 +25,8 @@ from django.utils.text import get_valid_filename from django.utils.translation import ugettext_lazy as _ from plinth import actions, cfg -from plinth.errors import ActionError from plinth.menu import main_menu from plinth.utils import format_lazy -from .errors import BorgError, BorgRepositoryDoesNotExistError from . import api @@ -45,30 +43,11 @@ description = [ service = None MANIFESTS_FOLDER = '/var/lib/plinth/backups-manifests/' -REPOSITORY = '/var/lib/freedombox/borgbackup' +ROOT_REPOSITORY = '/var/lib/freedombox/borgbackup' +ROOT_REPOSITORY_NAME = format_lazy(_('{box_name} storage'), + box_name=cfg.box_name) # session variable name that stores when a backup file should be deleted SESSION_PATH_VARIABLE = 'fbx-backups-upload-path' -# known errors that come up when remotely accessing a borg repository -# 'errors' are error strings to look for in the stacktrace. -ACCESS_PARAMS = ['ssh_keyfile', 'ssh_password', 'encryption_passphrase'] -# kvstore key for storing remote locations -REMOTE_LOCATIONS_KEY = 'remote_locations' -KNOWN_ERRORS = [{ - "errors": ["subprocess.TimeoutExpired"], - "message": _("Connection refused - make sure you provided correct " - "credentials and the server is running."), - "raise_as": BorgError, - }, - { - "errors": ["Connection refused"], - "message": _("Connection refused"), - "raise_as": BorgError, - }, - { - "errors": ["not a valid repository", "does not exist"], - "message": _("Repository not found"), - "raise_as": BorgRepositoryDoesNotExistError, - }] def init(): @@ -80,67 +59,8 @@ def init(): def setup(helper, old_version=None): """Install and configure the module.""" helper.install(managed_packages) - helper.call('post', actions.superuser_run, 'backups', ['setup']) - - -def get_info(repository, access_params=None): - cmd = ['info', '--repository', repository] - output = run(cmd, access_params) - return json.loads(output) - - -def list_archives(repository, access_params=None): - try: - output = run(['list-repo', '--path', repository], access_params) - except ActionError as err: - reraise_known_error(err) - else: - return json.loads(output)['archives'] - - -def get_root_location_content(): - """ - Get information about the root backup location in the same format - that the remote repositories use - """ - return { - 'name': format_lazy(_('{box_name} storage'), box_name=cfg.box_name), - 'mounted': True, - 'archives': list_archives(REPOSITORY), - 'type': 'rootfs', - } - - -def get_archive(name): - # TODO: can't we get this archive directly? - for archive in list_archives(): - if archive['name'] == name: - return archive - - return None - - -def reraise_known_error(err): - """Look whether the caught error is known and re-raise it accordingly""" - caught_error = str(err) - for known_error in KNOWN_ERRORS: - for error in known_error["errors"]: - if error in caught_error: - raise known_error["raise_as"](known_error["message"]) - else: - raise err - - -def test_connection(repository, access_params=None): - """ - Test connecting to a local or remote borg repository. - Tries to detect (and throw) some known ssh or borg errors. - Returns 'borg info' information otherwise. - """ - try: - return get_info(repository, access_params) - except ActionError as err: - reraise_known_error(err) + helper.call('post', actions.superuser_run, 'backups', ['setup', '--path', + ROOT_REPOSITORY]) def _backup_handler(packet): @@ -168,32 +88,6 @@ def _backup_handler(packet): paths) -def create_archive(name, app_names, access_params=None): - api.backup_apps(_backup_handler, app_names, name, - access_params=access_params) - - -def create_repository(repository, encryption, access_params=None): - cmd = ['init', '--repository', repository, '--encryption', encryption] - run(cmd, access_params=access_params) - - -def delete_archive(path): - actions.superuser_run('backups', ['delete-archive', '--path', path]) - - -def get_archive_path(archive_name): - """Get path of an archive""" - return "::".join([REPOSITORY, archive_name]) - - -def get_archive_apps(path): - """Get list of apps included in an archive.""" - output = actions.superuser_run('backups', - ['get-archive-apps', '--path', path]) - return output.splitlines() - - def get_exported_archive_apps(path): """Get list of apps included in exported archive file.""" arguments = ['get-exported-archive-apps', '--path', path] @@ -229,34 +123,3 @@ def restore(archive_path, apps=None): """Restore files from a backup archive.""" api.restore_apps(_restore_archive_handler, app_names=apps, create_subvolume=False, backup_file=archive_path) - - -def get_args(params): - args = [] - kwargs = {} - if 'ssh_password' in params and params['ssh_password'] is not None: - kwargs['input'] = params['ssh_password'].encode() - if 'ssh_keyfile' in params and params['ssh_keyfile'] is not None: - args += ['--ssh-keyfile', params['ssh_keyfile']] - if 'encryption_passphrase' in params and \ - params['encryption_passphrase'] is not None: - args += ['--encryption-passphrase', params['encryption_passphrase']] - return (args, kwargs) - - -def run(arguments, access_params=None, superuser=True): - """Run a given command, appending (kw)args as necessary""" - command = 'backups' - kwargs = {} - if access_params: - for key in access_params.keys(): - if key not in ACCESS_PARAMS: - raise ValueError('Unknown access parameter: %s' % key) - - append_arguments, kwargs = get_args(access_params) - arguments += append_arguments - - if superuser: - return actions.superuser_run(command, arguments, **kwargs) - else: - return actions.run(command, arguments, **kwargs) diff --git a/plinth/modules/backups/location.py b/plinth/modules/backups/location.py deleted file mode 100644 index 6c637c607..000000000 --- a/plinth/modules/backups/location.py +++ /dev/null @@ -1,24 +0,0 @@ -# -# This file is part of FreedomBox. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -# -""" -Manage borg backup locations -""" - - -class Location(object): - def __init__(self, uuid): - pass diff --git a/plinth/modules/backups/network_storage.py b/plinth/modules/backups/network_storage.py new file mode 100644 index 000000000..174433775 --- /dev/null +++ b/plinth/modules/backups/network_storage.py @@ -0,0 +1,68 @@ +# +# This file is part of FreedomBox. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +""" +Manage remote (network) storage storages in plinths' KVStore. +""" + +import json +from uuid import uuid1 + +from plinth import kvstore + +# kvstore key for network storage +NETWORK_STORAGE_KEY = 'network_storage' +REQUIRED_FIELDS = ['path', 'storage_type', 'added_by_module'] + + +def get_storages(storage_type=None): + """Get list of network storage storages""" + storages = kvstore.get_default(NETWORK_STORAGE_KEY, []) + if storages: + storages = json.loads(storages) + if storage_type: + storages = [storage for storage in storages if 'type' in storage + and storage['type'] == storage_type] + return storages + + +def get(uuid): + storages = get_storages() + return list(filter(lambda storage: storage['uuid'] == uuid, + storages))[0] + + +def update_or_add(storage): + """Update an existing or create a new network location""" + for field in REQUIRED_FIELDS: + assert field in storage + storages = get_storages() + if 'uuid' in storage: + # Replace the existing storage + storages = [_storage if _storage['uuid'] != storage['uuid'] else + storage for _storage in storages] + else: + storage['uuid'] = str(uuid1()) + storages.append(storage) + kvstore.set(NETWORK_STORAGE_KEY, json.dumps(storages)) + + +def delete(uuid): + """Remove a network storage from kvstore""" + storages = get_storages() + storages = list(filter(lambda storage: storage['uuid'] != uuid, + storages)) + kvstore.set(NETWORK_STORAGE_KEY, json.dumps(storages)) diff --git a/plinth/modules/backups/remote_locations.py b/plinth/modules/backups/remote_locations.py deleted file mode 100644 index 8261c41d6..000000000 --- a/plinth/modules/backups/remote_locations.py +++ /dev/null @@ -1,180 +0,0 @@ -# -# This file is part of FreedomBox. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -# -""" -Manage remote storage locations -""" - -import json -import logging -import os -from uuid import uuid1 - -from django.utils.translation import ugettext as _ - -from plinth import kvstore -from plinth.errors import ActionError - -from . import sshfs, list_archives, reraise_known_error, REMOTE_LOCATIONS_KEY -from .errors import BorgError - -logger = logging.getLogger(__name__) -MOUNTPOINT = '/media/' - - -def add(path, repotype, encryption, access_params, store_passwords, added_by): - locations = get_locations() - location = { - 'uuid': str(uuid1()), - 'path': path, - 'type': repotype, - 'encryption': encryption, - 'added_by': added_by - } - if store_passwords: - if 'encryption_passphrase' in access_params: - location['encryption_passphrase'] = \ - access_params['encryption_passphrase'] - if 'ssh_password' in access_params: - location['ssh_password'] = access_params['ssh_password'] - locations.append(location) - kvstore.set(REMOTE_LOCATIONS_KEY, json.dumps(locations)) - - -def delete(uuid): - """Umount a location, remove it from kvstore and unlink the mountpoint""" - locations = get_locations() - location = get_location(uuid) - mountpoint = os.path.join(MOUNTPOINT, location['uuid']) - locations = list(filter(lambda location: location['uuid'] != uuid, - locations)) - kvstore.set(REMOTE_LOCATIONS_KEY, json.dumps(locations)) - if os.path.exists(mountpoint): - try: - sshfs.umount(mountpoint) - except ActionError: - pass - try: - os.unlink(mountpoint) - except Exception as err: - logger.error(err) - - -def get_locations_content(uuid=None): - """ - Get archives of one or all locations. - returns: { - uuid: { - 'path': path, - 'type': type, - 'archives': [], - 'error': error_message, - } - } - """ - locations = {} - for location in get_locations(): - mountpoint = os.path.join(MOUNTPOINT, location['uuid']) - new_location = { - 'name': location['path'], - 'mounted': uuid_is_mounted(location['uuid']), - } - if new_location['mounted']: - try: - new_location['archives'] = list_archives(mountpoint) - except BorgError as err: - new_location['error'] = str(err) - except Exception as err: - logger.error(err) - new_location['error'] = _("Access failed") - locations[location['uuid']] = new_location - - return locations - - -def get_locations(location_type=None): - """Get list of all locations""" - # TODO: hold locations in memory? - locations = kvstore.get_default(REMOTE_LOCATIONS_KEY, []) - if locations: - locations = json.loads(locations) - if location_type: - locations = [location for location in locations if 'type' in location - and location['type'] == location_type] - return locations - - -def get_location(uuid): - locations = get_locations() - return list(filter(lambda location: location['uuid'] == uuid, - locations))[0] - - -def _mount_locations(uuid=None): - locations = get_locations(location_type='ssh') - for location in locations: - _mount_location(location) - - -def _mount_location(location): - # TODO: shouldn't I just store and query the access_params as they are? - # but encryption_passphrase is not an ssh access_param.. - mountpoint = os.path.join(MOUNTPOINT, location['uuid']) - is_mounted = False - if sshfs.is_mounted(mountpoint): - is_mounted = True - else: - access_params = _get_access_params(location) - # TODO: use actual feedback of sshfs.mount - try: - sshfs.mount(location['path'], mountpoint, access_params) - except Exception as err: - reraise_known_error(err) - is_mounted = True - return is_mounted - - -def _umount_location(location): - mountpoint = os.path.join(MOUNTPOINT, location['uuid']) - return sshfs.umount(mountpoint) - - -def _get_access_params(location): - keys = ['encryption_passphrase', 'ssh_keyfile', 'ssh_password'] - access_params = {key: location[key] for key in keys if key in location} - if location['type'] == 'ssh': - if 'ssh_keyfile' not in location and 'ssh_password' not in \ - location: - raise ValueError('Missing credentials') - return access_params - - -def mount_uuid(uuid): - location = get_location(uuid) - mounted = False - if location: - mounted = _mount_location(location) - return mounted - - -def umount_uuid(uuid): - location = get_location(uuid) - return _umount_location(location) - - -def uuid_is_mounted(uuid): - mountpoint = os.path.join(MOUNTPOINT, uuid) - return sshfs.is_mounted(mountpoint) diff --git a/plinth/modules/backups/repository.py b/plinth/modules/backups/repository.py new file mode 100644 index 000000000..db0a8df27 --- /dev/null +++ b/plinth/modules/backups/repository.py @@ -0,0 +1,266 @@ +# +# This file is part of FreedomBox. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +""" +Remote and local Borg backup repositories +""" + +import json +import os +import logging + +from django.utils.translation import ugettext_lazy as _ + +from plinth import actions +from plinth.errors import ActionError + +from . import api, network_storage, _backup_handler, ROOT_REPOSITORY_NAME +from .errors import BorgError, BorgRepositoryDoesNotExistError + +logger = logging.getLogger(__name__) + +SSHFS_MOUNTPOINT = '/media/' +# known errors that come up when remotely accessing a borg repository +# 'errors' are error strings to look for in the stacktrace. +KNOWN_ERRORS = [{ + "errors": ["subprocess.TimeoutExpired"], + "message": _("Connection refused - make sure you provided correct " + "credentials and the server is running."), + "raise_as": BorgError, + }, + { + "errors": ["Connection refused"], + "message": _("Connection refused"), + "raise_as": BorgError, + }, + { + "errors": ["not a valid repository", "does not exist"], + "message": _("Repository not found"), + "raise_as": BorgRepositoryDoesNotExistError, + }] + + +class BorgRepository(object): + """Borg repository on the root filesystem""" + command = 'backups' + storage_type = 'root' + name = ROOT_REPOSITORY_NAME + is_mounted = True + + def __init__(self, path): + self.path = path + + def get_info(self): + output = self._run(['info', '--path', self.path]) + return json.loads(output) + + def list_archives(self): + output = self._run(['list-repo', '--path', self.path]) + return json.loads(output)['archives'] + + def get_view_content(self): + """Get archives with additional information as needed by the view""" + return { + 'name': self.name, + 'mounted': self.is_mounted, + 'archives': self.list_archives(), + 'type': self.storage_type, + } + + def delete_archive(self, archive_name): + archive_path = self.get_archive_path(archive_name) + self._run(['delete-archive', '--path', archive_path]) + + def remove(self): + """Remove a borg repository""" + raise NotImplementedError + + def create_archive(self, app_names, archive_name): + api.backup_apps(_backup_handler, app_names=app_names, + label=archive_name), + + def download_archive(self, name): + pass + + def get_archive(self, name): + # TODO: can't we get this archive directly? + for archive in self.list_archives(): + if archive['name'] == name: + return archive + + return None + + def get_archive_apps(self, archive_name): + """Get list of apps included in an archive.""" + archive_path = self.get_archive_path(archive_name) + output = self._run(['get-archive-apps', '--path', archive_path]) + return output.splitlines() + + def restore_archive(self): + pass + + def get_archive_path(self, archive_name): + return "::".join(self.path, archive_name) + + def _run(self, arguments, superuser=True): + """Run a backups action script command.""" + try: + if superuser: + return actions.superuser_run(self.command, arguments) + else: + return actions.run(self.command, arguments) + except ActionError as err: + self.reraise_known_error(err) + + def reraise_known_error(self, err): + """Look whether the caught error is known and reraise it accordingly""" + caught_error = str(err) + for known_error in KNOWN_ERRORS: + for error in known_error["errors"]: + if error in caught_error: + raise known_error["raise_as"](known_error["message"]) + else: + raise err + + +class SshBorgRepository(BorgRepository): + KNOWN_CREDENTIALS = ['ssh_keyfile', 'ssh_password', + 'encryption_passphrase'] + storage_type = 'ssh' + + def __init__(self, uuid=None, path=None, credentials=None, **kwargs): + """ + Provide a uuid to instanciate an existing repository, + or 'path' and 'credentials' for a new repository. + """ + if uuid: + self.uuid = uuid + # If all data are given, instanciate right away. + if path and credentials: + self.path = path + self.credentials = credentials + else: + self._load_from_kvstore() + # No uuid given: new instance. + elif path and credentials: + self.path = path + self.credentials = credentials + else: + raise ValueError('Invalid arguments.') + + @property + def mountpoint(self): + return os.path.join(SSHFS_MOUNTPOINT, self.uuid) + + @property + def name(self): + return self.path + + @property + def is_mounted(self): + output = self._run(['is-mounted', '--mountpoint', self.mountpoint]) + return json.loads(output) + + def _load_from_kvstore(self): + storage = network_storage.get(self.uuid) + self.credentials = storage['credentials'] + self.path = storage['path'] + + def _get_network_storage_format(self): + storage = { + 'path': self.path, + 'credentials': self.credentials, + 'storage_type': self.storage_type, + 'added_by_module': 'backups' + } + if hasattr(self, 'uuid'): + storage['uuid'] = self.uuid + return storage + + def create_archive(self, app_names, archive_name): + api.backup_apps(_backup_handler, app_names=app_names, + label=archive_name, credentials=self.credentials) + + def create_repository(self, encryption): + cmd = ['init', '--path', self.path, '--encryption', encryption] + self._run(cmd) + + def save(self): + storage = self._get_network_storage_format() + self.uuid = network_storage.update_or_add(storage) + + def mount(self): + cmd = ['mount', '--mountpoint', self.mountpoint, '--path', self.path] + self._run(cmd) + + def umount(self): + self._run(['umount', '--mountpoint', self.mountpoint], + use_credentials=False) + + def remove(self): + """Remove a repository from the kvstore and delete its mountpoint""" + network_storage.delete(self.uuid) + try: + if os.path.exists(self.mountpoint): + try: + self.umount() + except ActionError: + pass + if not os.listdir(self.mountpoint): + os.rmdir(self.mountpoint) + except Exception as err: + logger.error(err) + + def _run(self, arguments, superuser=True, use_credentials=True): + """Run a backups action script command. + + This automatically passes on self.credentials to the backups script + via environment variables or input, except if you set use_credentials + to False. + """ + for key in self.credentials.keys(): + if key not in self.KNOWN_CREDENTIALS: + raise ValueError('Unknown credentials: %s' % key) + + kwargs = {} + if use_credentials: + if 'ssh_password' in self.credentials and \ + self.credentials['ssh_password'] is not None: + kwargs['input'] = self.credentials['ssh_password'].encode() + if 'ssh_keyfile' in self.credentials and \ + self.credentials['ssh_keyfile'] is not None: + arguments += ['--ssh-keyfile', self.credentials['ssh_keyfile']] + if 'encryption_passphrase' in self.credentials and \ + self.credentials['encryption_passphrase'] is not None: + arguments += ['--encryption-passphrase', + self.credentials['encryption_passphrase']] + + try: + if superuser: + return actions.superuser_run(self.command, arguments, **kwargs) + else: + return actions.run(self.command, arguments, **kwargs) + except ActionError as err: + self.reraise_known_error(err) + + +def get_ssh_repositories(): + """Get all SSH Repositories including the archive content""" + repositories = {} + for storage in network_storage.get_storages(): + repository = SshBorgRepository(**storage) + repositories[storage['uuid']] = repository.get_view_content() + return repositories diff --git a/plinth/modules/backups/sshfs.py b/plinth/modules/backups/sshfs.py deleted file mode 100644 index d3aa32b24..000000000 --- a/plinth/modules/backups/sshfs.py +++ /dev/null @@ -1,38 +0,0 @@ -# -# This file is part of FreedomBox. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -# - -""" -Utilities to work with sshfs. -""" - -import json - -from plinth.modules.backups import run - - -def mount(remote_path, mountpoint, access_params): - run(['mount', '--mountpoint', mountpoint, '--path', remote_path], - access_params) - - -def umount(mountpoint): - run(['umount', '--mountpoint', mountpoint]) - - -def is_mounted(mountpoint): - output = run(['is-mounted', '--mountpoint', mountpoint]) - return json.loads(output) diff --git a/plinth/modules/backups/templates/backups.html b/plinth/modules/backups/templates/backups.html index 58387a92b..a103ae4bc 100644 --- a/plinth/modules/backups/templates/backups.html +++ b/plinth/modules/backups/templates/backups.html @@ -52,17 +52,17 @@

{% trans 'Existing backups' %}

- {% include "backups_location.inc" with location=root_location uuid='root' editable=False %} + {% include "backups_repository.inc" with repository=root_repository uuid='root' editable=False %} - {% for uuid,location in remote_locations.items %} - {% include "backups_location.inc" with editable=True %} + {% for uuid,repository in ssh_repositories.items %} + {% include "backups_repository.inc" with editable=True %} {% endfor %}
+ href="{% url 'backups:repository-add' %}"> {% trans '+ Add Remote Repository' %} diff --git a/plinth/modules/backups/templates/backups_location.inc b/plinth/modules/backups/templates/backups_repository.inc similarity index 85% rename from plinth/modules/backups/templates/backups_location.inc rename to plinth/modules/backups/templates/backups_repository.inc index 0e4f191ea..e0fb7f43c 100644 --- a/plinth/modules/backups/templates/backups_location.inc +++ b/plinth/modules/backups/templates/backups_repository.inc @@ -25,16 +25,16 @@ - {{ location.name }} + {{ repository.name }} {% if editable %} - {% if location.mounted %} + {% if repository.mounted %} -
{% csrf_token %}