Backups, remote repositories: use object-oriented repositories

Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Michael Pimmer 2018-11-29 19:07:31 +00:00 committed by James Valleroy
parent 27fbc982c7
commit cf6bbd6bba
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
14 changed files with 443 additions and 492 deletions

View File

@ -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():

View File

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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
Manage borg backup locations
"""
class Location(object):
def __init__(self, uuid):
pass

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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))

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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)

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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)

View File

@ -52,17 +52,17 @@
<h3>{% trans 'Existing backups' %}</h3>
{% 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 %}
<br />
<a title="{% trans 'Create new repository' %}"
role="button" class="btn btn-primary"
href="{% url 'backups:location-add' %}">
href="{% url 'backups:repository-add' %}">
{% trans '+ Add Remote Repository' %}
</a>

View File

@ -25,16 +25,16 @@
<tr>
<th>
{{ location.name }}
{{ repository.name }}
{% if editable %}
{% if location.mounted %}
{% if repository.mounted %}
<!-- With GET redirects the browser URL would be pointing to the
redirected page - use POST instead.
-->
<form action="{% url 'backups:location-umount' uuid %}" method="POST"
<form action="{% url 'backups:repository-umount' uuid %}" method="POST"
class="inline-block" >
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-default"
@ -45,7 +45,7 @@
{% else %}
<form action="{% url 'backups:location-mount' uuid %}" method="POST"
<form action="{% url 'backups:repository-mount' uuid %}" method="POST"
class="inline-block" >
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-default"
@ -57,8 +57,8 @@
{% endif %}
<a title="{% trans 'Remove Location. This will not delete the remote backup.' %}"
role="button" class="location-remove btn btn-sm btn-default"
href="{% url 'backups:location-remove' uuid %}">
role="button" class="repository-remove btn btn-sm btn-default"
href="{% url 'backups:repository-remove' uuid %}">
<span class="glyphicon glyphicon-trash" aria-hidden="true">
</a>
@ -71,9 +71,9 @@
</thead>
<tbody>
{% if location.mounted %}
{% if repository.mounted %}
{% for archive in location.archives %}
{% for archive in repository.archives %}
<tr id="archive-{{ archive.name }}" class="archive">
<td class="archive-name">{{ archive.name }}</td>
<td class="archive-operations">
@ -94,7 +94,7 @@
</tr>
{% endfor %}
{% if not location.archives %}
{% if not repository.archives %}
<p>{% trans 'No archives currently exist.' %}</p>
{% endif %}

View File

@ -34,7 +34,7 @@
value="{% trans "Submit" %}"/>
<input type="submit" class="btn btn-secondary" value="Test Connection"
title="{% trans 'Test Connection to Repository' %}"
formaction="{% url 'backups:location-test' %}" />
formaction="{% url 'backups:repository-test' %}" />
</form>
{% endblock %}

View File

@ -27,7 +27,7 @@
<p>
<b>
{% trans "Are you sure that you want to remove the repository" %}
{{ location.path }}?
{{ repository.path }}?
</b>
</p>
{% blocktrans %}
@ -42,7 +42,7 @@
{% csrf_token %}
<input type="submit" class="btn btn-danger"
value="{% blocktrans trimmed with path=location.path %}
value="{% blocktrans trimmed with path=repository.path %}
Remove Repository
{% endblocktrans %}"/>
<a class="abort btn btn-sm btn-default"

View File

@ -20,10 +20,10 @@ URLs for the backups module.
from django.conf.urls import url
from .views import IndexView, CreateArchiveView, AddLocationView, \
DeleteArchiveView, ExportAndDownloadView, RemoveLocationView, \
mount_location, umount_location, UploadArchiveView, \
RestoreArchiveView, RestoreFromUploadView, TestLocationView
from .views import IndexView, CreateArchiveView, AddRepositoryView, \
DeleteArchiveView, ExportAndDownloadView, RemoveRepositoryView, \
mount_repository, umount_repository, UploadArchiveView, \
RestoreArchiveView, RestoreFromUploadView, TestRepositoryView
urlpatterns = [
url(r'^sys/backups/$', IndexView.as_view(), name='index'),
@ -37,14 +37,14 @@ urlpatterns = [
RestoreArchiveView.as_view(), name='restore-archive'),
url(r'^sys/backups/restore-from-upload/$',
RestoreFromUploadView.as_view(), name='restore-from-upload'),
url(r'^sys/backups/locations/add$',
AddLocationView.as_view(), name='location-add'),
url(r'^sys/backups/locations/test/$',
TestLocationView.as_view(), name='location-test'),
url(r'^sys/backups/locations/delete/(?P<uuid>[^/]+)/$',
RemoveLocationView.as_view(), name='location-remove'),
url(r'^sys/backups/locations/mount/(?P<uuid>[^/]+)/$',
mount_location, name='location-mount'),
url(r'^sys/backups/locations/umount/(?P<uuid>[^/]+)/$',
umount_location, name='location-umount'),
url(r'^sys/backups/repositories/add$',
AddRepositoryView.as_view(), name='repository-add'),
url(r'^sys/backups/repositories/test/$',
TestRepositoryView.as_view(), name='repository-test'),
url(r'^sys/backups/repositories/delete/(?P<uuid>[^/]+)/$',
RemoveRepositoryView.as_view(), name='repository-remove'),
url(r'^sys/backups/repositories/mount/(?P<uuid>[^/]+)/$',
mount_repository, name='repository-mount'),
url(r'^sys/backups/repositories/umount/(?P<uuid>[^/]+)/$',
umount_repository, name='repository-umount'),
]

View File

@ -41,7 +41,8 @@ from plinth import actions
from plinth.errors import PlinthError, ActionError
from plinth.modules import backups, storage
from . import api, forms, SESSION_PATH_VARIABLE, REPOSITORY, remote_locations
from . import api, forms, SESSION_PATH_VARIABLE, ROOT_REPOSITORY
from .repository import BorgRepository, SshBorgRepository, get_ssh_repositories
from .decorators import delete_tmp_backup_file
from .errors import BorgRepositoryDoesNotExistError
@ -72,9 +73,9 @@ class IndexView(TemplateView):
context = super().get_context_data(**kwargs)
context['title'] = backups.name
context['description'] = backups.description
context['info'] = backups.get_info(REPOSITORY)
context['root_location'] = backups.get_root_location_content()
context['remote_locations'] = remote_locations.get_locations_content()
root_repository = BorgRepository(ROOT_REPOSITORY)
context['root_repository'] = root_repository.get_view_content()
context['ssh_repositories'] = get_ssh_repositories()
context['subsubmenu'] = subsubmenu
return context
@ -290,13 +291,13 @@ class ExportAndDownloadView(View):
return response
class AddLocationView(SuccessMessageMixin, FormView):
"""View to create a new remote backup location."""
class AddRepositoryView(SuccessMessageMixin, FormView):
"""View to create a new remote backup repository."""
form_class = forms.AddRepositoryForm
prefix = 'backups'
template_name = 'backups_add_location.html'
template_name = 'backups_repository_add.html'
success_url = reverse_lazy('backups:index')
success_message = _('Added new location.')
success_message = _('Added new repository.')
def get_context_data(self, **kwargs):
"""Return additional context for rendering the template."""
@ -307,47 +308,42 @@ class AddLocationView(SuccessMessageMixin, FormView):
def form_valid(self, form):
"""Restore files from the archive on valid form submission."""
repository = form.cleaned_data['repository']
encryption = form.cleaned_data['encryption']
encryption_passphrase = form.cleaned_data['encryption_passphrase']
ssh_password = form.cleaned_data['ssh_password']
store_passwords = form.cleaned_data['store_passwords']
# TODO: add ssh_keyfile
# ssh_keyfile = form.cleaned_data['ssh_keyfile']
path = form.cleaned_data['repository']
credentials = {}
if form.cleaned_data['store_passwords']:
encryption_passphrase = form.cleaned_data['encryption_passphrase']
if encryption_passphrase:
credentials['encryption_passphrase'] = encryption_passphrase
if form.cleaned_data['ssh_password']:
credentials['ssh_password'] = form.cleaned_data['ssh_password']
# TODO: add ssh_keyfile
# ssh_keyfile = form.cleaned_data['ssh_keyfile']
repository = SshBorgRepository(path=path, credentials=credentials)
access_params = {}
if encryption_passphrase:
access_params['encryption_passphrase'] = encryption_passphrase
if ssh_password:
access_params['ssh_password'] = ssh_password
"""
if ssh_keyfile:
access_params['ssh_keyfile'] = ssh_keyfile
"""
remote_locations.add(repository, 'ssh', encryption, access_params,
store_passwords, 'backups')
# Create the borg repository if it doesn't exist
try:
backups.test_connection(repository, access_params)
repository.get_info()
except BorgRepositoryDoesNotExistError:
backups.create_repository(repository, encryption,
access_params=access_params)
repository.create_repository(form.cleaned_data['encryption'])
repository.save()
return super().form_valid(form)
class TestLocationView(TemplateView):
class TestRepositoryView(TemplateView):
"""View to create a new repository."""
template_name = 'backups_test_location.html'
template_name = 'backups_repository_test.html'
def post(self, request):
# TODO: add support for borg encryption and ssh keyfile
context = self.get_context_data()
repository = request.POST['backups-repository']
access_params = {
credentials = {
'ssh_password': request.POST['backups-ssh_password'],
}
repository = SshBorgRepository(path=request.POST['backups-repository'],
credentials=credentials)
try:
repo_info = backups.test_connection(repository, access_params)
repo_info = repository.get_info()
context["message"] = repo_info
except ActionError as err:
context["error"] = str(err)
@ -355,39 +351,42 @@ class TestLocationView(TemplateView):
return self.render_to_response(context)
class RemoveLocationView(SuccessMessageMixin, TemplateView):
class RemoveRepositoryView(SuccessMessageMixin, TemplateView):
"""View to delete an archive."""
template_name = 'backups_remove_repository.html'
template_name = 'backups_repository_remove.html'
def get_context_data(self, uuid, **kwargs):
"""Return additional context for rendering the template."""
context = super().get_context_data(**kwargs)
context['title'] = _('Remove Repository')
context['location'] = remote_locations.get_location(uuid)
context['repository'] = SshBorgRepository(uuid=uuid)
return context
def post(self, request, uuid):
"""Delete the archive."""
remote_locations.delete(uuid)
repository = SshBorgRepository(uuid)
repository.remove()
messages.success(request, _('Repository removed. The remote backup '
'itself was not deleted.'))
return redirect('backups:index')
def umount_location(request, uuid):
remote_locations.umount_uuid(uuid)
if remote_locations.uuid_is_mounted(uuid):
def umount_repository(request, uuid):
repository = SshBorgRepository(uuid=uuid)
repository.umount()
if repository.is_mounted:
messages.error(request, _('Unmounting failed!'))
return redirect('backups:index')
def mount_location(request, uuid):
def mount_repository(request, uuid):
repository = SshBorgRepository(uuid=uuid)
try:
remote_locations.mount_uuid(uuid)
repository.mount()
except Exception as err:
msg = "%s: %s" % (_('Mounting failed'), str(err))
messages.error(request, msg)
else:
if not remote_locations.uuid_is_mounted(uuid):
if not repository.is_mounted:
messages.error(request, _('Mounting failed'))
return redirect('backups:index')