Backups: clean up forms, names and templates

Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Michael Pimmer 2018-10-12 00:36:42 +00:00 committed by James Valleroy
parent c8554bef9d
commit b92b66b7e9
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
10 changed files with 99 additions and 198 deletions

View File

@ -56,15 +56,14 @@ def parse_arguments():
export_tar = subparsers.add_parser('export-tar', export_tar = subparsers.add_parser('export-tar',
help='Export archive contents as tarball') help='Export archive contents as tarball')
export_tar.add_argument('--archive', help='Archive name', export_tar.add_argument('--name', help='Archive name)', required=True)
required=True)
export_tar.add_argument('--filepath', help='Destination tarball file path', export_tar.add_argument('--filepath', help='Destination tarball file path',
required=True) required=True)
get_apps_of_exported_archive = subparsers.add_parser( get_exported_archive_apps = subparsers.add_parser(
'get-apps-of-exported-archive', 'get-exported-archive-apps',
help='Get list of apps included in exported archive file') help='Get list of apps included in exported archive file')
get_apps_of_exported_archive.add_argument( get_exported_archive_apps.add_argument(
'--path', help='Tarball file path', required=True) '--path', help='Tarball file path', required=True)
get_archive_apps = subparsers.add_parser( get_archive_apps = subparsers.add_parser(
@ -166,7 +165,7 @@ def subcommand_export_tar(arguments):
os.makedirs(directory) os.makedirs(directory)
subprocess.run([ subprocess.run([
'borg', 'export-tar', REPOSITORY + '::' + arguments.archive, 'borg', 'export-tar', REPOSITORY + '::' + arguments.name,
arguments.filepath arguments.filepath
], check=True) ], check=True)
@ -202,8 +201,8 @@ def subcommand_get_archive_apps(arguments):
print(app['name']) print(app['name'])
def subcommand_get_apps_of_exported_archive(arguments): def subcommand_get_exported_archive_apps(arguments):
"""Get list of apps included in exported archive file.""" """Get list of apps included in an exported archive file."""
manifest = None manifest = None
with tarfile.open(arguments.path) as t: with tarfile.open(arguments.path) as t:
filenames = t.getnames() filenames = t.getnames()
@ -225,8 +224,7 @@ def subcommand_restore_archive(arguments):
_locations = json.loads(locations_data) _locations = json.loads(locations_data)
locations = _locations['directories'] + _locations['files'] locations = _locations['directories'] + _locations['files']
locations = [os.path.relpath(location, '/') for location in locations] locations = [os.path.relpath(location, '/') for location in locations]
_extract(arguments.path, arguments.destination, _extract(arguments.path, arguments.destination, locations=locations)
locations=locations)
def subcommand_restore_exported_archive(arguments): def subcommand_restore_exported_archive(arguments):

View File

@ -43,10 +43,10 @@ service = None
MANIFESTS_FOLDER = '/var/lib/plinth/backups-manifests/' MANIFESTS_FOLDER = '/var/lib/plinth/backups-manifests/'
REPOSITORY = '/var/lib/freedombox/borgbackup' REPOSITORY = '/var/lib/freedombox/borgbackup'
SESSION_BACKUP_VARIABLE = 'fbx-backup-filestamp'
# default backup path for temporary actions like imports or download
TMP_BACKUP_PATH = '/tmp/freedombox-backup.tar.gz'
# session variable name that stores when a backup file should be deleted # session variable name that stores when a backup file should be deleted
SESSION_BACKUP_VARIABLE = 'fbx-backup-filestamp'
# default backup path for temporary backup files during down- or upload
UPLOAD_BACKUP_PATH = '/tmp/freedombox-backup.tar.gz'
def init(): def init():
@ -105,17 +105,20 @@ def create_archive(name, app_names):
def delete_archive(name): def delete_archive(name):
# TODO: is name actually a path?
actions.superuser_run('backups', ['delete', '--name', name]) actions.superuser_run('backups', ['delete', '--name', name])
def delete_tmp_backup_file(): def delete_upload_backup_file():
if os.path.isfile(TMP_BACKUP_PATH): if os.path.isfile(UPLOAD_BACKUP_PATH):
os.remove(TMP_BACKUP_PATH) os.remove(UPLOAD_BACKUP_PATH)
def export_archive(name, filepath=TMP_BACKUP_PATH): def export_archive(name, filepath=UPLOAD_BACKUP_PATH):
arguments = ['export-tar', '--archive', name, '--filepath', filepath] """Export an archive as .tar.gz file
name: name of the repository (w/o path)
"""
arguments = ['export-tar', '--name', name, '--filepath', filepath]
actions.superuser_run('backups', arguments) actions.superuser_run('backups', arguments)
@ -131,9 +134,9 @@ def get_archive_apps(path):
return output.splitlines() return output.splitlines()
def get_apps_of_exported_archive(path): def get_exported_archive_apps(path):
"""Get list of apps included in exported archive file.""" """Get list of apps included in exported archive file."""
arguments = ['get-apps-of-exported-archive', '--path', path] arguments = ['get-exported-archive-apps', '--path', path]
output = actions.superuser_run('backups', arguments) output = actions.superuser_run('backups', arguments)
return output.splitlines() return output.splitlines()
@ -154,10 +157,10 @@ def _restore_archive_handler(packet):
packet.label, '--destination', '/'], input=locations_data.encode()) packet.label, '--destination', '/'], input=locations_data.encode())
def restore_from_tmp(apps=None): def restore_from_upload(apps=None):
"""Restore files from temporary backup file""" """Restore files from (uploaded) eported backup file"""
api.restore_apps(_restore_exported_archive_handler, app_names=apps, api.restore_apps(_restore_exported_archive_handler, app_names=apps,
create_subvolume=False, backup_file=TMP_BACKUP_PATH) create_subvolume=False, backup_file=UPLOAD_BACKUP_PATH)
def restore(archive_path, apps=None): def restore(archive_path, apps=None):

View File

@ -110,13 +110,15 @@ class Packet:
All paths populated are relative to the 'root' path. The root path All paths populated are relative to the 'root' path. The root path
itself must not be stored in the backup. itself must not be stored in the backup.
label is either an archive name (w/o path), or the full path of an
exported archive.
TODO: create two variables out of it as it's distinct information.
""" """
self.operation = operation self.operation = operation
self.scope = scope self.scope = scope
# TODO: do we need root if we have the path?
self.root = root self.root = root
self.apps = apps self.apps = apps
# TODO: label is an archive path -- rename
self.label = label self.label = label
self.errors = [] self.errors = []

View File

@ -61,8 +61,7 @@ class CreateArchiveForm(forms.Form):
class RestoreForm(forms.Form): class RestoreForm(forms.Form):
selected_apps = forms.MultipleChoiceField( selected_apps = forms.MultipleChoiceField(
label=_('Restore apps'), label=_('Select the apps you want to restore'),
help_text=_('Apps data to restore from the backup'),
widget=forms.CheckboxSelectMultiple) widget=forms.CheckboxSelectMultiple)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -73,20 +72,7 @@ class RestoreForm(forms.Form):
self.fields['selected_apps'].initial = [app.name for app in apps] self.fields['selected_apps'].initial = [app.name for app in apps]
class RestoreFromTmpForm(forms.Form): class UploadForm(forms.Form):
selected_apps = forms.MultipleChoiceField(
label=_('Restore apps'),
widget=forms.CheckboxSelectMultiple)
def __init__(self, *args, **kwargs):
"""Initialize the form with selectable apps."""
apps = kwargs.pop('apps')
super().__init__(*args, **kwargs)
self.fields['selected_apps'].choices = _get_app_choices(apps)
self.fields['selected_apps'].initial = [app.name for app in apps]
class UploadToTmpForm(forms.Form):
file = forms.FileField(label=_('Upload File'), required=True, file = forms.FileField(label=_('Upload File'), required=True,
validators=[FileExtensionValidator(['gz'], validators=[FileExtensionValidator(['gz'],
'Backup files have to be in .tar.gz format')], 'Backup files have to be in .tar.gz format')],

View File

@ -16,8 +16,7 @@
# #
""" """
Django middleware to redirect to firstboot wizard if it has not be run Django middleware to occasionally delete temporary backup files
yet.
""" """
import logging import logging
@ -41,8 +40,8 @@ class BackupsMiddleware(MiddlewareMixin):
if request.session.has_key(backups.SESSION_BACKUP_VARIABLE): if request.session.has_key(backups.SESSION_BACKUP_VARIABLE):
now = time.time() now = time.time()
if now > request.session[backups.SESSION_BACKUP_VARIABLE]: if now > request.session[backups.SESSION_BACKUP_VARIABLE]:
backups.delete_tmp_backup_file() backups.delete_upload_backup_file()
del request.session[backups.SESSION_BACKUP_VARIABLE] del request.session[backups.SESSION_BACKUP_VARIABLE]
else: else:
backups.delete_tmp_backup_file() backups.delete_upload_backup_file()
return return

View File

@ -24,24 +24,11 @@
{% block content %} {% block content %}
<h2>{{ title }}</h2> <h2>{{ title }}</h2>
<p>{% trans "Restore data from this archive?" %}</p> {% if name %}
<p>
<div class="row"> {% trans 'Restore data from' %} {{ name }}
<div class="col-lg-12"> </p>
<table class="table table-bordered table-condensed table-striped"> {% endif %}
<thead>
<th>{% trans "Location" %}</th>
<th>{% trans "Name" %}</th>
</thead>
<tbody>
<tr>
<td>{{ label }}</td>
<td>{{ name }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<p> <p>
<form class="form" method="post"> <form class="form" method="post">
@ -49,10 +36,15 @@
{{ form|bootstrap }} {{ form|bootstrap }}
<input type="submit" class="btn btn-danger" <input type="submit" class="btn btn-primary"
value="{% blocktrans trimmed %} value="{% blocktrans trimmed %}
Restore data from {{ name }} Restore
{% endblocktrans %}"/> {% endblocktrans %}"/>
<a title="{% trans 'Abort' %}"
role="button" class="btn btn-warning"
href="{% url 'backups:index' %}">
{% trans 'Abort' %}
</a>
</form> </form>
</p> </p>

View File

@ -1,45 +0,0 @@
{% 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 %}
<h2>{{ title }}</h2>
<p>
<form class="form" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary"
value="{% blocktrans trimmed %}
Restore data
{% endblocktrans %}"/>
<a title="{% trans 'Abort' %}"
role="button" class="btn btn-warning"
href="{% url 'backups:index' %}">
{% trans 'Abort' %}
</a>
</form>
</p>
{% endblock %}

View File

@ -24,9 +24,8 @@ from unittest.mock import MagicMock, call, patch
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from plinth import cfg, module_loader from plinth import cfg, module_loader
from plinth.errors import PlinthError
from .. import api, forms, get_location_path from .. import api, forms
# pylint: disable=protected-access # pylint: disable=protected-access
@ -244,35 +243,16 @@ class TestBackupProcesses(unittest.TestCase):
class TestBackupModule(unittest.TestCase): class TestBackupModule(unittest.TestCase):
"""Tests of the backups django module, like views or forms.""" """Tests of the backups django module, like views or forms."""
def test_get_location_path(self):
"""Test the 'get_location_path' method"""
locations = [{
'path': '/var/www',
'device': '/dummy/device'
}, {
'path': '/etc',
'device': '/dangerous'
}]
location_path = get_location_path('/dummy/device', locations)
self.assertEqual(location_path, locations[0]['path'])
# verify that an unknown location raises an error
with self.assertRaises(PlinthError):
get_location_path('/unknown/device', locations)
def test_file_upload(self): def test_file_upload(self):
locations = get_export_locations()
location_name = locations[0]['device']
post_data = {'location': location_name}
# posting a video should fail # posting a video should fail
video_file = SimpleUploadedFile("video.mp4", b"file_content", video_file = SimpleUploadedFile("video.mp4", b"file_content",
content_type="video/mp4") content_type="video/mp4")
form = forms.UploadForm(post_data, {'file': video_file}) form = forms.UploadForm({}, {'file': video_file})
self.assertFalse(form.is_valid()) self.assertFalse(form.is_valid())
# posting an archive file should work # posting an archive file should work
archive_file = SimpleUploadedFile("backup.tar.gz", b"file_content", archive_file = SimpleUploadedFile("backup.tar.gz", b"file_content",
content_type="application/gzip") content_type="application/gzip")
form = forms.UploadForm(post_data, {'file': archive_file}) form = forms.UploadForm({}, {'file': archive_file})
form.is_valid() form.is_valid()
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())

View File

@ -22,7 +22,7 @@ from django.conf.urls import url
from .views import IndexView, CreateArchiveView, DeleteArchiveView, \ from .views import IndexView, CreateArchiveView, DeleteArchiveView, \
UploadArchiveView, ExportAndDownloadView, RestoreArchiveView, \ UploadArchiveView, ExportAndDownloadView, RestoreArchiveView, \
RestoreFromTmpView RestoreFromUploadView
urlpatterns = [ urlpatterns = [
url(r'^sys/backups/$', IndexView.as_view(), name='index'), url(r'^sys/backups/$', IndexView.as_view(), name='index'),
@ -34,6 +34,6 @@ urlpatterns = [
url(r'^sys/backups/upload/$', UploadArchiveView.as_view(), name='upload'), url(r'^sys/backups/upload/$', UploadArchiveView.as_view(), name='upload'),
url(r'^sys/backups/restore-archive/(?P<name>[^/]+)/$', url(r'^sys/backups/restore-archive/(?P<name>[^/]+)/$',
RestoreArchiveView.as_view(), name='restore-archive'), RestoreArchiveView.as_view(), name='restore-archive'),
url(r'^sys/backups/restore-from-tmp/$', url(r'^sys/backups/restore-from-upload/$',
RestoreFromTmpView.as_view(), name='restore-from-tmp'), RestoreFromUploadView.as_view(), name='restore-from-upload'),
] ]

View File

@ -35,8 +35,8 @@ from django.views.generic import View, FormView, TemplateView
from plinth.modules import backups from plinth.modules import backups
from . import api, TMP_BACKUP_PATH, forms, \ from . import api, UPLOAD_BACKUP_PATH, forms, \
SESSION_BACKUP_VARIABLE, delete_tmp_backup_file SESSION_BACKUP_VARIABLE, delete_upload_backup_file
# number of seconds an uploaded backup file should be kept/stored # number of seconds an uploaded backup file should be kept/stored
KEEP_UPLOADED_BACKUP_FOR = 60*10 KEEP_UPLOADED_BACKUP_FOR = 60*10
@ -145,32 +145,32 @@ class create_temporary_backup_file:
def __init__(self, name): def __init__(self, name):
self.name = name self.name = name
self.path = TMP_BACKUP_PATH self.path = UPLOAD_BACKUP_PATH
def __enter__(self): def __enter__(self):
backups.export_archive(self.name, self.path) backups.export_archive(self.name, self.path)
return self.path return self.path
def __exit__(self, type, value, traceback): def __exit__(self, type, value, traceback):
delete_tmp_backup_file() delete_upload_backup_file()
class UploadArchiveView(SuccessMessageMixin, FormView): class UploadArchiveView(SuccessMessageMixin, FormView):
form_class = forms.UploadToTmpForm form_class = forms.UploadForm
prefix = 'backups' prefix = 'backups'
template_name = 'backups_upload.html' template_name = 'backups_upload.html'
success_url = reverse_lazy('backups:restore-from-tmp') success_url = reverse_lazy('backups:restore-from-upload')
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)
context['title'] = _('Upload and import a backup file') context['title'] = _('Upload and restore a backup file')
context['subsubmenu'] = subsubmenu context['subsubmenu'] = subsubmenu
return context return context
def form_valid(self, form): def form_valid(self, form):
"""store uploaded file.""" """store uploaded file."""
with open(TMP_BACKUP_PATH, 'wb+') as destination: with open(UPLOAD_BACKUP_PATH, 'wb+') as destination:
for chunk in self.request.FILES['backups-file'].chunks(): for chunk in self.request.FILES['backups-file'].chunks():
destination.write(chunk) destination.write(chunk)
self.request.session[SESSION_BACKUP_VARIABLE] = time.time() + \ self.request.session[SESSION_BACKUP_VARIABLE] = time.time() + \
@ -178,51 +178,7 @@ class UploadArchiveView(SuccessMessageMixin, FormView):
return super().form_valid(form) return super().form_valid(form)
class RestoreFromTmpView(SuccessMessageMixin, FormView): class BaseRestoreView(SuccessMessageMixin, FormView):
"""View to restore files from an exported archive.
TODO: combine with RestoreView"""
# TODO: display more information about the backup, like the date
form_class = forms.RestoreFromTmpForm
prefix = 'backups'
template_name = 'backups_restore_from_tmp.html'
success_url = reverse_lazy('backups:index')
success_message = _('Restored files from backup.')
def get(self, *args, **kwargs):
if not os.path.isfile(TMP_BACKUP_PATH):
messages.error(self.request, _('No backup file found.'))
return redirect(reverse_lazy('backups:index'))
else:
return super().get(*args, **kwargs)
def _get_included_apps(self):
"""Save some data used to instantiate the form."""
return backups.get_apps_of_exported_archive(TMP_BACKUP_PATH)
def get_form_kwargs(self):
"""Pass additional keyword args for instantiating the form."""
kwargs = super().get_form_kwargs()
included_apps = self._get_included_apps()
installed_apps = api.get_all_apps_for_backup()
kwargs['apps'] = [
app for app in installed_apps if app.name in included_apps
]
return kwargs
def get_context_data(self, **kwargs):
"""Return additional context for rendering the template."""
context = super().get_context_data(**kwargs)
context['title'] = _('Restore data')
return context
def form_valid(self, form):
"""Restore files from the archive on valid form submission."""
backups.restore_from_tmp(form.cleaned_data['selected_apps'])
return super().form_valid(form)
class RestoreArchiveView(SuccessMessageMixin, FormView):
"""View to restore files from an archive.""" """View to restore files from an archive."""
form_class = forms.RestoreForm form_class = forms.RestoreForm
prefix = 'backups' prefix = 'backups'
@ -230,12 +186,6 @@ class RestoreArchiveView(SuccessMessageMixin, FormView):
success_url = reverse_lazy('backups:index') success_url = reverse_lazy('backups:index')
success_message = _('Restored files from backup.') success_message = _('Restored files from backup.')
def _get_included_apps(self):
"""Save some data used to instantiate the form."""
name = unquote(self.kwargs['name'])
archive_path = backups.get_archive_path(name)
return backups.get_archive_apps(archive_path)
def get_form_kwargs(self): def get_form_kwargs(self):
"""Pass additional keyword args for instantiating the form.""" """Pass additional keyword args for instantiating the form."""
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
@ -249,10 +199,46 @@ class RestoreArchiveView(SuccessMessageMixin, FormView):
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)
context['title'] = _('Restore from backup') context['title'] = _('Restore')
context['name'] = self.kwargs['name'] context['name'] = self.kwargs.get('name', None)
return context return context
class RestoreFromUploadView(BaseRestoreView):
"""View to restore files from an (uploaded) exported archive."""
def get(self, *args, **kwargs):
if not os.path.isfile(UPLOAD_BACKUP_PATH):
messages.error(self.request, _('No backup file found.'))
return redirect(reverse_lazy('backups:index'))
else:
return super().get(*args, **kwargs)
def get_context_data(self, **kwargs):
"""Return additional context for rendering the template."""
context = super().get_context_data(**kwargs)
context['title'] = _('Restore from uploaded file')
return context
def _get_included_apps(self):
"""Save some data used to instantiate the form."""
return backups.get_exported_archive_apps(UPLOAD_BACKUP_PATH)
def form_valid(self, form):
"""Restore files from the archive on valid form submission."""
backups.restore_from_upload(form.cleaned_data['selected_apps'])
return super().form_valid(form)
class RestoreArchiveView(BaseRestoreView):
"""View to restore files from an archive."""
def _get_included_apps(self):
"""Save some data used to instantiate the form."""
name = unquote(self.kwargs['name'])
archive_path = backups.get_archive_path(name)
return backups.get_archive_apps(archive_path)
def form_valid(self, form): def form_valid(self, form):
"""Restore files from the archive on valid form submission.""" """Restore files from the archive on valid form submission."""
archive_path = backups.get_archive_path(self.kwargs['name']) archive_path = backups.get_archive_path(self.kwargs['name'])