backups: Allow adding backup repositories on multiple disks

Signed-off-by: Joseph Nuthalapati <njoseph@riseup.net>
[sunil@medhas.org Fix showing form choices, undo blank line removals]
[sunil@medhas.org Fix typo in tooltip for add repo button]
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Joseph Nuthalpati 2019-07-29 16:36:10 +05:30 committed by James Valleroy
parent 5fbc3fc31f
commit 8a93b5b90c
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
8 changed files with 136 additions and 41 deletions

View File

@ -40,6 +40,8 @@ managed_packages = ['borgbackup', 'sshfs']
name = _('Backups')
depends = ['storage']
description = [
_('Backups allows creating and managing backup archives.'),
]
@ -48,8 +50,8 @@ manual_page = 'Backups'
MANIFESTS_FOLDER = '/var/lib/plinth/backups-manifests/'
ROOT_REPOSITORY = '/var/lib/freedombox/borgbackup'
ROOT_REPOSITORY_NAME = format_lazy(
_('{box_name} storage'), box_name=cfg.box_name)
ROOT_REPOSITORY_NAME = format_lazy(_('{box_name} storage'),
box_name=cfg.box_name)
ROOT_REPOSITORY_UUID = 'root'
# session variable name that stores when a backup file should be deleted
SESSION_PATH_VARIABLE = 'fbx-backups-upload-path'
@ -110,9 +112,8 @@ def _backup_handler(packet, encryption_passphrase=None):
arguments = ['create-archive', '--path', packet.path, '--paths'] + paths
input_data = ''
if encryption_passphrase:
input_data = json.dumps({
'encryption_passphrase': encryption_passphrase
})
input_data = json.dumps(
{'encryption_passphrase': encryption_passphrase})
actions.superuser_run('backups', arguments, input=input_data.encode())

View File

@ -29,6 +29,7 @@ from django.core.validators import (FileExtensionValidator,
from django.utils.translation import ugettext
from django.utils.translation import ugettext_lazy as _
from plinth.modules.storage import get_disks
from plinth.utils import format_lazy
from . import ROOT_REPOSITORY_NAME, api, network_storage, split_path
@ -124,18 +125,8 @@ def repository_validator(path):
raise ValidationError(_(f'Invalid directory path: {dir_path}'))
class AddRepositoryForm(forms.Form):
"""Form to add new SSH remote repository."""
repository = forms.CharField(
label=_('SSH Repository Path'), strip=True,
help_text=_('Path of a new or existing repository. Example: '
'<i>user@host:~/path/to/repo/</i>'),
validators=[repository_validator])
ssh_password = forms.CharField(
label=_('SSH server password'), strip=True,
help_text=_('Password of the SSH Server.<br />'
'SSH key-based authentication is not yet possible.'),
widget=forms.PasswordInput(), required=False)
class EncryptedBackupsMixin(forms.Form):
"""Form to add a new backup repository."""
encryption = forms.ChoiceField(
label=_('Encryption'), help_text=format_lazy(
_('"Key in Repository" means that a '
@ -150,7 +141,7 @@ class AddRepositoryForm(forms.Form):
widget=forms.PasswordInput(), required=False)
def clean(self):
super(AddRepositoryForm, self).clean()
super().clean()
passphrase = self.cleaned_data.get('encryption_passphrase')
confirm_passphrase = self.cleaned_data.get(
'confirm_encryption_passphrase')
@ -165,6 +156,44 @@ class AddRepositoryForm(forms.Form):
return self.cleaned_data
encryption_fields = [
'encryption', 'encryption_passphrase', 'confirm_encryption_passphrase'
]
def get_disk_choices():
"""Returns a list of all available partitions except the root partition."""
return [(device['mount_point'],
device['label'] if device['label'] else device['mount_point'])
for device in get_disks() if device['mount_point'] != '/']
class AddRepositoryForm(EncryptedBackupsMixin, forms.Form):
"""Form to create a new backups repository on a disk."""
disk = forms.ChoiceField(
label=_('Select Disk or Partition'), help_text=format_lazy(
_('Backups will be stored in the directory FreedomBoxBackups')),
choices=get_disk_choices)
field_order = ['disk'] + encryption_fields
class AddRemoteRepositoryForm(EncryptedBackupsMixin, forms.Form):
"""Form to add new SSH remote repository."""
repository = forms.CharField(
label=_('SSH Repository Path'), strip=True,
help_text=_('Path of a new or existing repository. Example: '
'<i>user@host:~/path/to/repo/</i>'),
validators=[repository_validator])
ssh_password = forms.CharField(
label=_('SSH server password'), strip=True,
help_text=_('Password of the SSH Server.<br />'
'SSH key-based authentication is not yet possible.'),
widget=forms.PasswordInput(), required=False)
field_order = ['repository', 'ssh_password'] + encryption_fields
def clean_repository(self):
"""Validate repository form field."""
path = self.cleaned_data.get('repository')
@ -202,10 +231,8 @@ class VerifySshHostkeyForm(forms.Form):
stderr=subprocess.DEVNULL)
keys = keyscan.stdout.decode().splitlines()
# Generate user-friendly fingerprints of public keys
keygen = subprocess.run(
['ssh-keygen', '-l', '-f', '-'],
input=keyscan.stdout,
stdout=subprocess.PIPE)
keygen = subprocess.run(['ssh-keygen', '-l', '-f', '-'],
input=keyscan.stdout, stdout=subprocess.PIPE)
fingerprints = keygen.stdout.decode().splitlines()
return zip(keys, fingerprints)

View File

@ -66,13 +66,18 @@
{% include "backups_repository.inc" with editable=True %}
{% endfor %}
<br/>
<a title="{% trans 'Add a backup location' %}"
role="button" class="btn btn-default"
href="{% url 'backups:add-repository' %}">
<span class="fa fa-plus" aria-hidden="true"></span>
{% trans 'Add Backup Location' %}
</a>
<a title="{% trans 'Add a remote backup location' %}"
role="button" class="btn btn-default"
href="{% url 'backups:repository-add' %}">
href="{% url 'backups:add-remote-repository' %}">
<span class="fa fa-plus" aria-hidden="true"></span>
{% trans 'Add Remote Location' %}
{% trans 'Add Remote Backup Location' %}
</a>
{% endblock %}

View File

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% 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 bootstrap %}
{% load i18n %}
{% block content %}
<h3>{{ title }}</h3>
<form class="form" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary"
value="{% trans "Create Repository" %}"/>
<a class="btn btn-default" role="button" href="{% url 'backups:index' %}">
{% trans "Cancel" %}
</a>
</form>
{% endblock %}

View File

@ -99,7 +99,7 @@ def test_add_repository_when_directory_is_missing(temp_user, temp_home,
'encryption': 'none'
}
# TODO test the view instead of the form
form = forms.AddRepositoryForm(data=data)
form = forms.AddRemoteRepositoryForm(data=data)
form.is_valid()
assert os.path.isdir(repo_path) # Directory gets created
@ -115,7 +115,7 @@ def test_add_repository_when_directory_exists_and_empty(
'encryption': 'none'
}
# TODO test the view instead of the form
form = forms.AddRepositoryForm(data=data)
form = forms.AddRemoteRepositoryForm(data=data)
form.is_valid()
@ -131,6 +131,6 @@ def test_add_repository_when_directory_exists_and_not_empty(
'ssh_password': password,
'encryption': 'none'
}
form = forms.AddRepositoryForm(data=data)
form = forms.AddRemoteRepositoryForm(data=data)
with pytest.raises(ValidationError):
form.is_valid()

View File

@ -20,11 +20,11 @@ URLs for the backups module.
from django.conf.urls import url
from .views import (AddRepositoryView, CreateArchiveView, DeleteArchiveView,
DownloadArchiveView, IndexView, RemoveRepositoryView,
RestoreArchiveView, RestoreFromUploadView,
UploadArchiveView, VerifySshHostkeyView, mount_repository,
umount_repository)
from .views import (AddRemoteRepositoryView, AddRepositoryView,
CreateArchiveView, DeleteArchiveView, DownloadArchiveView,
IndexView, RemoveRepositoryView, RestoreArchiveView,
RestoreFromUploadView, UploadArchiveView,
VerifySshHostkeyView, mount_repository, umount_repository)
urlpatterns = [
url(r'^sys/backups/$', IndexView.as_view(), name='index'),
@ -39,7 +39,9 @@ urlpatterns = [
url(r'^sys/backups/restore-from-upload/$', RestoreFromUploadView.as_view(),
name='restore-from-upload'),
url(r'^sys/backups/repositories/add$', AddRepositoryView.as_view(),
name='repository-add'),
name='add-repository'),
url(r'^sys/backups/repositories/add-remote$',
AddRemoteRepositoryView.as_view(), name='add-remote-repository'),
url(r'^sys/backups/repositories/(?P<uuid>[^/]+)/ssh-verify/$',
VerifySshHostkeyView.as_view(), name='verify-ssh-hostkey'),
url(r'^sys/backups/repositories/(?P<uuid>[^/]+)/delete/$',

View File

@ -234,20 +234,40 @@ class DownloadArchiveView(View):
def get(self, request, uuid, name):
repository = get_repository(uuid)
filename = '%s.tar.gz' % name
filename = f'{name}.tar.gz'
response = StreamingHttpResponse(
repository.get_download_stream(name),
content_type='application/gzip')
response = StreamingHttpResponse(repository.get_download_stream(name),
content_type='application/gzip')
response['Content-Disposition'] = 'attachment; filename="%s"' % \
filename
return response
class AddRepositoryView(SuccessMessageMixin, FormView):
"""View to create a new remote backup repository."""
"""View to create a new backup repository."""
form_class = forms.AddRepositoryForm
template_name = 'backups_repository_add.html'
template_name = 'backups_add_repository.html'
success_url = reverse_lazy('backups:index')
def get_context_data(self, **kwargs):
"""Return additional context for rendering the template."""
context = super().get_context_data(**kwargs)
context['title'] = _('Create backup repository')
return context
def form_valid(self, form):
"""Create and save a Borg repository."""
path = pathlib.Path(
form.cleaned_data.get('disk')) / 'FreedomBoxBackups'
encryption_passphrase = form.cleaned_data.get('encryption_passphrase')
if form.cleaned_data.get('encryption') == 'none':
encryption_passphrase = None
class AddRemoteRepositoryView(SuccessMessageMixin, FormView):
"""View to create a new remote backup repository."""
form_class = forms.AddRemoteRepositoryForm
template_name = 'backups_add_remote_repository.html'
def get_context_data(self, **kwargs):
"""Return additional context for rendering the template."""
@ -378,7 +398,7 @@ class VerifySshHostkeyView(SuccessMessageMixin, FormView):
# Delete the repository so that the user can have another go at
# creating it.
network_storage.delete(uuid)
return redirect(reverse_lazy('backups:repository-add'))
return redirect(reverse_lazy('backups:add-remote-repository'))
def _list_remote_directory(path, credentials):