mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-01-21 07:55:00 +00:00
backups: Handle common errors during borg operations
Closes: #2218. - When borg can't acquire a lock due to it being busy, any borg operation can fail. Show a readable message instead of a generic error. - Also handle errors for archive already existing and archive to be deleted not existing. Tests: - Functional tests for backup app work. - Creating archive works with proper message. Providing the name of existing archive shows proper error. - Deleting archive works with proper message. Open two tabs by clicking on the delete archive button. Then delete with one and try to delete the it again with the next one. Proper error message is shown. - While downloading an archive, delete an archive. Proper error message that borg is busy is shown. - Upload archive works. A proper success message is shown. - Restore backup from archive works. A proper success message is shown. - Restore backup from file upload works. A proper success message is shown after upload and after restoration. - Adding local repository works. A proper success message is shown. - Adding remote repository works. A proper success message is shown when SSH key is verified and repository is added. - Removing repository works. A proper success message is shown. Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org> Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
parent
fd1d13f9af
commit
e0aef43ece
@ -21,3 +21,15 @@ class BorgRepositoryExists(BorgError):
|
||||
|
||||
class BorgUnencryptedRepository(BorgError):
|
||||
"""Attempt to provide password on an unencrypted repository."""
|
||||
|
||||
|
||||
class BorgArchiveExists(BorgError):
|
||||
"""A archive with the given name already exists in the repository."""
|
||||
|
||||
|
||||
class BorgArchiveDoesNotExist(BorgError):
|
||||
"""Specified archive does not exist in the repository."""
|
||||
|
||||
|
||||
class BorgBusy(BorgError):
|
||||
"""Borg could not acquire lock being busy with another operation."""
|
||||
|
||||
@ -68,6 +68,25 @@ KNOWN_ERRORS = [
|
||||
'message': None,
|
||||
'raise_as': errors.BorgRepositoryExists,
|
||||
},
|
||||
{
|
||||
'errors': ['Archive .* already exists'],
|
||||
'message':
|
||||
_('An archive with given name already exists in the repository.'),
|
||||
'raise_as':
|
||||
errors.BorgArchiveExists,
|
||||
},
|
||||
{
|
||||
'errors': ['Archive .* not found'],
|
||||
'message':
|
||||
_('Archive with given name was not found in the repository.'),
|
||||
'raise_as':
|
||||
errors.BorgArchiveDoesNotExist,
|
||||
},
|
||||
{
|
||||
'errors': ['Failed to create/acquire the lock'],
|
||||
'message': _('Backup system is busy with another operation.'),
|
||||
'raise_as': errors.BorgBusy,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
Views for the backups app.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
@ -11,7 +12,7 @@ from urllib.parse import unquote
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from django.http import Http404, HttpRequest, StreamingHttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.decorators import method_decorator
|
||||
@ -24,7 +25,7 @@ from plinth.errors import PlinthError
|
||||
from plinth.modules import backups, storage
|
||||
from plinth.views import AppView
|
||||
|
||||
from . import (SESSION_PATH_VARIABLE, api, forms, get_known_hosts_path,
|
||||
from . import (SESSION_PATH_VARIABLE, api, errors, forms, get_known_hosts_path,
|
||||
is_ssh_hostkey_verified, privileged)
|
||||
from .decorators import delete_tmp_backup_file
|
||||
from .repository import (BorgRepository, SshBorgRepository, get_instance,
|
||||
@ -33,6 +34,15 @@ from .repository import (BorgRepository, SshBorgRepository, get_instance,
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def handle_common_errors(request: HttpRequest):
|
||||
"""If any known Borg exceptions occur, show proper error messages."""
|
||||
try:
|
||||
yield
|
||||
except errors.BorgError as exception:
|
||||
messages.error(request, exception.args[0])
|
||||
|
||||
|
||||
@method_decorator(delete_tmp_backup_file, name='dispatch')
|
||||
class BackupsView(AppView):
|
||||
"""View to show list of archives."""
|
||||
@ -101,14 +111,13 @@ class ScheduleView(SuccessMessageMixin, FormView):
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class CreateArchiveView(SuccessMessageMixin, FormView):
|
||||
class CreateArchiveView(FormView):
|
||||
"""View to create a new archive."""
|
||||
|
||||
form_class = forms.CreateArchiveForm
|
||||
prefix = 'backups'
|
||||
template_name = 'form.html'
|
||||
success_url = reverse_lazy('backups:index')
|
||||
success_message = gettext_lazy('Archive created.')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Return additional context for rendering the template."""
|
||||
@ -136,11 +145,14 @@ class CreateArchiveView(SuccessMessageMixin, FormView):
|
||||
microsecond=0).isoformat()
|
||||
|
||||
selected_apps = form.cleaned_data['selected_apps']
|
||||
repository.create_archive(name, selected_apps)
|
||||
with handle_common_errors(self.request):
|
||||
repository.create_archive(name, selected_apps)
|
||||
messages.success(self.request, _('Archive created.'))
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class DeleteArchiveView(SuccessMessageMixin, TemplateView):
|
||||
class DeleteArchiveView(TemplateView):
|
||||
"""View to delete an archive."""
|
||||
template_name = 'backups_delete.html'
|
||||
|
||||
@ -158,12 +170,14 @@ class DeleteArchiveView(SuccessMessageMixin, TemplateView):
|
||||
def post(self, request, uuid, name):
|
||||
"""Delete the archive."""
|
||||
repository = get_instance(uuid)
|
||||
repository.delete_archive(name)
|
||||
messages.success(request, _('Archive deleted.'))
|
||||
with handle_common_errors(self.request):
|
||||
repository.delete_archive(name)
|
||||
messages.success(request, _('Archive deleted.'))
|
||||
|
||||
return redirect('backups:index')
|
||||
|
||||
|
||||
class UploadArchiveView(SuccessMessageMixin, FormView):
|
||||
class UploadArchiveView(FormView):
|
||||
form_class = forms.UploadForm
|
||||
prefix = 'backups'
|
||||
template_name = 'backups_upload.html'
|
||||
@ -196,20 +210,22 @@ class UploadArchiveView(SuccessMessageMixin, FormView):
|
||||
"""Store uploaded file."""
|
||||
uploaded_file = self.request.FILES['backups-file']
|
||||
# Hold on to Django's uploaded file. It will be used by other views.
|
||||
privileged.add_uploaded_archive(uploaded_file.name,
|
||||
uploaded_file.temporary_file_path())
|
||||
self.request.session[SESSION_PATH_VARIABLE] = str(
|
||||
privileged.BACKUPS_UPLOAD_PATH / uploaded_file.name)
|
||||
with handle_common_errors(self.request):
|
||||
privileged.add_uploaded_archive(
|
||||
uploaded_file.name, uploaded_file.temporary_file_path())
|
||||
self.request.session[SESSION_PATH_VARIABLE] = str(
|
||||
privileged.BACKUPS_UPLOAD_PATH / uploaded_file.name)
|
||||
messages.success(self.request, _('Upload successful.'))
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class BaseRestoreView(SuccessMessageMixin, FormView):
|
||||
class BaseRestoreView(FormView):
|
||||
"""View to restore files from an archive."""
|
||||
form_class = forms.RestoreForm
|
||||
prefix = 'backups'
|
||||
template_name = 'backups_restore.html'
|
||||
success_url = reverse_lazy('backups:index')
|
||||
success_message = gettext_lazy('Restored files from backup.')
|
||||
|
||||
def get_form_kwargs(self):
|
||||
"""Pass additional keyword args for instantiating the form."""
|
||||
@ -257,7 +273,10 @@ class RestoreFromUploadView(BaseRestoreView):
|
||||
"""Restore files from the archive on valid form submission."""
|
||||
path = self.request.session.get(SESSION_PATH_VARIABLE)
|
||||
selected_apps = form.cleaned_data['selected_apps']
|
||||
backups.restore_from_upload(path, selected_apps)
|
||||
with handle_common_errors(self.request):
|
||||
backups.restore_from_upload(path, selected_apps)
|
||||
messages.success(self.request, _('Restored files from backup.'))
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
@ -275,7 +294,10 @@ class RestoreArchiveView(BaseRestoreView):
|
||||
"""Restore files from the archive on valid form submission."""
|
||||
repository = get_instance(self.kwargs['uuid'])
|
||||
selected_apps = form.cleaned_data['selected_apps']
|
||||
repository.restore_archive(self.kwargs['name'], selected_apps)
|
||||
with handle_common_errors(self.request):
|
||||
repository.restore_archive(self.kwargs['name'], selected_apps)
|
||||
messages.success(self.request, _('Restored files from backup.'))
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
@ -293,7 +315,7 @@ class DownloadArchiveView(View):
|
||||
return response
|
||||
|
||||
|
||||
class AddRepositoryView(SuccessMessageMixin, FormView):
|
||||
class AddRepositoryView(FormView):
|
||||
"""View to create a new backup repository."""
|
||||
form_class = forms.AddRepositoryForm
|
||||
template_name = 'backups_add_repository.html'
|
||||
@ -324,14 +346,16 @@ class AddRepositoryView(SuccessMessageMixin, FormView):
|
||||
encryption_passphrase = None
|
||||
|
||||
credentials = {'encryption_passphrase': encryption_passphrase}
|
||||
repository = BorgRepository(path, credentials)
|
||||
if _save_repository(self.request, repository):
|
||||
return super().form_valid(form)
|
||||
with handle_common_errors(self.request):
|
||||
repository = BorgRepository(path, credentials)
|
||||
if _save_repository(self.request, repository):
|
||||
messages.success(self.request, _('Added new repository.'))
|
||||
return super().form_valid(form)
|
||||
|
||||
return redirect(reverse_lazy('backups:add-repository'))
|
||||
|
||||
|
||||
class AddRemoteRepositoryView(SuccessMessageMixin, FormView):
|
||||
class AddRemoteRepositoryView(FormView):
|
||||
"""View to create a new remote backup repository."""
|
||||
form_class = forms.AddRemoteRepositoryForm
|
||||
template_name = 'backups_add_remote_repository.html'
|
||||
@ -356,16 +380,18 @@ class AddRemoteRepositoryView(SuccessMessageMixin, FormView):
|
||||
'ssh_password': form.cleaned_data.get('ssh_password'),
|
||||
'encryption_passphrase': encryption_passphrase
|
||||
}
|
||||
repository = SshBorgRepository(path, credentials)
|
||||
repository.verfied = False
|
||||
repository.save()
|
||||
messages.success(self.request, _('Added new remote SSH repository.'))
|
||||
with handle_common_errors(self.request):
|
||||
repository = SshBorgRepository(path, credentials)
|
||||
repository.verfied = False
|
||||
repository.save()
|
||||
messages.success(self.request,
|
||||
_('Added new remote SSH repository.'))
|
||||
|
||||
url = reverse('backups:verify-ssh-hostkey', args=[repository.uuid])
|
||||
return redirect(url)
|
||||
|
||||
|
||||
class VerifySshHostkeyView(SuccessMessageMixin, FormView):
|
||||
class VerifySshHostkeyView(FormView):
|
||||
"""View to verify SSH Hostkey of the remote repository."""
|
||||
form_class = forms.VerifySshHostkeyForm
|
||||
template_name = 'verify_ssh_hostkey.html'
|
||||
@ -416,10 +442,11 @@ class VerifySshHostkeyView(SuccessMessageMixin, FormView):
|
||||
def form_valid(self, form):
|
||||
"""Create and store the repository."""
|
||||
ssh_public_key = form.cleaned_data['ssh_public_key']
|
||||
self._add_ssh_hostkey(ssh_public_key)
|
||||
messages.success(self.request, _('SSH host verified.'))
|
||||
if _save_repository(self.request, self._get_repository()):
|
||||
return redirect(reverse_lazy('backups:index'))
|
||||
with handle_common_errors(self.request):
|
||||
self._add_ssh_hostkey(ssh_public_key)
|
||||
messages.success(self.request, _('SSH host verified.'))
|
||||
if _save_repository(self.request, self._get_repository()):
|
||||
return redirect(reverse_lazy('backups:index'))
|
||||
|
||||
return redirect(reverse_lazy('backups:add-remote-repository'))
|
||||
|
||||
@ -455,7 +482,7 @@ def _save_repository(request, repository):
|
||||
return False
|
||||
|
||||
|
||||
class RemoveRepositoryView(SuccessMessageMixin, TemplateView):
|
||||
class RemoveRepositoryView(TemplateView):
|
||||
"""View to delete a repository."""
|
||||
template_name = 'backups_repository_remove.html'
|
||||
|
||||
@ -468,10 +495,11 @@ class RemoveRepositoryView(SuccessMessageMixin, TemplateView):
|
||||
|
||||
def post(self, request, uuid):
|
||||
"""Delete the repository on confirmation."""
|
||||
repository = get_instance(uuid)
|
||||
repository.remove()
|
||||
messages.success(request,
|
||||
_('Repository removed. Backups were not deleted.'))
|
||||
with handle_common_errors(self.request):
|
||||
repository = get_instance(uuid)
|
||||
repository.remove()
|
||||
messages.success(
|
||||
request, _('Repository removed. Backups were not deleted.'))
|
||||
|
||||
return redirect('backups:index')
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user