diff --git a/NEWS b/NEWS index 1be3d09cd..549efaacc 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,8 @@ +1.5.1.dev0 + * #289: Tired of looking up the latest successful archive name in order to pass it to borgmatic + actions? Me too. Now you can specify "--archive latest" to all actions that accept an archive + flag. + 1.5.0 * #245: Monitor backups with PagerDuty hook integration. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook diff --git a/borgmatic/borg/list.py b/borgmatic/borg/list.py index 845f28840..cc9fdd76f 100644 --- a/borgmatic/borg/list.py +++ b/borgmatic/borg/list.py @@ -11,6 +11,42 @@ logger = logging.getLogger(__name__) BORG_EXCLUDE_CHECKPOINTS_GLOB = '*[0123456789]' +def resolve_archive_name(repository, archive, storage_config, local_path='borg', remote_path=None): + ''' + Given a local or remote repository path, an archive name, a storage config dict, a local Borg + path, and a remote Borg path, simply return the archive name. But if the archive name is + "latest", then instead introspect the repository for the latest successful (non-checkpoint) + archive, and return its name. + + Raise ValueError if "latest" is given but there are no archives in the repository. + ''' + if archive != "latest": + return archive + + lock_wait = storage_config.get('lock_wait', None) + + full_command = ( + (local_path, 'list') + + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + + make_flags('remote-path', remote_path) + + make_flags('lock-wait', lock_wait) + + make_flags('glob-archives', BORG_EXCLUDE_CHECKPOINTS_GLOB) + + make_flags('last', 1) + + ('--short', repository) + ) + + output = execute_command(full_command, output_log_level=None, error_on_warnings=False) + try: + latest_archive = output.strip().splitlines()[-1] + except IndexError: + raise ValueError('No archives found in the repository') + + logger.debug('{}: Latest archive is {}'.format(repository, latest_archive)) + + return latest_archive + + def list_archives(repository, storage_config, list_arguments, local_path='borg', remote_path=None): ''' Given a local or remote repository path, a storage config dict, and the arguments to the list diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index cde8539e2..c6f14eebd 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -323,7 +323,9 @@ def parse_arguments(*unparsed_arguments): '--repository', help='Path of repository to extract, defaults to the configured repository if there is only one', ) - extract_group.add_argument('--archive', help='Name of archive to extract', required=True) + extract_group.add_argument( + '--archive', help='Name of archive to extract (or "latest")', required=True + ) extract_group.add_argument( '--path', '--restore-path', @@ -361,7 +363,7 @@ def parse_arguments(*unparsed_arguments): '--repository', help='Path of repository to use, defaults to the configured repository if there is only one', ) - mount_group.add_argument('--archive', help='Name of archive to mount') + mount_group.add_argument('--archive', help='Name of archive to mount (or "latest")') mount_group.add_argument( '--mount-point', metavar='PATH', @@ -415,7 +417,9 @@ def parse_arguments(*unparsed_arguments): '--repository', help='Path of repository to restore from, defaults to the configured repository if there is only one', ) - restore_group.add_argument('--archive', help='Name of archive to restore from', required=True) + restore_group.add_argument( + '--archive', help='Name of archive to restore from (or "latest")', required=True + ) restore_group.add_argument( '--database', metavar='NAME', @@ -446,7 +450,7 @@ def parse_arguments(*unparsed_arguments): '--repository', help='Path of repository to list, defaults to the configured repository if there is only one', ) - list_group.add_argument('--archive', help='Name of archive to list') + list_group.add_argument('--archive', help='Name of archive to list (or "latest")') list_group.add_argument( '--path', metavar='PATH', @@ -508,7 +512,7 @@ def parse_arguments(*unparsed_arguments): '--repository', help='Path of repository to show info for, defaults to the configured repository if there is only one', ) - info_group.add_argument('--archive', help='Name of archive to show info for') + info_group.add_argument('--archive', help='Name of archive to show info for (or "latest")') info_group.add_argument( '--json', dest='json', default=False, action='store_true', help='Output results as JSON' ) diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 8a2489cd2..5688ae9bc 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -1,4 +1,5 @@ import collections +import copy import json import logging import os @@ -297,7 +298,9 @@ def run_actions( borg_extract.extract_archive( global_arguments.dry_run, repository, - arguments['extract'].archive, + borg_list.resolve_archive_name( + repository, arguments['extract'].archive, storage, local_path, remote_path + ), arguments['extract'].paths, location, storage, @@ -319,7 +322,9 @@ def run_actions( borg_mount.mount_archive( repository, - arguments['mount'].archive, + borg_list.resolve_archive_name( + repository, arguments['mount'].archive, storage, local_path, remote_path + ), arguments['mount'].mount_point, arguments['mount'].paths, arguments['mount'].foreground, @@ -355,7 +360,9 @@ def run_actions( borg_extract.extract_archive( global_arguments.dry_run, repository, - arguments['restore'].archive, + borg_list.resolve_archive_name( + repository, arguments['restore'].archive, storage, local_path, remote_path + ), dump.convert_glob_patterns_to_borg_patterns( dump.flatten_dump_patterns(dump_patterns, restore_names) ), @@ -395,12 +402,16 @@ def run_actions( if arguments['list'].repository is None or validate.repositories_match( repository, arguments['list'].repository ): - if not arguments['list'].json: + list_arguments = copy.copy(arguments['list']) + if not list_arguments.json: logger.warning('{}: Listing archives'.format(repository)) + list_arguments.archive = borg_list.resolve_archive_name( + repository, list_arguments.archive, storage, local_path, remote_path + ) json_output = borg_list.list_archives( repository, storage, - list_arguments=arguments['list'], + list_arguments=list_arguments, local_path=local_path, remote_path=remote_path, ) @@ -410,12 +421,16 @@ def run_actions( if arguments['info'].repository is None or validate.repositories_match( repository, arguments['info'].repository ): - if not arguments['info'].json: + info_arguments = copy.copy(arguments['info']) + if not info_arguments.json: logger.warning('{}: Displaying summary info for archives'.format(repository)) + info_arguments.archive = borg_list.resolve_archive_name( + repository, info_arguments.archive, storage, local_path, remote_path + ) json_output = borg_info.display_archives_info( repository, storage, - info_arguments=arguments['info'], + info_arguments=info_arguments, local_path=local_path, remote_path=remote_path, ) diff --git a/setup.py b/setup.py index 2cce11ab3..98bc64dd1 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -VERSION = '1.5.0' +VERSION = '1.5.1.dev0' setup( diff --git a/tests/unit/borg/test_list.py b/tests/unit/borg/test_list.py index 40ad1229c..3445d38ca 100644 --- a/tests/unit/borg/test_list.py +++ b/tests/unit/borg/test_list.py @@ -7,6 +7,110 @@ from borgmatic.borg import list as module from ..test_verbosity import insert_logging_mock +BORG_LIST_LATEST_ARGUMENTS = ( + '--glob-archives', + module.BORG_EXCLUDE_CHECKPOINTS_GLOB, + '--last', + '1', + '--short', + 'repo', +) + + +def test_resolve_archive_name_passes_through_non_latest_archive_name(): + archive = 'myhost-2030-01-01T14:41:17.647620' + + assert module.resolve_archive_name('repo', archive, storage_config={}) == archive + + +def test_resolve_archive_name_calls_borg_with_parameters(): + expected_archive = 'archive-name' + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'list') + BORG_LIST_LATEST_ARGUMENTS, + output_log_level=None, + error_on_warnings=False, + ).and_return(expected_archive + '\n') + + assert module.resolve_archive_name('repo', 'latest', storage_config={}) == expected_archive + + +def test_resolve_archive_name_with_log_info_calls_borg_with_info_parameter(): + expected_archive = 'archive-name' + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'list', '--info') + BORG_LIST_LATEST_ARGUMENTS, + output_log_level=None, + error_on_warnings=False, + ).and_return(expected_archive + '\n') + insert_logging_mock(logging.INFO) + + assert module.resolve_archive_name('repo', 'latest', storage_config={}) == expected_archive + + +def test_resolve_archive_name_with_log_debug_calls_borg_with_debug_parameter(): + expected_archive = 'archive-name' + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'list', '--debug', '--show-rc') + BORG_LIST_LATEST_ARGUMENTS, + output_log_level=None, + error_on_warnings=False, + ).and_return(expected_archive + '\n') + insert_logging_mock(logging.DEBUG) + + assert module.resolve_archive_name('repo', 'latest', storage_config={}) == expected_archive + + +def test_resolve_archive_name_with_local_path_calls_borg_via_local_path(): + expected_archive = 'archive-name' + flexmock(module).should_receive('execute_command').with_args( + ('borg1', 'list') + BORG_LIST_LATEST_ARGUMENTS, + output_log_level=None, + error_on_warnings=False, + ).and_return(expected_archive + '\n') + + assert ( + module.resolve_archive_name('repo', 'latest', storage_config={}, local_path='borg1') + == expected_archive + ) + + +def test_resolve_archive_name_with_remote_path_calls_borg_with_remote_path_parameters(): + expected_archive = 'archive-name' + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'list', '--remote-path', 'borg1') + BORG_LIST_LATEST_ARGUMENTS, + output_log_level=None, + error_on_warnings=False, + ).and_return(expected_archive + '\n') + + assert ( + module.resolve_archive_name('repo', 'latest', storage_config={}, remote_path='borg1') + == expected_archive + ) + + +def test_resolve_archive_name_without_archives_raises(): + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'list') + BORG_LIST_LATEST_ARGUMENTS, + output_log_level=None, + error_on_warnings=False, + ).and_return('') + + with pytest.raises(ValueError): + module.resolve_archive_name('repo', 'latest', storage_config={}) + + +def test_resolve_archive_name_with_lock_wait_calls_borg_with_lock_wait_parameters(): + expected_archive = 'archive-name' + + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'list', '--lock-wait', 'okay') + BORG_LIST_LATEST_ARGUMENTS, + output_log_level=None, + error_on_warnings=False, + ).and_return(expected_archive + '\n') + + assert ( + module.resolve_archive_name('repo', 'latest', storage_config={'lock_wait': 'okay'}) + == expected_archive + ) + def test_list_archives_calls_borg_with_parameters(): flexmock(module).should_receive('execute_command').with_args(