From a6b16920e23c16373184c05c10b0132f981c27fe Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Mon, 23 Dec 2024 22:53:44 -0800 Subject: [PATCH] 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 Reviewed-by: James Valleroy --- plinth/modules/backups/privileged.py | 98 +++++++++++++++++++++++ plinth/modules/backups/repository.py | 112 ++++----------------------- 2 files changed, 114 insertions(+), 96 deletions(-) diff --git a/plinth/modules/backups/privileged.py b/plinth/modules/backups/privileged.py index be0f2558a..a58d3ff16 100644 --- a/plinth/modules/backups/privileged.py +++ b/plinth/modules/backups/privileged.py @@ -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.""" diff --git a/plinth/modules/backups/repository.py b/plinth/modules/backups/repository.py index a135ff495..4d742f21c 100644 --- a/plinth/modules/backups/repository.py +++ b/plinth/modules/backups/repository.py @@ -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."""