backups: Parse borg errors from all operations and not just some

- Currently, in the repository class, if an operation fails, the error messages
from borg are interpreted and re-raised. Everywhere else, the errors are
interpreted. Fix this by wrapping privileged methods at the privileged module
level instead of a context manager at the place of calling.

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-23 22:53:44 -08:00 committed by James Valleroy
parent 9b29ea960f
commit a6b16920e2
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
2 changed files with 114 additions and 96 deletions

View File

@ -1,6 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Configure backups (with borg) and sshfs."""
import functools
import json
import os
import pathlib
@ -8,20 +9,100 @@ import re
import subprocess
import tarfile
from django.utils.translation import gettext_lazy as _
from plinth import action_utils
from plinth.actions import privileged, secret_str
from plinth.utils import Version
from . import errors
TIMEOUT = 30
BACKUPS_DATA_PATH = pathlib.Path('/var/lib/plinth/backups-data/')
BACKUPS_UPLOAD_PATH = pathlib.Path('/var/lib/freedombox/backups-upload/')
MANIFESTS_FOLDER = '/var/lib/plinth/backups-manifests/'
# known errors that come up when remotely accessing a borg repository
# 'errors' are error strings to look for in the stacktrace.
KNOWN_ERRORS = [
{
'errors': ['subprocess.TimeoutExpired'],
'message':
_('Connection refused - make sure you provided correct '
'credentials and the server is running.'),
'raise_as':
errors.BorgError,
},
{
'errors': ['Connection refused'],
'message': _('Connection refused'),
'raise_as': errors.BorgError,
},
{
'errors': [
'not a valid repository', 'does not exist', 'FileNotFoundError'
],
'message': _('Repository not found'),
'raise_as': errors.BorgRepositoryDoesNotExistError,
},
{
'errors': ['passphrase supplied in .* is incorrect'],
'message': _('Incorrect encryption passphrase'),
'raise_as': errors.BorgError,
},
{
'errors': ['Connection reset by peer'],
'message': _('SSH access denied'),
'raise_as': errors.SshfsError,
},
{
'errors': ['There is already something at'],
'message':
_('Repository path is neither empty nor '
'is an existing backups repository.'),
'raise_as':
errors.BorgError,
},
{
'errors': ['A repository already exists at'],
'message': None,
'raise_as': errors.BorgRepositoryExists,
},
]
class AlreadyMountedError(Exception):
"""Exception raised when mount point is already mounted."""
def reraise_known_errors(privileged_func):
"""Decorator to convert borg raised exceptions to specialized ones."""
@functools.wraps(privileged_func)
def wrapper(*args, **kwargs):
"""Run privileged method, catch exceptions and throw new ones."""
try:
return privileged_func(*args, **kwargs)
except Exception as exception:
_reraise_known_errors(exception)
return wrapper
def _reraise_known_errors(err):
"""Look whether the caught error is known and reraise it accordingly"""
stdout = getattr(err, 'stdout', b'').decode()
stderr = getattr(err, 'stderr', b'').decode()
caught_error = str((err, err.args, stdout, stderr))
for known_error in KNOWN_ERRORS:
for error in known_error['errors']:
if re.search(error, caught_error):
raise known_error['raise_as'](known_error['message'])
raise err
@reraise_known_errors
@privileged
def mount(mountpoint: str, remote_path: str, ssh_keyfile: str | None = None,
password: secret_str | None = None,
@ -61,6 +142,7 @@ def mount(mountpoint: str, remote_path: str, ssh_keyfile: str | None = None,
subprocess.run(cmd, check=True, timeout=TIMEOUT, input=input_)
@reraise_known_errors
@privileged
def umount(mountpoint: str):
"""Unmount a mountpoint."""
@ -91,12 +173,14 @@ def _is_mounted(mountpoint):
return False
@reraise_known_errors
@privileged
def is_mounted(mount_point: str) -> bool:
"""Return whether a path is already mounted."""
return _is_mounted(mount_point)
@reraise_known_errors
@privileged
def setup(path: str):
"""Create repository if it does not already exist."""
@ -121,6 +205,7 @@ def _init_repository(path: str, encryption: str,
_run(cmd, encryption_passphrase)
@reraise_known_errors
@privileged
def init(path: str, encryption: str,
encryption_passphrase: secret_str | None = None):
@ -128,6 +213,7 @@ def init(path: str, encryption: str,
_init_repository(path, encryption, encryption_passphrase)
@reraise_known_errors
@privileged
def info(path: str, encryption_passphrase: secret_str | None = None) -> dict:
"""Show repository information."""
@ -136,6 +222,7 @@ def info(path: str, encryption_passphrase: secret_str | None = None) -> dict:
return json.loads(process.stdout.decode())
@reraise_known_errors
@privileged
def list_repo(path: str,
encryption_passphrase: secret_str | None = None) -> dict:
@ -145,6 +232,7 @@ def list_repo(path: str,
return json.loads(process.stdout.decode())
@reraise_known_errors
@privileged
def add_uploaded_archive(file_name: str, temporary_file_path: str):
"""Store an archive uploaded by the user."""
@ -154,6 +242,7 @@ def add_uploaded_archive(file_name: str, temporary_file_path: str):
permissions=0o600)
@reraise_known_errors
@privileged
def remove_uploaded_archive(file_path: str):
"""Delete the archive uploaded by the user."""
@ -169,6 +258,7 @@ def _get_borg_version():
return process.stdout.decode().split()[1] # Example: "borg 1.1.9"
@reraise_known_errors
@privileged
def create_archive(path: str, paths: list[str], comment: str | None = None,
encryption_passphrase: secret_str | None = None):
@ -188,6 +278,7 @@ def create_archive(path: str, paths: list[str], comment: str | None = None,
_run(command, encryption_passphrase)
@reraise_known_errors
@privileged
def delete_archive(path: str, encryption_passphrase: secret_str | None = None):
"""Delete archive."""
@ -218,6 +309,7 @@ def _extract(archive_path, destination, encryption_passphrase, locations=None):
os.chdir(prev_dir)
@reraise_known_errors
@privileged
def export_tar(path: str, encryption_passphrase: secret_str | None = None):
"""Export archive contents as tar stream on stdout."""
@ -232,6 +324,7 @@ def _read_archive_file(archive, filepath, encryption_passphrase):
stdout=subprocess.PIPE).stdout.decode()
@reraise_known_errors
@privileged
def get_archive_apps(
path: str,
@ -278,6 +371,7 @@ def _get_apps_of_manifest(manifest):
return apps
@reraise_known_errors
@privileged
def get_exported_archive_apps(path: str) -> list[str]:
"""Get list of apps included in an exported archive file."""
@ -304,6 +398,7 @@ def get_exported_archive_apps(path: str) -> list[str]:
return app_names
@reraise_known_errors
@privileged
def restore_archive(archive_path: str, destination: str,
directories: list[str], files: list[str],
@ -317,6 +412,7 @@ def restore_archive(archive_path: str, destination: str,
locations=locations_all)
@reraise_known_errors
@privileged
def restore_exported_archive(path: str, directories: list[str],
files: list[str]):
@ -339,6 +435,7 @@ def _assert_app_id(app_id):
raise Exception('Invalid App ID')
@reraise_known_errors
@privileged
def dump_settings(app_id: str, settings: dict[str, int | float | bool | str]):
"""Dump an app's settings to a JSON file."""
@ -348,6 +445,7 @@ def dump_settings(app_id: str, settings: dict[str, int | float | bool | str]):
settings_path.write_text(json.dumps(settings))
@reraise_known_errors
@privileged
def load_settings(app_id: str) -> dict[str, int | float | bool | str]:
"""Load an app's settings from a JSON file."""

View File

@ -6,7 +6,6 @@ import contextlib
import io
import logging
import os
import re
import subprocess
from uuid import uuid1
@ -21,54 +20,6 @@ from .schedule import Schedule
logger = logging.getLogger(__name__)
# known errors that come up when remotely accessing a borg repository
# 'errors' are error strings to look for in the stacktrace.
KNOWN_ERRORS = [
{
'errors': ['subprocess.TimeoutExpired'],
'message':
_('Connection refused - make sure you provided correct '
'credentials and the server is running.'),
'raise_as':
errors.BorgError,
},
{
'errors': ['Connection refused'],
'message': _('Connection refused'),
'raise_as': errors.BorgError,
},
{
'errors': [
'not a valid repository', 'does not exist', 'FileNotFoundError'
],
'message': _('Repository not found'),
'raise_as': errors.BorgRepositoryDoesNotExistError,
},
{
'errors': ['passphrase supplied in .* is incorrect'],
'message': _('Incorrect encryption passphrase'),
'raise_as': errors.BorgError,
},
{
'errors': ['Connection reset by peer'],
'message': _('SSH access denied'),
'raise_as': errors.SshfsError,
},
{
'errors': ['There is already something at'],
'message':
_('Repository path is neither empty nor '
'is an existing backups repository.'),
'raise_as':
errors.BorgError,
},
{
'errors': ['A repository already exists at'],
'message': None,
'raise_as': errors.BorgRepositoryExists,
},
]
class BaseBorgRepository(abc.ABC):
"""Base class for all kinds of Borg repositories."""
@ -135,10 +86,8 @@ class BaseBorgRepository(abc.ABC):
def get_info(self):
"""Return Borg information about a repository."""
with self._handle_errors():
output = privileged.info(self.borg_path,
self._get_encryption_passpharse())
output = privileged.info(self.borg_path,
self._get_encryption_passpharse())
if output['encryption']['mode'] == 'none' and \
self._get_encryption_data():
raise errors.BorgUnencryptedRepository(
@ -170,9 +119,8 @@ class BaseBorgRepository(abc.ABC):
def list_archives(self):
"""Return list of archives in this repository."""
with self._handle_errors():
archives = privileged.list_repo(
self.borg_path, self._get_encryption_passpharse())['archives']
archives = privileged.list_repo(
self.borg_path, self._get_encryption_passpharse())['archives']
return sorted(archives, key=lambda archive: archive['start'],
reverse=True)
@ -187,9 +135,8 @@ class BaseBorgRepository(abc.ABC):
def delete_archive(self, archive_name):
"""Delete an archive with given name from this repository."""
archive_path = self._get_archive_path(archive_name)
with self._handle_errors():
privileged.delete_archive(archive_path,
self._get_encryption_passpharse())
privileged.delete_archive(archive_path,
self._get_encryption_passpharse())
def initialize(self):
"""Initialize / create a borg repository."""
@ -199,9 +146,8 @@ class BaseBorgRepository(abc.ABC):
encryption = 'repokey'
try:
with self._handle_errors():
privileged.init(self.borg_path, encryption,
self._get_encryption_passpharse())
privileged.init(self.borg_path, encryption,
self._get_encryption_passpharse())
except errors.BorgRepositoryExists:
pass
@ -215,14 +161,6 @@ class BaseBorgRepository(abc.ABC):
return {}
@contextlib.contextmanager
def _handle_errors(self):
"""Parse exceptions into more specific ones."""
try:
yield
except Exception as exception:
self.reraise_known_error(exception)
def _get_encryption_passpharse(self):
"""Return encryption passphrase or raise an exception."""
for key in self.credentials.keys():
@ -256,10 +194,9 @@ class BaseBorgRepository(abc.ABC):
return chunk
with self._handle_errors():
proc, read_fd, input_ = privileged.export_tar(
self._get_archive_path(archive_name),
self._get_encryption_passpharse(), _raw_output=True)
proc, read_fd, input_ = privileged.export_tar(
self._get_archive_path(archive_name),
self._get_encryption_passpharse(), _raw_output=True)
os.close(read_fd) # Don't use the pipe for communication, just stdout
proc.stdin.write(input_)
@ -272,19 +209,6 @@ class BaseBorgRepository(abc.ABC):
"""Return full borg path for an archive."""
return '::'.join([self.borg_path, archive_name])
@staticmethod
def reraise_known_error(err):
"""Look whether the caught error is known and reraise it accordingly"""
stdout = getattr(err, 'stdout', b'').decode()
stderr = getattr(err, 'stderr', b'').decode()
caught_error = str((err, err.args, stdout, stderr))
for known_error in KNOWN_ERRORS:
for error in known_error['errors']:
if re.search(error, caught_error):
raise known_error['raise_as'](known_error['message'])
raise err
def get_archive(self, name):
"""Return a specific archive from this repository with given name."""
for archive in self.list_archives():
@ -296,9 +220,8 @@ class BaseBorgRepository(abc.ABC):
def get_archive_apps(self, archive_name):
"""Get list of apps included in an archive."""
archive_path = self._get_archive_path(archive_name)
with self._handle_errors():
return privileged.get_archive_apps(
archive_path, self._get_encryption_passpharse())
return privileged.get_archive_apps(archive_path,
self._get_encryption_passpharse())
def restore_archive(self, archive_name, app_ids=None):
"""Restore an archive from this repository to the system."""
@ -424,8 +347,7 @@ class SshBorgRepository(BaseBorgRepository):
@property
def is_mounted(self):
"""Return whether remote path is mounted locally."""
with self._handle_errors():
return privileged.is_mounted(self._mountpoint)
return privileged.is_mounted(self._mountpoint)
def initialize(self):
"""Initialize the repository after mounting the target directory."""
@ -448,16 +370,14 @@ class SshBorgRepository(BaseBorgRepository):
'ssh_keyfile']:
kwargs['ssh_keyfile'] = self.credentials['ssh_keyfile']
with self._handle_errors():
privileged.mount(self._mountpoint, self._path, **kwargs)
privileged.mount(self._mountpoint, self._path, **kwargs)
def umount(self):
"""Unmount the remote path that was mounted locally using sshfs."""
if not self.is_mounted:
return
with self._handle_errors():
privileged.umount(self._mountpoint)
privileged.umount(self._mountpoint)
def _umount_ignore_errors(self):
"""Run unmount operation and ignore any exceptions thrown."""