From ba8fbe7a44d07481dc8bd7d5a3b07f2dd4d4e883 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Tue, 4 Oct 2022 13:42:18 -0700 Subject: [PATCH] Add "break-lock" action for removing any repository and cache locks leftover from Borg aborting (#357). --- NEWS | 2 + borgmatic/borg/break_lock.py | 31 ++++++++++++ borgmatic/borg/create.py | 1 + borgmatic/commands/arguments.py | 14 ++++++ borgmatic/commands/borgmatic.py | 13 +++++ docs/Dockerfile | 2 +- tests/unit/borg/test_break_lock.py | 70 +++++++++++++++++++++++++++ tests/unit/commands/test_borgmatic.py | 25 ++++++++++ 8 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 borgmatic/borg/break_lock.py create mode 100644 tests/unit/borg/test_break_lock.py diff --git a/NEWS b/NEWS index 62002e8..19017c0 100644 --- a/NEWS +++ b/NEWS @@ -1,4 +1,6 @@ 1.7.3.dev0 + * #357: Add "break-lock" action for removing any repository and cache locks leftover from Borg + aborting. * #587: When the "read_special" option is true or database hooks are enabled, auto-exclude special files for a "create" action to prevent Borg from hanging. * #587: Warn when ignoring a configured "read_special" value of false, as true is needed when diff --git a/borgmatic/borg/break_lock.py b/borgmatic/borg/break_lock.py new file mode 100644 index 0000000..820b1c5 --- /dev/null +++ b/borgmatic/borg/break_lock.py @@ -0,0 +1,31 @@ +import logging + +from borgmatic.borg import environment, flags +from borgmatic.execute import execute_command + +logger = logging.getLogger(__name__) + + +def break_lock( + repository, storage_config, local_borg_version, local_path='borg', remote_path=None, +): + ''' + Given a local or remote repository path, a storage configuration dict, the local Borg version, + and optional local and remote Borg paths, break any repository and cache locks leftover from Borg + aborting. + ''' + umask = storage_config.get('umask', None) + lock_wait = storage_config.get('lock_wait', None) + + full_command = ( + (local_path, 'break-lock') + + (('--remote-path', remote_path) if remote_path else ()) + + (('--umask', str(umask)) if umask else ()) + + (('--lock-wait', str(lock_wait)) if lock_wait else ()) + + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + + flags.make_repository_flags(repository, local_borg_version) + ) + + borg_environment = environment.make_environment(storage_config) + execute_command(full_command, borg_local_path=local_path, extra_environment=borg_environment) diff --git a/borgmatic/borg/create.py b/borgmatic/borg/create.py index 2f9aea7..ca6b6a6 100644 --- a/borgmatic/borg/create.py +++ b/borgmatic/borg/create.py @@ -407,6 +407,7 @@ def create_archive( # If read_special is enabled, exclude files that might cause Borg to hang. if read_special: + logger.debug(f'{repository}: Collecting special file paths') special_file_paths = collect_special_file_paths( create_command, local_path, diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index e96029d..9499e95 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -19,6 +19,7 @@ SUBPARSER_ALIASES = { 'rinfo': [], 'info': ['-i'], 'transfer': [], + 'break-lock': [], 'borg': [], } @@ -774,6 +775,19 @@ def make_parsers(): ) info_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') + break_lock_parser = subparsers.add_parser( + 'break-lock', + aliases=SUBPARSER_ALIASES['break-lock'], + help='Break the repository and cache locks left behind by Borg aborting', + description='Break Borg repository and cache locks left behind by Borg aborting', + add_help=False, + ) + break_lock_group = break_lock_parser.add_argument_group('break-lock arguments') + break_lock_group.add_argument( + '--repository', + help='Path of repository to break the lock for, defaults to the configured repository if there is only one', + ) + borg_parser = subparsers.add_parser( 'borg', aliases=SUBPARSER_ALIASES['borg'], diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index c485adc..aef683c 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -13,6 +13,7 @@ import pkg_resources import borgmatic.commands.completion from borgmatic.borg import borg as borg_borg +from borgmatic.borg import break_lock as borg_break_lock from borgmatic.borg import check as borg_check from borgmatic.borg import compact as borg_compact from borgmatic.borg import create as borg_create @@ -731,6 +732,18 @@ def run_actions( ) if json_output: # pragma: nocover yield json.loads(json_output) + if 'break-lock' in arguments: + if arguments['break-lock'].repository is None or validate.repositories_match( + repository, arguments['break-lock'].repository + ): + logger.warning(f'{repository}: Breaking repository and cache locks') + borg_break_lock.break_lock( + repository, + storage, + local_borg_version, + local_path=local_path, + remote_path=remote_path, + ) if 'borg' in arguments: if arguments['borg'].repository is None or validate.repositories_match( repository, arguments['borg'].repository diff --git a/docs/Dockerfile b/docs/Dockerfile index 8bdde7c..5827193 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -4,7 +4,7 @@ COPY . /app RUN apk add --no-cache py3-pip py3-ruamel.yaml py3-ruamel.yaml.clib RUN pip install --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml RUN borgmatic --help > /command-line.txt \ - && for action in rcreate transfer prune compact create check extract export-tar mount umount restore rlist list rinfo info borg; do \ + && for action in rcreate transfer prune compact create check extract export-tar mount umount restore rlist list rinfo info break-lock borg; do \ echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \ && borgmatic "$action" --help >> /command-line.txt; done diff --git a/tests/unit/borg/test_break_lock.py b/tests/unit/borg/test_break_lock.py new file mode 100644 index 0000000..0663f93 --- /dev/null +++ b/tests/unit/borg/test_break_lock.py @@ -0,0 +1,70 @@ +import logging + +from flexmock import flexmock + +from borgmatic.borg import break_lock as module + +from ..test_verbosity import insert_logging_mock + + +def insert_execute_command_mock(command): + flexmock(module.environment).should_receive('make_environment') + flexmock(module).should_receive('execute_command').with_args( + command, borg_local_path='borg', extra_environment=None, + ).once() + + +def test_break_lock_calls_borg_with_required_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_execute_command_mock(('borg', 'break-lock', 'repo')) + + module.break_lock( + repository='repo', storage_config={}, local_borg_version='1.2.3', + ) + + +def test_break_lock_calls_borg_with_remote_path_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_execute_command_mock(('borg', 'break-lock', '--remote-path', 'borg1', 'repo')) + + module.break_lock( + repository='repo', storage_config={}, local_borg_version='1.2.3', remote_path='borg1', + ) + + +def test_break_lock_calls_borg_with_umask_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_execute_command_mock(('borg', 'break-lock', '--umask', '0770', 'repo')) + + module.break_lock( + repository='repo', storage_config={'umask': '0770'}, local_borg_version='1.2.3', + ) + + +def test_break_lock_calls_borg_with_lock_wait_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_execute_command_mock(('borg', 'break-lock', '--lock-wait', '5', 'repo')) + + module.break_lock( + repository='repo', storage_config={'lock_wait': '5'}, local_borg_version='1.2.3', + ) + + +def test_break_lock_with_log_info_calls_borg_with_info_parameter(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_execute_command_mock(('borg', 'break-lock', '--info', 'repo')) + insert_logging_mock(logging.INFO) + + module.break_lock( + repository='repo', storage_config={}, local_borg_version='1.2.3', + ) + + +def test_break_lock_with_log_debug_calls_borg_with_debug_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_execute_command_mock(('borg', 'break-lock', '--debug', '--show-rc', 'repo')) + insert_logging_mock(logging.DEBUG) + + module.break_lock( + repository='repo', storage_config={}, local_borg_version='1.2.3', + ) diff --git a/tests/unit/commands/test_borgmatic.py b/tests/unit/commands/test_borgmatic.py index d412e87..64d75d3 100644 --- a/tests/unit/commands/test_borgmatic.py +++ b/tests/unit/commands/test_borgmatic.py @@ -712,6 +712,31 @@ def test_run_actions_does_not_raise_for_info_action(): ) +def test_run_actions_does_not_raise_for_break_lock_action(): + flexmock(module.validate).should_receive('repositories_match').and_return(True) + flexmock(module.borg_break_lock).should_receive('break_lock') + arguments = { + 'global': flexmock(monitoring_verbosity=1, dry_run=False), + 'break-lock': flexmock(repository=flexmock()), + } + + list( + module.run_actions( + arguments=arguments, + config_filename='test.yaml', + location={'repositories': ['repo']}, + storage={}, + retention={}, + consistency={}, + hooks={}, + local_path=None, + remote_path=None, + local_borg_version=None, + repository_path='repo', + ) + ) + + def test_run_actions_does_not_raise_for_borg_action(): flexmock(module.validate).should_receive('repositories_match').and_return(True) flexmock(module.borg_rlist).should_receive('resolve_archive_name').and_return(flexmock())