From e3817a1a316a626cedcfc371bd32b6884e20e9dd Mon Sep 17 00:00:00 2001
From: Michael Pimmer
Date: Wed, 28 Nov 2018 17:01:29 +0000
Subject: [PATCH] Backups, remote repositories: integrate to backups index page
- integrate remote repositories into backups index page
- remove, mount and unmount repositories via UI
Reviewed-by: James Valleroy
---
plinth/modules/backups/__init__.py | 44 +++--
plinth/modules/backups/forms.py | 4 +-
plinth/modules/backups/remote_locations.py | 180 ++++++++++++++++++
plinth/modules/backups/templates/backups.html | 25 ++-
...ository.html => backups_add_location.html} | 2 +-
.../backups/templates/backups_location.inc | 83 ++++++++
...es.html => backups_remove_repository.html} | 37 +++-
plinth/modules/backups/urls.py | 24 ++-
plinth/modules/backups/views.py | 142 ++++++++------
9 files changed, 440 insertions(+), 101 deletions(-)
create mode 100644 plinth/modules/backups/remote_locations.py
rename plinth/modules/backups/templates/{backups_create_repository.html => backups_add_location.html} (94%)
create mode 100644 plinth/modules/backups/templates/backups_location.inc
rename plinth/modules/backups/templates/{backups_repositories.html => backups_remove_repository.html} (50%)
diff --git a/plinth/modules/backups/__init__.py b/plinth/modules/backups/__init__.py
index 0a5fa3ba0..1fae39a16 100644
--- a/plinth/modules/backups/__init__.py
+++ b/plinth/modules/backups/__init__.py
@@ -50,9 +50,12 @@ 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": _("Server not reachable - try providing a password."),
+ "message": _("Connection refused - make sure you provided correct "
+ "credentials and the server is running."),
"raise_as": BorgError,
},
{
@@ -62,7 +65,7 @@ KNOWN_ERRORS = [{
},
{
"errors": ["not a valid repository", "does not exist"],
- "message": _("Connection works - Repository does not exist"),
+ "message": _("Repository not found"),
"raise_as": BorgRepositoryDoesNotExistError,
}]
@@ -86,11 +89,16 @@ def get_info(repository, access_params=None):
def list_archives(repository, access_params=None):
- output = run(['list-repo', '--path', repository], access_params)
- return json.loads(output)['archives']
+ 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_archive(name):
+ # TODO: can't we get this archive directly?
for archive in list_archives():
if archive['name'] == name:
return archive
@@ -98,6 +106,17 @@ def get_archive(name):
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.
@@ -107,13 +126,7 @@ def test_connection(repository, access_params=None):
try:
return get_info(repository, access_params)
except ActionError as err:
- 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
+ reraise_known_error(err)
def _backup_handler(packet):
@@ -137,6 +150,7 @@ def _backup_handler(packet):
paths.append(manifest_path)
actions.superuser_run(
'backups', ['create-archive', '--name', packet.label, '--paths'] +
+ # TODO: add ssh_keyfile
paths)
@@ -177,8 +191,9 @@ def _restore_exported_archive_handler(packet):
"""Perform restore operation on packet."""
locations = {'directories': packet.directories, 'files': packet.files}
locations_data = json.dumps(locations)
- actions.superuser_run('backups', ['restore-exported-archive',
- '--path', packet.label], input=locations_data.encode())
+ actions.superuser_run('backups', ['restore-exported-archive', '--path',
+ packet.label],
+ input=locations_data.encode())
def _restore_archive_handler(packet):
@@ -186,7 +201,8 @@ def _restore_archive_handler(packet):
locations = {'directories': packet.directories, 'files': packet.files}
locations_data = json.dumps(locations)
actions.superuser_run('backups', ['restore-archive', '--path',
- packet.label, '--destination', '/'], input=locations_data.encode())
+ packet.label, '--destination', '/'],
+ input=locations_data.encode())
def restore_from_upload(path, apps=None):
diff --git a/plinth/modules/backups/forms.py b/plinth/modules/backups/forms.py
index 0da605950..5506dce29 100644
--- a/plinth/modules/backups/forms.py
+++ b/plinth/modules/backups/forms.py
@@ -84,7 +84,7 @@ class UploadForm(forms.Form):
help_text=_('Select the backup file you want to upload'))
-class CreateRepositoryForm(forms.Form):
+class AddRepositoryForm(forms.Form):
repository = forms.CharField(
label=_('SSH Repository Path'), strip=True,
help_text=_('Path of the new repository. Example: '
@@ -125,7 +125,7 @@ class CreateRepositoryForm(forms.Form):
)
def clean(self):
- cleaned_data = super(CreateRepositoryForm, self).clean()
+ cleaned_data = super(AddRepositoryForm, self).clean()
passphrase = cleaned_data.get("encryption_passphrase")
confirm_passphrase = cleaned_data.get("confirm_encryption_passphrase")
diff --git a/plinth/modules/backups/remote_locations.py b/plinth/modules/backups/remote_locations.py
new file mode 100644
index 000000000..c4c26ec4a
--- /dev/null
+++ b/plinth/modules/backups/remote_locations.py
@@ -0,0 +1,180 @@
+#
+# 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_archives(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 = {
+ 'path': 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/templates/backups.html b/plinth/modules/backups/templates/backups.html
index 7aa10d2da..6379a3584 100644
--- a/plinth/modules/backups/templates/backups.html
+++ b/plinth/modules/backups/templates/backups.html
@@ -28,6 +28,17 @@
.share-operations {
text-align: right;
}
+ .mount-error {
+ padding: 0px 10px 0px 10px;
+ color: orange;
+ }
+ .mount-success {
+ padding: 0px 10px 0px 10px;
+ color: black;
+ }
+ .inline-block {
+ display: inline-block;
+ }
{% endblock %}
@@ -48,7 +59,7 @@
id="archives-list">
-
{% trans "Name" %}
+
{{ box_name }} storage
@@ -78,4 +89,16 @@
{% endif %}
+ {% for uuid,location in remote_archives.items %}
+ {% include "backups_location.inc" %}
+ {% endfor %}
+
+
+
+
+ {% trans '+ Add Remote Repository' %}
+
+
{% endblock %}
diff --git a/plinth/modules/backups/templates/backups_create_repository.html b/plinth/modules/backups/templates/backups_add_location.html
similarity index 94%
rename from plinth/modules/backups/templates/backups_create_repository.html
rename to plinth/modules/backups/templates/backups_add_location.html
index 0b4be62eb..d125b1cc7 100644
--- a/plinth/modules/backups/templates/backups_create_repository.html
+++ b/plinth/modules/backups/templates/backups_add_location.html
@@ -34,7 +34,7 @@
value="{% trans "Submit" %}"/>
+ formaction="{% url 'backups:location-test' %}" />
{% endblock %}
diff --git a/plinth/modules/backups/templates/backups_location.inc b/plinth/modules/backups/templates/backups_location.inc
new file mode 100644
index 000000000..08153c98b
--- /dev/null
+++ b/plinth/modules/backups/templates/backups_location.inc
@@ -0,0 +1,83 @@
+{% comment %}
+#
+# 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 .
+#
+{% endcomment %}
+
+{% load i18n %}
+
+
- {% for repository in repositories %}
- Repository: {{ repository.repository }}
- {% endfor %}
+
+ {% trans "Are you sure that you want to remove the repository" %}
+ {{ location.path }}?
+
+
+ {% blocktrans %}
+ The remote repository will not be deleted.
+ This just removes the repository from the listing on the backup page, you
+ can add it again later on.
+ {% endblocktrans %}