mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-08-26 12:46:08 +00:00
backups: Restore from exported archive
Signed-off-by: James Valleroy <jvalleroy@mailbox.org> Reviewed-by: Joseph Nuthalapati <njoseph@thoughtworks.com>
This commit is contained in:
parent
bd45de2915
commit
04d14e276f
@ -21,6 +21,7 @@ Configuration helper for backups.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import glob
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@ -57,8 +58,13 @@ def parse_arguments():
|
|||||||
|
|
||||||
list_exports = subparsers.add_parser(
|
list_exports = subparsers.add_parser(
|
||||||
'list-exports', help='List exported backup archive files')
|
'list-exports', help='List exported backup archive files')
|
||||||
list_exports.add_argument('--locations', nargs='+',
|
list_exports.add_argument('--location', required=True,
|
||||||
help='list of locations to check')
|
help='location to check')
|
||||||
|
|
||||||
|
restore = subparsers.add_parser(
|
||||||
|
'restore', help='Restore files from an exported archive')
|
||||||
|
restore.add_argument('--filename', help='Tarball file name', required=True)
|
||||||
|
|
||||||
subparsers.required = True
|
subparsers.required = True
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
@ -121,17 +127,27 @@ def subcommand_export(arguments):
|
|||||||
|
|
||||||
def subcommand_list_exports(arguments):
|
def subcommand_list_exports(arguments):
|
||||||
"""List exported backup archive files."""
|
"""List exported backup archive files."""
|
||||||
archive_files = []
|
exports = []
|
||||||
for location in arguments.locations:
|
path = arguments.location
|
||||||
backup_path = location
|
if path[-1] != '/':
|
||||||
if backup_path[-1] != '/':
|
path += '/'
|
||||||
backup_path += '/'
|
|
||||||
backup_path += 'FreedomBox-backups/'
|
|
||||||
if os.path.exists(backup_path):
|
|
||||||
for filename in os.listdir(backup_path):
|
|
||||||
archive_files.append(os.path.join(backup_path, filename))
|
|
||||||
|
|
||||||
print(json.dumps(archive_files))
|
path += 'FreedomBox-backups/'
|
||||||
|
if os.path.exists(path):
|
||||||
|
for filename in glob.glob(path + '*.tar.gz'):
|
||||||
|
exports.append(os.path.basename(filename))
|
||||||
|
|
||||||
|
print(json.dumps(exports))
|
||||||
|
|
||||||
|
|
||||||
|
def subcommand_restore(arguments):
|
||||||
|
"""Restore files from an exported archive."""
|
||||||
|
prev_dir = os.getcwd()
|
||||||
|
try:
|
||||||
|
os.chdir('/')
|
||||||
|
subprocess.run(['tar', 'xf', arguments.filename], check=True)
|
||||||
|
finally:
|
||||||
|
os.chdir(prev_dir)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@ -76,11 +76,6 @@ def delete_archive(name):
|
|||||||
actions.superuser_run('backups', ['delete', '--name', 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, location):
|
def export_archive(name, location):
|
||||||
if location[-1] != '/':
|
if location[-1] != '/':
|
||||||
location += '/'
|
location += '/'
|
||||||
@ -102,9 +97,27 @@ def get_export_locations():
|
|||||||
return locations
|
return locations
|
||||||
|
|
||||||
|
|
||||||
def list_export_files():
|
def get_export_files():
|
||||||
"""Return a list of exported backup archives found in storage locations."""
|
"""Return a dict of exported backup archives found in storage locations."""
|
||||||
locations = [x[0] for x in get_export_locations()]
|
locations = get_export_locations()
|
||||||
command = ['list-exports', '--locations'] + locations
|
export_files = {}
|
||||||
output = actions.superuser_run('backups', command)
|
for location in locations:
|
||||||
return json.loads(output)
|
output = actions.superuser_run(
|
||||||
|
'backups', ['list-exports', '--location', location[0]])
|
||||||
|
export_files[location[1]] = json.loads(output)
|
||||||
|
|
||||||
|
return export_files
|
||||||
|
|
||||||
|
|
||||||
|
def restore_exported(label, name):
|
||||||
|
"""Restore files from exported backup archive."""
|
||||||
|
locations = get_export_locations()
|
||||||
|
for location in locations:
|
||||||
|
if location[1] == label:
|
||||||
|
filename = location[0]
|
||||||
|
if filename[-1] != '/':
|
||||||
|
filename += '/'
|
||||||
|
filename += 'FreedomBox-backups/' + name
|
||||||
|
actions.superuser_run(
|
||||||
|
'backups', ['restore', '--filename', filename])
|
||||||
|
break
|
||||||
|
|||||||
@ -37,12 +37,6 @@ class CreateArchiveForm(forms.Form):
|
|||||||
'backup repository.'))
|
'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):
|
class ExportArchiveForm(forms.Form):
|
||||||
disk = forms.ChoiceField(
|
disk = forms.ChoiceField(
|
||||||
label=_('Disk'), widget=forms.RadioSelect(),
|
label=_('Disk'), widget=forms.RadioSelect(),
|
||||||
|
|||||||
@ -52,7 +52,7 @@
|
|||||||
<p>{% trans 'No archives currently exist.' %}</p>
|
<p>{% trans 'No archives currently exist.' %}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<table class="table table-bordered table-condensed table-striped"
|
<table class="table table-bordered table-condensed table-striped"
|
||||||
id="archives-list">
|
id="archives-list">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{% trans "Name" %}</th>
|
<th>{% trans "Name" %}</th>
|
||||||
@ -67,18 +67,14 @@
|
|||||||
<td class="archive-name">{{ archive.name }}</td>
|
<td class="archive-name">{{ archive.name }}</td>
|
||||||
<td class="archive-time">{{ archive.time }}</td>
|
<td class="archive-time">{{ archive.time }}</td>
|
||||||
<td class="archive-operations">
|
<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"
|
<a class="archive-export btn btn-sm btn-default"
|
||||||
href="{% url 'backups:export' archive.name %}">
|
href="{% url 'backups:export' archive.name %}">
|
||||||
{% trans "Export" %}
|
{% trans "Export" %}
|
||||||
</a>
|
</a>
|
||||||
<a class="archive-delete btn btn-sm btn-default"
|
<a class="archive-delete btn btn-sm btn-default"
|
||||||
href="{% url 'backups:delete' archive.name %}">
|
href="{% url 'backups:delete' archive.name %}">
|
||||||
<span class="glyphicon glyphicon-trash" aria-hidden="true">
|
<span class="glyphicon glyphicon-trash" aria-hidden="true">
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -92,18 +88,29 @@
|
|||||||
<p>{% trans 'No exported backup archives were found.' %}</p>
|
<p>{% trans 'No exported backup archives were found.' %}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<table class="table table-bordered table-condensed table-striped"
|
<table class="table table-bordered table-condensed table-striped"
|
||||||
id="exports-list">
|
id="exports-list">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>{% trans "Location" %}</th>
|
||||||
<th>{% trans "Name" %}</th>
|
<th>{% trans "Name" %}</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for export in exports %}
|
{% for label, file_list in exports.items %}
|
||||||
<tr id="export-{{ export }}" class="export">
|
{% for name in file_list %}
|
||||||
<td class="export-name">{{ export }}</td>
|
<tr id="export-{{ label }}-{{ name }}" class="export">
|
||||||
</tr>
|
<td class="export-label">{{ label }}</td>
|
||||||
|
<td class="export-name">{{ name }}</td>
|
||||||
|
<td class="export-operations">
|
||||||
|
<a class="restore btn btn-sm btn-default"
|
||||||
|
href="{% url 'backups:restore' label|urlencode name|urlencode %}">
|
||||||
|
{% trans "Restore" %}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
57
plinth/modules/backups/templates/backups_restore.html
Normal file
57
plinth/modules/backups/templates/backups_restore.html
Normal 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 "Restore data from this archive?" %}</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-12">
|
||||||
|
<table class="table table-bordered table-condensed table-striped">
|
||||||
|
<thead>
|
||||||
|
<th>{% trans "Location" %}</th>
|
||||||
|
<th>{% trans "Name" %}</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>{{ label }}</td>
|
||||||
|
<td>{{ name }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<form class="form" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<input type="submit" class="btn btn-danger"
|
||||||
|
value="{% blocktrans trimmed %}
|
||||||
|
Restore data from {{ name }}
|
||||||
|
{% endblocktrans %}"/>
|
||||||
|
</form>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@ -20,16 +20,16 @@ URLs for the backups module.
|
|||||||
|
|
||||||
from django.conf.urls import url
|
from django.conf.urls import url
|
||||||
|
|
||||||
from .views import IndexView, CreateArchiveView, DeleteArchiveView, \
|
from .views import IndexView, CreateArchiveView, ExportArchiveView, \
|
||||||
ExtractArchiveView, ExportArchiveView
|
DeleteArchiveView, RestoreView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^sys/backups/$', IndexView.as_view(), name='index'),
|
url(r'^sys/backups/$', IndexView.as_view(), name='index'),
|
||||||
url(r'^sys/backups/create/$', CreateArchiveView.as_view(), name='create'),
|
url(r'^sys/backups/create/$', CreateArchiveView.as_view(), name='create'),
|
||||||
url(r'^sys/backups/(?P<name>[^/]+)/delete/$',
|
url(r'^sys/backups/export/(?P<name>[^/]+)/$',
|
||||||
DeleteArchiveView.as_view(), name='delete'),
|
|
||||||
url(r'^sys/backups/(?P<name>[^/]+)/extract/$',
|
|
||||||
ExtractArchiveView.as_view(), name='extract'),
|
|
||||||
url(r'^sys/backups/(?P<name>[^/]+)/export/$',
|
|
||||||
ExportArchiveView.as_view(), name='export'),
|
ExportArchiveView.as_view(), name='export'),
|
||||||
|
url(r'^sys/backups/delete/(?P<name>[^/]+)/$',
|
||||||
|
DeleteArchiveView.as_view(), name='delete'),
|
||||||
|
url(r'^sys/backups/restore/(?P<label>[^/]+)/(?P<name>[^/]+)/$',
|
||||||
|
RestoreView.as_view(), name='restore'),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -25,10 +25,11 @@ from django.shortcuts import redirect
|
|||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.views.generic import FormView, TemplateView
|
from django.views.generic import FormView, TemplateView
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from plinth.modules import backups
|
from plinth.modules import backups
|
||||||
|
|
||||||
from .forms import CreateArchiveForm, ExtractArchiveForm, ExportArchiveForm
|
from .forms import CreateArchiveForm, ExportArchiveForm
|
||||||
|
|
||||||
|
|
||||||
class IndexView(TemplateView):
|
class IndexView(TemplateView):
|
||||||
@ -42,7 +43,7 @@ class IndexView(TemplateView):
|
|||||||
context['description'] = backups.description
|
context['description'] = backups.description
|
||||||
context['info'] = backups.get_info()
|
context['info'] = backups.get_info()
|
||||||
context['archives'] = backups.list_archives()
|
context['archives'] = backups.list_archives()
|
||||||
context['exports'] = backups.list_export_files()
|
context['exports'] = backups.get_export_files()
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@ -88,30 +89,6 @@ class DeleteArchiveView(SuccessMessageMixin, TemplateView):
|
|||||||
return redirect(reverse_lazy('backups:index'))
|
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):
|
class ExportArchiveView(SuccessMessageMixin, FormView):
|
||||||
"""View to export an archive."""
|
"""View to export an archive."""
|
||||||
form_class = ExportArchiveForm
|
form_class = ExportArchiveForm
|
||||||
@ -135,3 +112,22 @@ class ExportArchiveView(SuccessMessageMixin, FormView):
|
|||||||
backups.export_archive(self.kwargs['name'],
|
backups.export_archive(self.kwargs['name'],
|
||||||
form.cleaned_data['disk'])
|
form.cleaned_data['disk'])
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreView(SuccessMessageMixin, TemplateView):
|
||||||
|
"""View to restore files from an exported archive."""
|
||||||
|
template_name = 'backups_restore.html'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""Return additional context for rendering the template."""
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['title'] = _('Restore from backup')
|
||||||
|
context['label'] = unquote(self.kwargs['label'])
|
||||||
|
context['name'] = self.kwargs['name']
|
||||||
|
return context
|
||||||
|
|
||||||
|
def post(self, request, label, name):
|
||||||
|
"""Restore files from the archive on valid form submission."""
|
||||||
|
backups.restore_exported(label, name)
|
||||||
|
messages.success(request, _('Restored data from backup.'))
|
||||||
|
return redirect(reverse_lazy('backups:index'))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user