From c852cd824f008235cfe560943fd0fd7da3ae2070 Mon Sep 17 00:00:00 2001 From: James Valleroy Date: Sat, 21 Apr 2018 08:12:23 -0400 Subject: [PATCH] backups: New app to manage borgbackup archives Reviewed-by: Joseph Nuthalapati --- actions/backups | 122 ++++++++++++++++ data/etc/plinth/modules-enabled/backups | 1 + plinth/modules/backups/__init__.py | 85 +++++++++++ plinth/modules/backups/forms.py | 43 ++++++ plinth/modules/backups/templates/backups.html | 89 ++++++++++++ .../backups/templates/backups_delete.html | 57 ++++++++ .../backups/templates/backups_form.html | 37 +++++ plinth/modules/backups/urls.py | 35 +++++ plinth/modules/backups/views.py | 136 ++++++++++++++++++ 9 files changed, 605 insertions(+) create mode 100755 actions/backups create mode 100644 data/etc/plinth/modules-enabled/backups create mode 100644 plinth/modules/backups/__init__.py create mode 100644 plinth/modules/backups/forms.py create mode 100644 plinth/modules/backups/templates/backups.html create mode 100644 plinth/modules/backups/templates/backups_delete.html create mode 100644 plinth/modules/backups/templates/backups_form.html create mode 100644 plinth/modules/backups/urls.py create mode 100644 plinth/modules/backups/views.py diff --git a/actions/backups b/actions/backups new file mode 100755 index 000000000..e84982194 --- /dev/null +++ b/actions/backups @@ -0,0 +1,122 @@ +#!/usr/bin/python3 +# -*- mode: python -*- +# +# 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 . +# +""" +Configuration helper for backups. +""" + +import argparse +import os +import subprocess + +REPOSITORY = '/tmp/freedombox-backups' + + +def parse_arguments(): + """Return parsed command line arguments as dictionary.""" + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') + + subparsers.add_parser( + 'setup', help='Create repository if it does not already exist') + subparsers.add_parser('info', help='Show repository information') + subparsers.add_parser('list', help='List repository contents') + + create = subparsers.add_parser('create', help='Create archive') + create.add_argument('--name', help='Archive name', required=True) + create.add_argument('--path', help='Path to archive', required=True) + + delete = subparsers.add_parser('delete', help='Delete archive') + delete.add_argument('--name', help='Archive name', required=True) + + extract = subparsers.add_parser('extract', help='Extract archive contents') + extract.add_argument('--name', help='Archive name', required=True) + extract.add_argument('--destination', help='Extract destination', + required=True) + + export = subparsers.add_parser('export', + help='Export archive contents as tarball') + export.add_argument('--name', help='Archive name', required=True) + export.add_argument('--filename', help='Tarball file name', required=True) + + subparsers.required = True + return parser.parse_args() + + +def subcommand_setup(_): + """Create repository if it does not already exist.""" + try: + subprocess.run(['borg', 'info', REPOSITORY], check=True) + except: + subprocess.run(['borg', 'init', '--encryption', 'none', REPOSITORY]) + + +def subcommand_info(_): + """Show repository information.""" + subprocess.run(['borg', 'info', '--json', REPOSITORY], check=True) + + +def subcommand_list(_): + """List repository contents.""" + subprocess.run(['borg', 'list', '--json', REPOSITORY], check=True) + + +def subcommand_create(arguments): + """Create archive.""" + subprocess.run([ + 'borg', 'create', '--json', REPOSITORY + '::' + arguments.name, + arguments.path + ], check=True) + + +def subcommand_delete(arguments): + """Delete archive.""" + subprocess.run(['borg', 'delete', REPOSITORY + '::' + arguments.name], + check=True) + + +def subcommand_extract(arguments): + """Extract archive contents.""" + prev_dir = os.getcwd() + try: + os.chdir(os.path.expanduser(arguments.destination)) + subprocess.run(['borg', 'extract', REPOSITORY + '::' + arguments.name], + check=True) + finally: + os.chdir(prev_dir) + + +def subcommand_export(arguments): + """Export archive contents as tarball.""" + subprocess.run([ + 'borg', 'export-tar', REPOSITORY + '::' + arguments.name, + arguments.filename + ], check=True) + + +def main(): + """Parse arguments and perform all duties.""" + arguments = parse_arguments() + + subcommand = arguments.subcommand.replace('-', '_') + subcommand_method = globals()['subcommand_' + subcommand] + subcommand_method(arguments) + + +if __name__ == '__main__': + main() diff --git a/data/etc/plinth/modules-enabled/backups b/data/etc/plinth/modules-enabled/backups new file mode 100644 index 000000000..0e2d89ecf --- /dev/null +++ b/data/etc/plinth/modules-enabled/backups @@ -0,0 +1 @@ +plinth.modules.backups diff --git a/plinth/modules/backups/__init__.py b/plinth/modules/backups/__init__.py new file mode 100644 index 000000000..dae7dd23f --- /dev/null +++ b/plinth/modules/backups/__init__.py @@ -0,0 +1,85 @@ +# +# 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 . +# +""" +FreedomBox app to manage backup archives. +""" + +import json + +from django.utils.translation import ugettext_lazy as _ + +from plinth import actions +from plinth.menu import main_menu + +version = 1 + +managed_packages = ['borgbackup'] + +name = _('Backups') + +description = [_('Backups allows creating and managing backup archives.'), ] + +service = None + + +def init(): + """Intialize the module.""" + menu = main_menu.get('system') + menu.add_urlname(name, 'glyphicon-duplicate', 'backups:index') + + +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(): + output = actions.superuser_run('backups', ['info']) + return json.loads(output) + + +def list_archives(): + output = actions.superuser_run('backups', ['list']) + return json.loads(output)['archives'] + + +def get_archive(name): + for archive in list_archives(): + if archive['name'] == name: + return archive + + return None + + +def create_archive(name, path): + actions.superuser_run('backups', + ['create', '--name', name, '--path', path]) + + +def delete_archive(name): + actions.superuser_run('backups', ['delete', '--name', name]) + + +def extract_archive(name, destination): + actions.superuser_run( + 'backups', ['extract', '--name', name, '--destination', destination]) + + +def export_archive(name, filename): + actions.superuser_run('backups', + ['export', '--name', name, '--filename', filename]) diff --git a/plinth/modules/backups/forms.py b/plinth/modules/backups/forms.py new file mode 100644 index 000000000..90f0daa14 --- /dev/null +++ b/plinth/modules/backups/forms.py @@ -0,0 +1,43 @@ +# +# 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 . +# +""" +Forms for backups module. +""" + +from django import forms +from django.utils.translation import ugettext_lazy as _ + + +class CreateArchiveForm(forms.Form): + name = forms.CharField(label=_('Archive name'), strip=True, + help_text=_('Name for new backup archive.')) + + path = forms.CharField(label=_('Path'), strip=True, help_text=_( + 'Disk path to a folder on this server that will be archived into ' + 'backup repository.')) + + +class ExtractArchiveForm(forms.Form): + path = forms.CharField(label=_('Path'), strip=True, help_text=_( + 'Disk path to a folder on this server where the archive will be ' + 'extracted.')) + + +class ExportArchiveForm(forms.Form): + filename = forms.CharField( + label=_('Exported filename'), strip=True, + help_text=_('Name for the tar file exported from the archive.')) diff --git a/plinth/modules/backups/templates/backups.html b/plinth/modules/backups/templates/backups.html new file mode 100644 index 000000000..6121e396a --- /dev/null +++ b/plinth/modules/backups/templates/backups.html @@ -0,0 +1,89 @@ +{% 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 . +# +{% endcomment %} + +{% load i18n %} + +{% block page_head %} + +{% endblock %} + +{% block content %} + +

{{ title }}

+ + {% for paragraph in description %} +

{{ paragraph|safe }}

+ {% endfor %} + +

+ + {% trans 'Create archive' %} + +

+ + {% if not archives %} +

{% trans 'No archives currently exist.' %}

+ {% else %} + + + + + + + + + + + {% for archive in archives %} + + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Time" %}
{{ archive.name }}{{ archive.time }} + + {% trans "Extract" %} + + + {% trans "Export" %} + + + + +
+ {% endif %} + +{% endblock %} diff --git a/plinth/modules/backups/templates/backups_delete.html b/plinth/modules/backups/templates/backups_delete.html new file mode 100644 index 000000000..b7fa50a70 --- /dev/null +++ b/plinth/modules/backups/templates/backups_delete.html @@ -0,0 +1,57 @@ +{% 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 . +# +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block content %} +

{{ title }}

+ +

{% trans "Delete this archive permanently?" %}

+ +
+
+ + + + + + + + + + + +
{% trans "Name" %}{% trans "Time" %}
{{ archive.name }}{{ archive.time }}
+
+
+ +

+

+ {% csrf_token %} + + +
+

+ +{% endblock %} diff --git a/plinth/modules/backups/templates/backups_form.html b/plinth/modules/backups/templates/backups_form.html new file mode 100644 index 000000000..97150a2a7 --- /dev/null +++ b/plinth/modules/backups/templates/backups_form.html @@ -0,0 +1,37 @@ +{% 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 . +# +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block content %} + +

{{ title }}

+ +
+ {% csrf_token %} + + {{ form|bootstrap }} + + +
+ +{% endblock %} diff --git a/plinth/modules/backups/urls.py b/plinth/modules/backups/urls.py new file mode 100644 index 000000000..53b419419 --- /dev/null +++ b/plinth/modules/backups/urls.py @@ -0,0 +1,35 @@ +# +# 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 . +# +""" +URLs for the backups module. +""" + +from django.conf.urls import url + +from .views import IndexView, CreateArchiveView, DeleteArchiveView, \ + ExtractArchiveView, ExportArchiveView + +urlpatterns = [ + url(r'^sys/backups/$', IndexView.as_view(), name='index'), + url(r'^sys/backups/create/$', CreateArchiveView.as_view(), name='create'), + url(r'^sys/backups/(?P[a-z0-9]+)/delete/$', + DeleteArchiveView.as_view(), name='delete'), + url(r'^sys/backups/(?P[a-z0-9]+)/extract/$', + ExtractArchiveView.as_view(), name='extract'), + url(r'^sys/backups/(?P[a-z0-9]+)/export/$', + ExportArchiveView.as_view(), name='export'), +] diff --git a/plinth/modules/backups/views.py b/plinth/modules/backups/views.py new file mode 100644 index 000000000..673c52596 --- /dev/null +++ b/plinth/modules/backups/views.py @@ -0,0 +1,136 @@ +# +# 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 . +# +""" +Views for the backups app. +""" + +from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin +from django.http import Http404 +from django.shortcuts import redirect +from django.urls import reverse_lazy +from django.utils.translation import ugettext as _ +from django.views.generic import FormView, TemplateView + +from plinth.modules import backups + +from .forms import CreateArchiveForm, ExtractArchiveForm, ExportArchiveForm + + +class IndexView(TemplateView): + """View to show list of archives.""" + template_name = 'backups.html' + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = backups.name + context['description'] = backups.description + context['info'] = backups.get_info() + context['archives'] = backups.list_archives() + return context + + +class CreateArchiveView(SuccessMessageMixin, FormView): + """View to create a new archive.""" + form_class = CreateArchiveForm + prefix = 'backups' + template_name = 'backups_form.html' + success_url = reverse_lazy('backups:index') + success_message = _('Archive created.') + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Create Archive') + return context + + def form_valid(self, form): + """Create the archive on valid form submission.""" + backups.create_archive(form.cleaned_data['name'], + form.cleaned_data['path']) + return super().form_valid(form) + + +class DeleteArchiveView(SuccessMessageMixin, TemplateView): + """View to delete an archive.""" + template_name = 'backups_delete.html' + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Delete Archive') + context['archive'] = backups.get_archive(self.kwargs['name']) + if context['archive'] is None: + raise Http404 + + return context + + def post(self, request, name): + """Delete the archive.""" + backups.delete_archive(name) + messages.success(request, _('Archive deleted.')) + return redirect(reverse_lazy('backups:index')) + + +class ExtractArchiveView(SuccessMessageMixin, FormView): + """View to extract an archive.""" + form_class = ExtractArchiveForm + prefix = 'backups' + template_name = 'backups_form.html' + success_url = reverse_lazy('backups:index') + success_message = _('Archive extracted.') + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Extract Archive') + context['archive'] = backups.get_archive(self.kwargs['name']) + if context['archive'] is None: + raise Http404 + + return context + + def form_valid(self, form): + """Create the archive on valid form submission.""" + backups.extract_archive(self.kwargs['name'], form.cleaned_data['path']) + return super().form_valid(form) + + +class ExportArchiveView(SuccessMessageMixin, FormView): + """View to export an archive.""" + form_class = ExportArchiveForm + prefix = 'backups' + template_name = 'backups_form.html' + success_url = reverse_lazy('backups:index') + success_message = _('Archive exported.') + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Export Archive') + context['archive'] = backups.get_archive(self.kwargs['name']) + if context['archive'] is None: + raise Http404 + + return context + + def form_valid(self, form): + """Create the archive on valid form submission.""" + backups.export_archive(self.kwargs['name'], + form.cleaned_data['filename']) + return super().form_valid(form)