mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-05-20 10:34:30 +00:00
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 <jvalleroy@mailbox.org>
This commit is contained in:
parent
3a8b69fc82
commit
e3817a1a31
@ -50,9 +50,12 @@ SESSION_PATH_VARIABLE = 'fbx-backups-upload-path'
|
|||||||
# known errors that come up when remotely accessing a borg repository
|
# known errors that come up when remotely accessing a borg repository
|
||||||
# 'errors' are error strings to look for in the stacktrace.
|
# 'errors' are error strings to look for in the stacktrace.
|
||||||
ACCESS_PARAMS = ['ssh_keyfile', 'ssh_password', 'encryption_passphrase']
|
ACCESS_PARAMS = ['ssh_keyfile', 'ssh_password', 'encryption_passphrase']
|
||||||
|
# kvstore key for storing remote locations
|
||||||
|
REMOTE_LOCATIONS_KEY = 'remote_locations'
|
||||||
KNOWN_ERRORS = [{
|
KNOWN_ERRORS = [{
|
||||||
"errors": ["subprocess.TimeoutExpired"],
|
"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,
|
"raise_as": BorgError,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -62,7 +65,7 @@ KNOWN_ERRORS = [{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"errors": ["not a valid repository", "does not exist"],
|
"errors": ["not a valid repository", "does not exist"],
|
||||||
"message": _("Connection works - Repository does not exist"),
|
"message": _("Repository not found"),
|
||||||
"raise_as": BorgRepositoryDoesNotExistError,
|
"raise_as": BorgRepositoryDoesNotExistError,
|
||||||
}]
|
}]
|
||||||
|
|
||||||
@ -86,11 +89,16 @@ def get_info(repository, access_params=None):
|
|||||||
|
|
||||||
|
|
||||||
def list_archives(repository, access_params=None):
|
def list_archives(repository, access_params=None):
|
||||||
output = run(['list-repo', '--path', repository], access_params)
|
try:
|
||||||
return json.loads(output)['archives']
|
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):
|
def get_archive(name):
|
||||||
|
# TODO: can't we get this archive directly?
|
||||||
for archive in list_archives():
|
for archive in list_archives():
|
||||||
if archive['name'] == name:
|
if archive['name'] == name:
|
||||||
return archive
|
return archive
|
||||||
@ -98,6 +106,17 @@ def get_archive(name):
|
|||||||
return None
|
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):
|
def test_connection(repository, access_params=None):
|
||||||
"""
|
"""
|
||||||
Test connecting to a local or remote borg repository.
|
Test connecting to a local or remote borg repository.
|
||||||
@ -107,13 +126,7 @@ def test_connection(repository, access_params=None):
|
|||||||
try:
|
try:
|
||||||
return get_info(repository, access_params)
|
return get_info(repository, access_params)
|
||||||
except ActionError as err:
|
except ActionError as err:
|
||||||
caught_error = str(err)
|
reraise_known_error(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
|
|
||||||
|
|
||||||
|
|
||||||
def _backup_handler(packet):
|
def _backup_handler(packet):
|
||||||
@ -137,6 +150,7 @@ def _backup_handler(packet):
|
|||||||
paths.append(manifest_path)
|
paths.append(manifest_path)
|
||||||
actions.superuser_run(
|
actions.superuser_run(
|
||||||
'backups', ['create-archive', '--name', packet.label, '--paths'] +
|
'backups', ['create-archive', '--name', packet.label, '--paths'] +
|
||||||
|
# TODO: add ssh_keyfile
|
||||||
paths)
|
paths)
|
||||||
|
|
||||||
|
|
||||||
@ -177,8 +191,9 @@ def _restore_exported_archive_handler(packet):
|
|||||||
"""Perform restore operation on packet."""
|
"""Perform restore operation on packet."""
|
||||||
locations = {'directories': packet.directories, 'files': packet.files}
|
locations = {'directories': packet.directories, 'files': packet.files}
|
||||||
locations_data = json.dumps(locations)
|
locations_data = json.dumps(locations)
|
||||||
actions.superuser_run('backups', ['restore-exported-archive',
|
actions.superuser_run('backups', ['restore-exported-archive', '--path',
|
||||||
'--path', packet.label], input=locations_data.encode())
|
packet.label],
|
||||||
|
input=locations_data.encode())
|
||||||
|
|
||||||
|
|
||||||
def _restore_archive_handler(packet):
|
def _restore_archive_handler(packet):
|
||||||
@ -186,7 +201,8 @@ def _restore_archive_handler(packet):
|
|||||||
locations = {'directories': packet.directories, 'files': packet.files}
|
locations = {'directories': packet.directories, 'files': packet.files}
|
||||||
locations_data = json.dumps(locations)
|
locations_data = json.dumps(locations)
|
||||||
actions.superuser_run('backups', ['restore-archive', '--path',
|
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):
|
def restore_from_upload(path, apps=None):
|
||||||
|
|||||||
@ -84,7 +84,7 @@ class UploadForm(forms.Form):
|
|||||||
help_text=_('Select the backup file you want to upload'))
|
help_text=_('Select the backup file you want to upload'))
|
||||||
|
|
||||||
|
|
||||||
class CreateRepositoryForm(forms.Form):
|
class AddRepositoryForm(forms.Form):
|
||||||
repository = forms.CharField(
|
repository = forms.CharField(
|
||||||
label=_('SSH Repository Path'), strip=True,
|
label=_('SSH Repository Path'), strip=True,
|
||||||
help_text=_('Path of the new repository. Example: '
|
help_text=_('Path of the new repository. Example: '
|
||||||
@ -125,7 +125,7 @@ class CreateRepositoryForm(forms.Form):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super(CreateRepositoryForm, self).clean()
|
cleaned_data = super(AddRepositoryForm, self).clean()
|
||||||
passphrase = cleaned_data.get("encryption_passphrase")
|
passphrase = cleaned_data.get("encryption_passphrase")
|
||||||
confirm_passphrase = cleaned_data.get("confirm_encryption_passphrase")
|
confirm_passphrase = cleaned_data.get("confirm_encryption_passphrase")
|
||||||
|
|
||||||
|
|||||||
180
plinth/modules/backups/remote_locations.py
Normal file
180
plinth/modules/backups/remote_locations.py
Normal file
@ -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 <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_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)
|
||||||
@ -28,6 +28,17 @@
|
|||||||
.share-operations {
|
.share-operations {
|
||||||
text-align: right;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -48,7 +59,7 @@
|
|||||||
id="archives-list">
|
id="archives-list">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{% trans "Name" %}</th>
|
<th>{{ box_name }} storage</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -78,4 +89,16 @@
|
|||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% for uuid,location in remote_archives.items %}
|
||||||
|
{% include "backups_location.inc" %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<a title="{% trans 'Create new repository' %}"
|
||||||
|
role="button" class="btn btn-primary"
|
||||||
|
href="{% url 'backups:location-add' %}">
|
||||||
|
{% trans '+ Add Remote Repository' %}
|
||||||
|
</a>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -34,7 +34,7 @@
|
|||||||
value="{% trans "Submit" %}"/>
|
value="{% trans "Submit" %}"/>
|
||||||
<input type="submit" class="btn btn-secondary" value="Test Connection"
|
<input type="submit" class="btn btn-secondary" value="Test Connection"
|
||||||
title="{% trans 'Test Connection to Repository' %}"
|
title="{% trans 'Test Connection to Repository' %}"
|
||||||
formaction="{% url 'backups:test-repository' %}" />
|
formaction="{% url 'backups:location-test' %}" />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
83
plinth/modules/backups/templates/backups_location.inc
Normal file
83
plinth/modules/backups/templates/backups_location.inc
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<table class="table table-bordered table-condensed table-striped"
|
||||||
|
id="archives-list">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
{{ location.path }}
|
||||||
|
{% if location.mounted %}
|
||||||
|
<form action="{% url 'backups:location-umount' uuid %}" method="POST"
|
||||||
|
class="inline-block" >
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-default"
|
||||||
|
title="{% trans 'Unount Location' %}">
|
||||||
|
<span class="glyphicon glyphicon-eject" aria-hidden="true">
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<!-- With GET redirects the browser URL would be pointing to the
|
||||||
|
redirected page - use POST instead.
|
||||||
|
-->
|
||||||
|
<form action="{% url 'backups:location-mount' uuid %}" method="POST"
|
||||||
|
class="inline-block" >
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-default"
|
||||||
|
title="{% trans 'Mount Location' %}">
|
||||||
|
<span class="glyphicon glyphicon-eye-open" aria-hidden="true">
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% 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 %}">
|
||||||
|
<span class="glyphicon glyphicon-trash" aria-hidden="true">
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{% for archive in location.archives %}
|
||||||
|
<tr id="archive-{{ archive.name }}" class="archive">
|
||||||
|
<td class="archive-name">{{ archive.name }}</td>
|
||||||
|
<td class="archive-operations">
|
||||||
|
<a class="archive-export btn btn-sm btn-default" target="_blank"
|
||||||
|
href="{% url 'backups:export-and-download' archive.name %}">
|
||||||
|
{% trans "Download" %}
|
||||||
|
</a>
|
||||||
|
<a class="archive-export btn btn-sm btn-default"
|
||||||
|
href="{% url 'backups:restore-archive' archive.name %}">
|
||||||
|
{% trans "Restore" %}
|
||||||
|
</a>
|
||||||
|
<a class="archive-delete btn btn-sm btn-default"
|
||||||
|
href="{% url 'backups:delete' archive.name %}">
|
||||||
|
<span class="glyphicon glyphicon-trash" aria-hidden="true">
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@ -18,22 +18,39 @@
|
|||||||
#
|
#
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% load bootstrap %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<h2>{{ title }}</h2>
|
||||||
<h3>{% trans 'Backup repositories' %}</h3>
|
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{% for repository in repositories %}
|
<b>
|
||||||
Repository: {{ repository.repository }}<br />
|
{% trans "Are you sure that you want to remove the repository" %}
|
||||||
{% endfor %}
|
{{ location.path }}?
|
||||||
|
</b>
|
||||||
|
</p>
|
||||||
|
{% 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 %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a title="{% trans 'Create new repository' %}"
|
<p>
|
||||||
role="button" class="btn btn-primary"
|
<form class="form" method="post">
|
||||||
href="{% url 'backups:create-repository' %}">
|
{% csrf_token %}
|
||||||
{% trans 'Add Repository' %}
|
|
||||||
</a>
|
<input type="submit" class="btn btn-danger"
|
||||||
|
value="{% blocktrans trimmed with path=location.path %}
|
||||||
|
Remove Repository
|
||||||
|
{% endblocktrans %}"/>
|
||||||
|
<a class="abort btn btn-sm btn-default"
|
||||||
|
href="{% url 'backups:index' %}">
|
||||||
|
{% trans "Abort" %}
|
||||||
|
</a>
|
||||||
|
</form>
|
||||||
|
</p>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -20,10 +20,10 @@ URLs for the backups module.
|
|||||||
|
|
||||||
from django.conf.urls import url
|
from django.conf.urls import url
|
||||||
|
|
||||||
from .views import IndexView, CreateArchiveView, CreateRepositoryView, \
|
from .views import IndexView, CreateArchiveView, AddLocationView, \
|
||||||
DeleteArchiveView, UploadArchiveView, ExportAndDownloadView, \
|
DeleteArchiveView, ExportAndDownloadView, RemoveLocationView, \
|
||||||
RepositoriesView, RestoreArchiveView, RestoreFromUploadView, \
|
mount_location, umount_location, UploadArchiveView, \
|
||||||
TestRepositoryView
|
RestoreArchiveView, RestoreFromUploadView, TestLocationView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^sys/backups/$', IndexView.as_view(), name='index'),
|
url(r'^sys/backups/$', IndexView.as_view(), name='index'),
|
||||||
@ -37,10 +37,14 @@ urlpatterns = [
|
|||||||
RestoreArchiveView.as_view(), name='restore-archive'),
|
RestoreArchiveView.as_view(), name='restore-archive'),
|
||||||
url(r'^sys/backups/restore-from-upload/$',
|
url(r'^sys/backups/restore-from-upload/$',
|
||||||
RestoreFromUploadView.as_view(), name='restore-from-upload'),
|
RestoreFromUploadView.as_view(), name='restore-from-upload'),
|
||||||
url(r'^sys/backups/repositories/$',
|
url(r'^sys/backups/locations/add$',
|
||||||
RepositoriesView.as_view(), name='repositories'),
|
AddLocationView.as_view(), name='location-add'),
|
||||||
url(r'^sys/backups/repositories/create/$',
|
url(r'^sys/backups/locations/test/$',
|
||||||
CreateRepositoryView.as_view(), name='create-repository'),
|
TestLocationView.as_view(), name='location-test'),
|
||||||
url(r'^sys/backups/repositories/test/$',
|
url(r'^sys/backups/locations/delete/(?P<uuid>[^/]+)/$',
|
||||||
TestRepositoryView.as_view(), name='test-repository'),
|
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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -20,13 +20,11 @@ Views for the backups app.
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import gzip
|
import gzip
|
||||||
import json
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from uuid import uuid1
|
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
@ -39,11 +37,11 @@ from django.utils.translation import ugettext as _
|
|||||||
from django.utils.translation import ugettext_lazy
|
from django.utils.translation import ugettext_lazy
|
||||||
from django.views.generic import View, FormView, TemplateView
|
from django.views.generic import View, FormView, TemplateView
|
||||||
|
|
||||||
from plinth import actions, kvstore
|
from plinth import actions
|
||||||
from plinth.errors import PlinthError
|
from plinth.errors import PlinthError, ActionError
|
||||||
from plinth.modules import backups, storage
|
from plinth.modules import backups, storage
|
||||||
|
|
||||||
from . import api, forms, SESSION_PATH_VARIABLE, REPOSITORY
|
from . import api, forms, SESSION_PATH_VARIABLE, REPOSITORY, remote_locations
|
||||||
from .decorators import delete_tmp_backup_file
|
from .decorators import delete_tmp_backup_file
|
||||||
from .errors import BorgRepositoryDoesNotExistError
|
from .errors import BorgRepositoryDoesNotExistError
|
||||||
|
|
||||||
@ -58,9 +56,6 @@ subsubmenu = [{
|
|||||||
}, {
|
}, {
|
||||||
'url': reverse_lazy('backups:create'),
|
'url': reverse_lazy('backups:create'),
|
||||||
'text': ugettext_lazy('Create')
|
'text': ugettext_lazy('Create')
|
||||||
}, {
|
|
||||||
'url': reverse_lazy('backups:repositories'),
|
|
||||||
'text': ugettext_lazy('Repositories')
|
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
@ -69,6 +64,9 @@ class IndexView(TemplateView):
|
|||||||
"""View to show list of archives."""
|
"""View to show list of archives."""
|
||||||
template_name = 'backups.html'
|
template_name = 'backups.html'
|
||||||
|
|
||||||
|
def get_remote_archives(self):
|
||||||
|
return {} # uuid --> archive list
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
"""Return additional context for rendering the template."""
|
"""Return additional context for rendering the template."""
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
@ -76,9 +74,8 @@ class IndexView(TemplateView):
|
|||||||
context['description'] = backups.description
|
context['description'] = backups.description
|
||||||
context['info'] = backups.get_info(REPOSITORY)
|
context['info'] = backups.get_info(REPOSITORY)
|
||||||
context['archives'] = backups.list_archives(REPOSITORY)
|
context['archives'] = backups.list_archives(REPOSITORY)
|
||||||
|
context['remote_archives'] = remote_locations.get_archives()
|
||||||
context['subsubmenu'] = subsubmenu
|
context['subsubmenu'] = subsubmenu
|
||||||
apps = api.get_all_apps_for_backup()
|
|
||||||
context['available_apps'] = [app.name for app in apps]
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@ -129,7 +126,7 @@ class DeleteArchiveView(SuccessMessageMixin, TemplateView):
|
|||||||
"""Delete the archive."""
|
"""Delete the archive."""
|
||||||
backups.delete_archive(name)
|
backups.delete_archive(name)
|
||||||
messages.success(request, _('Archive deleted.'))
|
messages.success(request, _('Archive deleted.'))
|
||||||
return redirect(reverse_lazy('backups:index'))
|
return redirect('backups:index')
|
||||||
|
|
||||||
|
|
||||||
def _get_file_response(path, filename):
|
def _get_file_response(path, filename):
|
||||||
@ -292,29 +289,13 @@ class ExportAndDownloadView(View):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
class RepositoriesView(TemplateView):
|
class AddLocationView(SuccessMessageMixin, FormView):
|
||||||
"""View list of repositories."""
|
"""View to create a new remote backup location."""
|
||||||
template_name = 'backups_repositories.html'
|
form_class = forms.AddRepositoryForm
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
"""Return additional context for rendering the template."""
|
|
||||||
context = super().get_context_data(**kwargs)
|
|
||||||
context['title'] = 'Backup repositories'
|
|
||||||
# TODO: rename backups_repositories to something more generic,
|
|
||||||
# that can be used/managed by the storage module too
|
|
||||||
repositories = kvstore.get_default('backups_repositories', [])
|
|
||||||
context['repositories'] = json.loads(repositories)
|
|
||||||
context['subsubmenu'] = subsubmenu
|
|
||||||
return context
|
|
||||||
|
|
||||||
|
|
||||||
class CreateRepositoryView(SuccessMessageMixin, FormView):
|
|
||||||
"""View to create a new repository."""
|
|
||||||
form_class = forms.CreateRepositoryForm
|
|
||||||
prefix = 'backups'
|
prefix = 'backups'
|
||||||
template_name = 'backups_create_repository.html'
|
template_name = 'backups_add_location.html'
|
||||||
success_url = reverse_lazy('backups:repositories')
|
success_url = reverse_lazy('backups:index')
|
||||||
success_message = _('Created new repository.')
|
success_message = _('Added new location.')
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
"""Return additional context for rendering the template."""
|
"""Return additional context for rendering the template."""
|
||||||
@ -329,48 +310,83 @@ class CreateRepositoryView(SuccessMessageMixin, FormView):
|
|||||||
encryption = form.cleaned_data['encryption']
|
encryption = form.cleaned_data['encryption']
|
||||||
encryption_passphrase = form.cleaned_data['encryption_passphrase']
|
encryption_passphrase = form.cleaned_data['encryption_passphrase']
|
||||||
ssh_password = form.cleaned_data['ssh_password']
|
ssh_password = form.cleaned_data['ssh_password']
|
||||||
ssh_keyfile = form.cleaned_data['ssh_keyfile']
|
store_passwords = form.cleaned_data['store_passwords']
|
||||||
|
# TODO: add ssh_keyfile
|
||||||
|
# ssh_keyfile = form.cleaned_data['ssh_keyfile']
|
||||||
|
|
||||||
# TODO: create borg repository if it doesn't exist
|
access_params = {}
|
||||||
import pdb; pdb.set_trace()
|
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:
|
try:
|
||||||
backups.test_connection(repository, ssh_password)
|
backups.test_connection(repository, access_params)
|
||||||
except BorgRepositoryDoesNotExistError:
|
except BorgRepositoryDoesNotExistError:
|
||||||
access_params = {}
|
|
||||||
if encryption_passphrase:
|
|
||||||
access_params['encryption_passphrase'] = encryption_passphrase
|
|
||||||
if ssh_keyfile:
|
|
||||||
access_params['ssh_keyfile'] = ssh_keyfile
|
|
||||||
backups.create_repository(repository, encryption,
|
backups.create_repository(repository, encryption,
|
||||||
access_params=access_params)
|
access_params=access_params)
|
||||||
|
|
||||||
repositories = kvstore.get_default('backups_repositories', [])
|
|
||||||
if repositories:
|
|
||||||
repositories = json.loads(repositories)
|
|
||||||
new_repo = {
|
|
||||||
'uuid': str(uuid1()),
|
|
||||||
'repository': repository,
|
|
||||||
'encryption': encryption,
|
|
||||||
}
|
|
||||||
if form.cleaned_data['store_passwords']:
|
|
||||||
new_repo['encryption_passphrase'] = \
|
|
||||||
form.cleaned_data['encryption_passphrase']
|
|
||||||
repositories.append(new_repo)
|
|
||||||
kvstore.set('backups_repositories', json.dumps(repositories))
|
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
class TestRepositoryView(TemplateView):
|
class TestLocationView(TemplateView):
|
||||||
"""View to create a new repository."""
|
"""View to create a new repository."""
|
||||||
template_name = 'backups_test_repository.html'
|
template_name = 'backups_test_location.html'
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
# TODO: add support for borg encryption
|
# TODO: add support for borg encryption and ssh keyfile
|
||||||
context = self.get_context_data()
|
context = self.get_context_data()
|
||||||
repository = request.POST['backups-repository']
|
repository = request.POST['backups-repository']
|
||||||
ssh_password = request.POST['backups-ssh_password']
|
access_params = {
|
||||||
(error, message) = backups.test_connection(repository, ssh_password)
|
'ssh_password': request.POST['backups-ssh_password'],
|
||||||
context["message"] = message
|
}
|
||||||
context["error"] = error
|
try:
|
||||||
|
repo_info = backups.test_connection(repository, access_params)
|
||||||
|
context["message"] = repo_info
|
||||||
|
except ActionError as err:
|
||||||
|
context["error"] = str(err)
|
||||||
|
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
|
||||||
|
class RemoveLocationView(SuccessMessageMixin, TemplateView):
|
||||||
|
"""View to delete an archive."""
|
||||||
|
template_name = 'backups_remove_repository.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)
|
||||||
|
return context
|
||||||
|
|
||||||
|
def post(self, request, uuid):
|
||||||
|
"""Delete the archive."""
|
||||||
|
remote_locations.delete(uuid)
|
||||||
|
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):
|
||||||
|
messages.error(request, _('Unmounting failed!'))
|
||||||
|
return redirect('backups:index')
|
||||||
|
|
||||||
|
|
||||||
|
def mount_location(request, uuid):
|
||||||
|
try:
|
||||||
|
remote_locations.mount_uuid(uuid)
|
||||||
|
except Exception as err:
|
||||||
|
msg = "%s: %s" % (_('Mounting failed'), str(err))
|
||||||
|
messages.error(request, msg)
|
||||||
|
else:
|
||||||
|
if not remote_locations.uuid_is_mounted(uuid):
|
||||||
|
messages.error(request, _('Mounting failed'))
|
||||||
|
return redirect('backups:index')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user