backups: New app to manage borgbackup archives

Reviewed-by: Joseph Nuthalapati <njoseph@thoughtworks.com>
This commit is contained in:
James Valleroy 2018-04-21 08:12:23 -04:00 committed by Joseph Nuthalapati
parent 7e2c9b1095
commit c852cd824f
No known key found for this signature in database
GPG Key ID: 5398F00A2FA43C35
9 changed files with 605 additions and 0 deletions

122
actions/backups Executable file
View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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()

View File

@ -0,0 +1 @@
plinth.modules.backups

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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])

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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.'))

View File

@ -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 <http://www.gnu.org/licenses/>.
#
{% endcomment %}
{% load i18n %}
{% block page_head %}
<style type="text/css">
.share-operations form {
display: inline-block;
}
.share-operations {
text-align: right;
}
</style>
{% endblock %}
{% block content %}
<h2>{{ title }}</h2>
{% for paragraph in description %}
<p>{{ paragraph|safe }}</p>
{% endfor %}
<p>
<a title="{% trans 'Create archive' %}"
role="button" class="btn btn-primary"
href="{% url 'backups:create' %}">
{% trans 'Create archive' %}
</a>
</p>
{% if not archives %}
<p>{% trans 'No archives currently exist.' %}</p>
{% else %}
<table class="table table-bordered table-condensed table-striped"
id="archives-list">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Time" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for archive in archives %}
<tr id="archive-{{ archive.name }}" class="archive">
<td class="archive-name">{{ archive.name }}</td>
<td class="archive-time">{{ archive.time }}</td>
<td class="archive-operations">
<a class="archive-extract btn btn-sm btn-default"
href="{% url 'backups:extract' archive.name %}">
{% trans "Extract" %}
</a>
<a class="archive-export btn btn-sm btn-default"
href="{% url 'backups:export' archive.name %}">
{% trans "Export" %}
</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>
{% endif %}
{% endblock %}

View File

@ -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 <http://www.gnu.org/licenses/>.
#
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% block content %}
<h2>{{ title }}</h2>
<p>{% trans "Delete this archive permanently?" %}</p>
<div class="row">
<div class="col-lg-12">
<table class="table table-bordered table-condensed table-striped">
<thead>
<th>{% trans "Name" %}</th>
<th>{% trans "Time" %}</th>
</thead>
<tbody>
<tr>
<td>{{ archive.name }}</td>
<td>{{ archive.time }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<p>
<form class="form" method="post">
{% csrf_token %}
<input type="submit" class="btn btn-danger"
value="{% blocktrans trimmed with name=archive.name %}
Delete Archive {{ name }}
{% endblocktrans %}"/>
</form>
</p>
{% endblock %}

View File

@ -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 <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 "Submit" %}"/>
</form>
{% endblock %}

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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<name>[a-z0-9]+)/delete/$',
DeleteArchiveView.as_view(), name='delete'),
url(r'^sys/backups/(?P<name>[a-z0-9]+)/extract/$',
ExtractArchiveView.as_view(), name='extract'),
url(r'^sys/backups/(?P<name>[a-z0-9]+)/export/$',
ExportArchiveView.as_view(), name='export'),
]

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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)