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:
Sunil Mohan Adapa 2024-12-24 10:28:22 -08:00 committed by James Valleroy
parent fd1d13f9af
commit e0aef43ece
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
3 changed files with 95 additions and 36 deletions

View File

@ -21,3 +21,15 @@ class BorgRepositoryExists(BorgError):
class BorgUnencryptedRepository(BorgError): class BorgUnencryptedRepository(BorgError):
"""Attempt to provide password on an unencrypted repository.""" """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."""

View File

@ -68,6 +68,25 @@ KNOWN_ERRORS = [
'message': None, 'message': None,
'raise_as': errors.BorgRepositoryExists, '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,
},
] ]

View File

@ -3,6 +3,7 @@
Views for the backups app. Views for the backups app.
""" """
import contextlib
import logging import logging
import os import os
import subprocess import subprocess
@ -11,7 +12,7 @@ from urllib.parse import unquote
from django.contrib import messages from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin 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.shortcuts import redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@ -24,7 +25,7 @@ from plinth.errors import PlinthError
from plinth.modules import backups, storage from plinth.modules import backups, storage
from plinth.views import AppView 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) is_ssh_hostkey_verified, privileged)
from .decorators import delete_tmp_backup_file from .decorators import delete_tmp_backup_file
from .repository import (BorgRepository, SshBorgRepository, get_instance, from .repository import (BorgRepository, SshBorgRepository, get_instance,
@ -33,6 +34,15 @@ from .repository import (BorgRepository, SshBorgRepository, get_instance,
logger = logging.getLogger(__name__) 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') @method_decorator(delete_tmp_backup_file, name='dispatch')
class BackupsView(AppView): class BackupsView(AppView):
"""View to show list of archives.""" """View to show list of archives."""
@ -101,14 +111,13 @@ class ScheduleView(SuccessMessageMixin, FormView):
return super().form_valid(form) return super().form_valid(form)
class CreateArchiveView(SuccessMessageMixin, FormView): class CreateArchiveView(FormView):
"""View to create a new archive.""" """View to create a new archive."""
form_class = forms.CreateArchiveForm form_class = forms.CreateArchiveForm
prefix = 'backups' prefix = 'backups'
template_name = 'form.html' template_name = 'form.html'
success_url = reverse_lazy('backups:index') success_url = reverse_lazy('backups:index')
success_message = gettext_lazy('Archive created.')
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."""
@ -136,11 +145,14 @@ class CreateArchiveView(SuccessMessageMixin, FormView):
microsecond=0).isoformat() microsecond=0).isoformat()
selected_apps = form.cleaned_data['selected_apps'] 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) return super().form_valid(form)
class DeleteArchiveView(SuccessMessageMixin, TemplateView): class DeleteArchiveView(TemplateView):
"""View to delete an archive.""" """View to delete an archive."""
template_name = 'backups_delete.html' template_name = 'backups_delete.html'
@ -158,12 +170,14 @@ class DeleteArchiveView(SuccessMessageMixin, TemplateView):
def post(self, request, uuid, name): def post(self, request, uuid, name):
"""Delete the archive.""" """Delete the archive."""
repository = get_instance(uuid) repository = get_instance(uuid)
repository.delete_archive(name) with handle_common_errors(self.request):
messages.success(request, _('Archive deleted.')) repository.delete_archive(name)
messages.success(request, _('Archive deleted.'))
return redirect('backups:index') return redirect('backups:index')
class UploadArchiveView(SuccessMessageMixin, FormView): class UploadArchiveView(FormView):
form_class = forms.UploadForm form_class = forms.UploadForm
prefix = 'backups' prefix = 'backups'
template_name = 'backups_upload.html' template_name = 'backups_upload.html'
@ -196,20 +210,22 @@ class UploadArchiveView(SuccessMessageMixin, FormView):
"""Store uploaded file.""" """Store uploaded file."""
uploaded_file = self.request.FILES['backups-file'] uploaded_file = self.request.FILES['backups-file']
# Hold on to Django's uploaded file. It will be used by other views. # Hold on to Django's uploaded file. It will be used by other views.
privileged.add_uploaded_archive(uploaded_file.name, with handle_common_errors(self.request):
uploaded_file.temporary_file_path()) privileged.add_uploaded_archive(
self.request.session[SESSION_PATH_VARIABLE] = str( uploaded_file.name, uploaded_file.temporary_file_path())
privileged.BACKUPS_UPLOAD_PATH / uploaded_file.name) 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) return super().form_valid(form)
class BaseRestoreView(SuccessMessageMixin, FormView): class BaseRestoreView(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'
template_name = 'backups_restore.html' template_name = 'backups_restore.html'
success_url = reverse_lazy('backups:index') success_url = reverse_lazy('backups:index')
success_message = gettext_lazy('Restored files from backup.')
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."""
@ -257,7 +273,10 @@ class RestoreFromUploadView(BaseRestoreView):
"""Restore files from the archive on valid form submission.""" """Restore files from the archive on valid form submission."""
path = self.request.session.get(SESSION_PATH_VARIABLE) path = self.request.session.get(SESSION_PATH_VARIABLE)
selected_apps = form.cleaned_data['selected_apps'] 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) return super().form_valid(form)
@ -275,7 +294,10 @@ class RestoreArchiveView(BaseRestoreView):
"""Restore files from the archive on valid form submission.""" """Restore files from the archive on valid form submission."""
repository = get_instance(self.kwargs['uuid']) repository = get_instance(self.kwargs['uuid'])
selected_apps = form.cleaned_data['selected_apps'] 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) return super().form_valid(form)
@ -293,7 +315,7 @@ class DownloadArchiveView(View):
return response return response
class AddRepositoryView(SuccessMessageMixin, FormView): class AddRepositoryView(FormView):
"""View to create a new backup repository.""" """View to create a new backup repository."""
form_class = forms.AddRepositoryForm form_class = forms.AddRepositoryForm
template_name = 'backups_add_repository.html' template_name = 'backups_add_repository.html'
@ -324,14 +346,16 @@ class AddRepositoryView(SuccessMessageMixin, FormView):
encryption_passphrase = None encryption_passphrase = None
credentials = {'encryption_passphrase': encryption_passphrase} credentials = {'encryption_passphrase': encryption_passphrase}
repository = BorgRepository(path, credentials) with handle_common_errors(self.request):
if _save_repository(self.request, repository): repository = BorgRepository(path, credentials)
return super().form_valid(form) 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')) return redirect(reverse_lazy('backups:add-repository'))
class AddRemoteRepositoryView(SuccessMessageMixin, FormView): class AddRemoteRepositoryView(FormView):
"""View to create a new remote backup repository.""" """View to create a new remote backup repository."""
form_class = forms.AddRemoteRepositoryForm form_class = forms.AddRemoteRepositoryForm
template_name = 'backups_add_remote_repository.html' template_name = 'backups_add_remote_repository.html'
@ -356,16 +380,18 @@ class AddRemoteRepositoryView(SuccessMessageMixin, FormView):
'ssh_password': form.cleaned_data.get('ssh_password'), 'ssh_password': form.cleaned_data.get('ssh_password'),
'encryption_passphrase': encryption_passphrase 'encryption_passphrase': encryption_passphrase
} }
repository = SshBorgRepository(path, credentials) with handle_common_errors(self.request):
repository.verfied = False repository = SshBorgRepository(path, credentials)
repository.save() repository.verfied = False
messages.success(self.request, _('Added new remote SSH repository.')) repository.save()
messages.success(self.request,
_('Added new remote SSH repository.'))
url = reverse('backups:verify-ssh-hostkey', args=[repository.uuid]) url = reverse('backups:verify-ssh-hostkey', args=[repository.uuid])
return redirect(url) return redirect(url)
class VerifySshHostkeyView(SuccessMessageMixin, FormView): class VerifySshHostkeyView(FormView):
"""View to verify SSH Hostkey of the remote repository.""" """View to verify SSH Hostkey of the remote repository."""
form_class = forms.VerifySshHostkeyForm form_class = forms.VerifySshHostkeyForm
template_name = 'verify_ssh_hostkey.html' template_name = 'verify_ssh_hostkey.html'
@ -416,10 +442,11 @@ class VerifySshHostkeyView(SuccessMessageMixin, FormView):
def form_valid(self, form): def form_valid(self, form):
"""Create and store the repository.""" """Create and store the repository."""
ssh_public_key = form.cleaned_data['ssh_public_key'] ssh_public_key = form.cleaned_data['ssh_public_key']
self._add_ssh_hostkey(ssh_public_key) with handle_common_errors(self.request):
messages.success(self.request, _('SSH host verified.')) self._add_ssh_hostkey(ssh_public_key)
if _save_repository(self.request, self._get_repository()): messages.success(self.request, _('SSH host verified.'))
return redirect(reverse_lazy('backups:index')) if _save_repository(self.request, self._get_repository()):
return redirect(reverse_lazy('backups:index'))
return redirect(reverse_lazy('backups:add-remote-repository')) return redirect(reverse_lazy('backups:add-remote-repository'))
@ -455,7 +482,7 @@ def _save_repository(request, repository):
return False return False
class RemoveRepositoryView(SuccessMessageMixin, TemplateView): class RemoveRepositoryView(TemplateView):
"""View to delete a repository.""" """View to delete a repository."""
template_name = 'backups_repository_remove.html' template_name = 'backups_repository_remove.html'
@ -468,10 +495,11 @@ class RemoveRepositoryView(SuccessMessageMixin, TemplateView):
def post(self, request, uuid): def post(self, request, uuid):
"""Delete the repository on confirmation.""" """Delete the repository on confirmation."""
repository = get_instance(uuid) with handle_common_errors(self.request):
repository.remove() repository = get_instance(uuid)
messages.success(request, repository.remove()
_('Repository removed. Backups were not deleted.')) messages.success(
request, _('Repository removed. Backups were not deleted.'))
return redirect('backups:index') return redirect('backups:index')