diff --git a/NEWS b/NEWS index b0da2ca1..6a464f94 100644 --- a/NEWS +++ b/NEWS @@ -21,6 +21,15 @@ all repositories in the configuration file. * Add support for Borg 2's "rclone://" repository URLs, so you can backup to 70+ cloud storage services whether or not they support Borg explicitly. + * When using Borg 2, default the "archive_name_format" option to just "{hostname}", as Borg 2 does + not require unique archive names; identical archive names form a common "series" that can be + targeted together. See the Borg 2 documentation for more information: + https://borgbackup.readthedocs.io/en/2.0.0b12/changes.html#borg-1-2-x-1-4-x-to-borg-2-0 + * Update the "--match-archives" flag in all actions (and the "--archive" flag in select actions) to + support a Borg 2 series name as its value. + * Update the "--match-archives" and "--archive" flags in all actions to support a Borg 2 archive + hash as its value. + * Add a "--match-archives" flag to the "prune" action. 1.8.14 * #896: Fix an error in borgmatic rcreate/init on an empty repository directory with Borg 1.4. diff --git a/borgmatic/borg/create.py b/borgmatic/borg/create.py index 5df80358..113c8ca8 100644 --- a/borgmatic/borg/create.py +++ b/borgmatic/borg/create.py @@ -399,7 +399,9 @@ def make_base_create_command( lock_wait = config.get('lock_wait', None) list_filter_flags = make_list_filter_flags(local_borg_version, dry_run) files_cache = config.get('files_cache') - archive_name_format = config.get('archive_name_format', flags.DEFAULT_ARCHIVE_NAME_FORMAT) + archive_name_format = config.get( + 'archive_name_format', flags.get_default_archive_name_format(local_borg_version) + ) extra_borg_options = config.get('extra_borg_options', {}).get('create', '') if feature.available(feature.Feature.ATIME, local_borg_version): diff --git a/borgmatic/borg/feature.py b/borgmatic/borg/feature.py index eb73c009..a6462e13 100644 --- a/borgmatic/borg/feature.py +++ b/borgmatic/borg/feature.py @@ -16,6 +16,7 @@ class Feature(Enum): REPO_DELETE = 10 MATCH_ARCHIVES = 11 EXCLUDED_FILES_MINUS = 12 + ARCHIVE_SERIES = 13 FEATURE_TO_MINIMUM_BORG_VERSION = { @@ -31,6 +32,7 @@ FEATURE_TO_MINIMUM_BORG_VERSION = { Feature.REPO_DELETE: parse('2.0.0a2'), # borg repo-delete Feature.MATCH_ARCHIVES: parse('2.0.0b3'), # borg --match-archives Feature.EXCLUDED_FILES_MINUS: parse('2.0.0b5'), # --list --filter uses "-" for excludes + Feature.ARCHIVE_SERIES: parse('2.0.0b11'), # identically named archives form a series } diff --git a/borgmatic/borg/flags.py b/borgmatic/borg/flags.py index 23b9c356..97bda27d 100644 --- a/borgmatic/borg/flags.py +++ b/borgmatic/borg/flags.py @@ -50,6 +50,9 @@ def make_repository_flags(repository_path, local_borg_version): ) + (repository_path,) +ARCHIVE_HASH_PATTERN = re.compile('[0-9a-fA-F]{8,}$') + + def make_repository_archive_flags(repository_path, archive, local_borg_version): ''' Given the path of a Borg repository, an archive name or pattern, and the local Borg version, @@ -57,20 +60,41 @@ def make_repository_archive_flags(repository_path, archive, local_borg_version): and archive. ''' return ( - ('--repo', repository_path, archive) + ( + '--repo', + repository_path, + ( + f'aid:{archive}' + if feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version) + and ARCHIVE_HASH_PATTERN.match(archive) + and not archive.startswith('aid:') + else archive + ), + ) if feature.available(feature.Feature.SEPARATE_REPOSITORY_ARCHIVE, local_borg_version) else (f'{repository_path}::{archive}',) ) -DEFAULT_ARCHIVE_NAME_FORMAT = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}' # noqa: FS003 +DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}' # noqa: FS003 +DEFAULT_ARCHIVE_NAME_FORMAT_WITH_SERIES = '{hostname}' # noqa: FS003 + + +def get_default_archive_name_format(local_borg_version): + ''' + Given the local Borg version, return the corresponding default archive name format. + ''' + if feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version): + return DEFAULT_ARCHIVE_NAME_FORMAT_WITH_SERIES + + return DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES def make_match_archives_flags( match_archives, archive_name_format, local_borg_version, - default_archive_name_format=DEFAULT_ARCHIVE_NAME_FORMAT, + default_archive_name_format=None, ): ''' Return match archives flags based on the given match archives value, if any. If it isn't set, @@ -83,12 +107,23 @@ def make_match_archives_flags( return () if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version): + if ( + feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version) + and ARCHIVE_HASH_PATTERN.match(match_archives) + and not match_archives.startswith('aid:') + ): + return ('--match-archives', f'aid:{match_archives}') + return ('--match-archives', match_archives) else: return ('--glob-archives', re.sub(r'^sh:', '', match_archives)) derived_match_archives = re.sub( - r'\{(now|utcnow|pid)([:%\w\.-]*)\}', '*', archive_name_format or default_archive_name_format + r'\{(now|utcnow|pid)([:%\w\.-]*)\}', + '*', + archive_name_format + or default_archive_name_format + or get_default_archive_name_format(local_borg_version), ) if derived_match_archives == '*': diff --git a/borgmatic/borg/prune.py b/borgmatic/borg/prune.py index 39db1285..c79a6a7b 100644 --- a/borgmatic/borg/prune.py +++ b/borgmatic/borg/prune.py @@ -8,9 +8,10 @@ from borgmatic.execute import execute_command logger = logging.getLogger(__name__) -def make_prune_flags(config, local_borg_version): +def make_prune_flags(config, prune_arguments, local_borg_version): ''' - Given a configuration dict mapping from option name to value, transform it into an sequence of + Given a configuration dict mapping from option name to value, prune arguments as an + argparse.Namespace instance, and the local Borg version, produce a corresponding sequence of command-line flags. For example, given a retention config of: @@ -40,7 +41,7 @@ def make_prune_flags(config, local_borg_version): if prefix else ( flags.make_match_archives_flags( - config.get('match_archives'), + prune_arguments.match_archives or config.get('match_archives'), config.get('archive_name_format'), local_borg_version, ) @@ -69,7 +70,7 @@ def prune_archives( full_command = ( (local_path, 'prune') - + make_prune_flags(config, local_borg_version) + + make_prune_flags(config, prune_arguments, local_borg_version) + (('--remote-path', remote_path) if remote_path else ()) + (('--umask', str(umask)) if umask else ()) + (('--log-json',) if global_arguments.log_json else ()) @@ -78,7 +79,7 @@ def prune_archives( + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + flags.make_flags_from_arguments( prune_arguments, - excludes=('repository', 'stats', 'list_archives'), + excludes=('repository', 'match_archives', 'stats', 'list_archives'), ) + (('--list',) if prune_arguments.list_archives else ()) + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index b01632da..76531bab 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -469,7 +469,7 @@ def make_parsers(): ) transfer_group.add_argument( '--archive', - help='Name of single archive to transfer (or "latest"), defaults to transferring all archives', + help='Name or hash of a single archive to transfer (or "latest"), defaults to transferring all archives', ) transfer_group.add_argument( '--upgrader', @@ -486,7 +486,7 @@ def make_parsers(): '--match-archives', '--glob-archives', metavar='PATTERN', - help='Only transfer archives with names matching this pattern', + help='Only transfer archives with names, hashes, or series matching this pattern', ) transfer_group.add_argument( '--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys' @@ -535,6 +535,13 @@ def make_parsers(): '--repository', help='Path of specific existing repository to prune (must be already specified in a borgmatic configuration file), quoted globs supported', ) + prune_group.add_argument( + '-a', + '--match-archives', + '--glob-archives', + metavar='PATTERN', + help='When pruning, only consider archives with names, hashes, or series matching this pattern', + ) prune_group.add_argument( '--stats', dest='stats', @@ -673,7 +680,7 @@ def make_parsers(): '--match-archives', '--glob-archives', metavar='PATTERN', - help='Only check archives with names matching this pattern', + help='Only check archives with names, hashes, or series matching this pattern', ) check_group.add_argument( '--only', @@ -705,7 +712,7 @@ def make_parsers(): ) delete_group.add_argument( '--archive', - help='Archive to delete', + help='Archive name, hash, or series to delete', ) delete_group.add_argument( '--list', @@ -749,7 +756,7 @@ def make_parsers(): '--match-archives', '--glob-archives', metavar='PATTERN', - help='Only delete archives matching this pattern', + help='Only delete archives with names, hashes, or series matching this pattern', ) delete_group.add_argument( '--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys' @@ -795,7 +802,7 @@ def make_parsers(): help='Path of repository to extract, defaults to the configured repository if there is only one, quoted globs supported', ) extract_group.add_argument( - '--archive', help='Name of archive to extract (or "latest")', required=True + '--archive', help='Name or hash of a single archive to extract (or "latest")', required=True ) extract_group.add_argument( '--path', @@ -863,7 +870,7 @@ def make_parsers(): ) config_bootstrap_group.add_argument( '--archive', - help='Name of archive to extract config files from, defaults to "latest"', + help='Name or hash of a single archive to extract config files from, defaults to "latest"', default='latest', ) config_bootstrap_group.add_argument( @@ -955,7 +962,7 @@ def make_parsers(): help='Path of repository to export from, defaults to the configured repository if there is only one, quoted globs supported', ) export_tar_group.add_argument( - '--archive', help='Name of archive to export (or "latest")', required=True + '--archive', help='Name or hash of a single archive to export (or "latest")', required=True ) export_tar_group.add_argument( '--path', @@ -1000,7 +1007,9 @@ def make_parsers(): '--repository', help='Path of repository to use, defaults to the configured repository if there is only one, quoted globs supported', ) - mount_group.add_argument('--archive', help='Name of archive to mount (or "latest")') + mount_group.add_argument( + '--archive', help='Name or hash of a single archive to mount (or "latest")' + ) mount_group.add_argument( '--mount-point', metavar='PATH', @@ -1120,7 +1129,9 @@ def make_parsers(): help='Path of repository to restore from, defaults to the configured repository if there is only one, quoted globs supported', ) restore_group.add_argument( - '--archive', help='Name of archive to restore from (or "latest")', required=True + '--archive', + help='Name or hash of a single archive to restore from (or "latest")', + required=True, ) restore_group.add_argument( '--data-source', @@ -1188,7 +1199,7 @@ def make_parsers(): '--match-archives', '--glob-archives', metavar='PATTERN', - help='Only list archive names matching this pattern', + help='Only list archive names, hashes, or series matching this pattern', ) repo_list_group.add_argument( '--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys' @@ -1235,7 +1246,9 @@ def make_parsers(): '--repository', help='Path of repository containing archive to list, defaults to the configured repositories, quoted globs supported', ) - list_group.add_argument('--archive', help='Name of the archive to list (or "latest")') + list_group.add_argument( + '--archive', help='Name or hash of a single archive to list (or "latest")' + ) list_group.add_argument( '--path', metavar='PATH', @@ -1321,7 +1334,9 @@ def make_parsers(): '--repository', help='Path of repository containing archive to show info for, defaults to the configured repository if there is only one, quoted globs supported', ) - info_group.add_argument('--archive', help='Name of archive to show info for (or "latest")') + info_group.add_argument( + '--archive', help='Archive name, hash, or series to show info for (or "latest")' + ) info_group.add_argument( '--json', dest='json', default=False, action='store_true', help='Output results as JSON' ) @@ -1335,7 +1350,7 @@ def make_parsers(): '--match-archives', '--glob-archives', metavar='PATTERN', - help='Only show info for archive names matching this pattern', + help='Only show info for archive names, hashes, or series matching this pattern', ) info_group.add_argument( '--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys' @@ -1460,7 +1475,9 @@ def make_parsers(): '--repository', help='Path of repository to pass to Borg, defaults to the configured repositories, quoted globs supported', ) - borg_group.add_argument('--archive', help='Name of archive to pass to Borg (or "latest")') + borg_group.add_argument( + '--archive', help='Archive name, hash, or series to pass to Borg (or "latest")' + ) borg_group.add_argument( '--', metavar='OPTION', diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index d2b080fc..9e901a32 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -397,11 +397,14 @@ properties: archive_name_format: type: string description: | - Name of the archive. Borg placeholders can be used. See the output - of "borg help placeholders" for details. Defaults to - "{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}". When running actions like - repo-list, info, or check, borgmatic automatically tries to match - only archives created with this name format. + Name of the archive to create. Borg placeholders can be used. See + the output of "borg help placeholders" for details. Defaults to + "{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}" with Borg 1 and + "{hostname}" with Borg 2, as Borg 2 does not require unique + archive names; identical archive names form a common "series" that + can be targeted together. When running actions like repo-list, + info, or check, borgmatic automatically tries to match only + archives created with this name format. example: "{hostname}-documents-{now}" match_archives: type: string diff --git a/docs/docker-compose.yaml b/docs/docker-compose.yaml index b90622f2..99b3d87e 100644 --- a/docs/docker-compose.yaml +++ b/docs/docker-compose.yaml @@ -11,7 +11,7 @@ services: ENVIRONMENT: development message: image: alpine - container_name: message + container_name: borgmatic-docs-message command: - sh - -c diff --git a/docs/how-to/backup-your-databases.md b/docs/how-to/backup-your-databases.md index 93ce0a7d..fe9afd61 100644 --- a/docs/how-to/backup-your-databases.md +++ b/docs/how-to/backup-your-databases.md @@ -336,15 +336,15 @@ borgmatic restore --archive host-2023-01-02T04:06:07.080910 (No borgmatic `restore` action? Upgrade borgmatic!) -With newer versions of borgmatic, you can simplify this to: +Or you can simplify this to: ```bash borgmatic restore --archive latest ``` -The `--archive` value is the name of the archive to restore from. This -restores all databases dumps that borgmatic originally backed up to that -archive. +The `--archive` value is the name of the archive or archive hash to restore +from. This restores all databases dumps that borgmatic originally backed up to +that archive. This is a destructive action! `borgmatic restore` replaces live databases by restoring dumps from the selected archive. So be very careful when and where diff --git a/docs/how-to/extract-a-backup.md b/docs/how-to/extract-a-backup.md index 5a9f6836..7bfdd8da 100644 --- a/docs/how-to/extract-a-backup.md +++ b/docs/how-to/extract-a-backup.md @@ -40,10 +40,10 @@ Or simplify this to: borgmatic extract --archive latest ``` -The `--archive` value is the name of the archive to extract. This extracts the -entire contents of the archive to the current directory, so make sure you're -in the right place before running the command—or see below about the -`--destination` flag. +The `--archive` value is the name of the archive or archive hash to extract. +This extracts the entire contents of the archive to the current directory, so +make sure you're in the right place before running the command—or see below +about the `--destination` flag. ## Repository selection @@ -131,6 +131,14 @@ Or use the "latest" value for the archive to mount the latest archive: borgmatic mount --archive latest --mount-point /mnt ``` +With Borg version 2.xYou can +provide a series name for the `--archive` value to mount multiple archives in +that series: + +```bash +borgmatic mount --archive seriesname --mount-point /mnt +``` + If you'd like to restrict the mounted filesystem to only particular paths from your archive, use the `--path` flag, similar to the `extract` action above. For instance: diff --git a/docs/how-to/make-per-application-backups.md b/docs/how-to/make-per-application-backups.md index a1a9381c..9ca2d07f 100644 --- a/docs/how-to/make-per-application-backups.md +++ b/docs/how-to/make-per-application-backups.md @@ -82,10 +82,14 @@ this option in the `storage:` section of your configuration. This example means that when borgmatic creates an archive, its name will start with the string `home-directories-` and end with a timestamp for its creation -time. If `archive_name_format` is unspecified, the default is +time. If `archive_name_format` is unspecified, the default with Borg 1 is `{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}`, meaning your system hostname plus a timestamp in a particular format. +With Borg version 2.xThe default +is just `{hostname}`, as Borg 2 does not require unique archive names; identical +archive names form a common "series" that can be targeted together. + ### Archive filtering @@ -129,10 +133,13 @@ archive_name_format: {hostname}-user-data-{now} match_archives: sh:myhost-user-data-* ``` -For Borg 1.x, use a shell pattern for the `match_archives` value and see the -[Borg patterns +With Borg version 1.xUse a shell +pattern for the `match_archives` value and see the [Borg patterns documentation](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns) -for more information. For Borg 2.x, see the [match archives +for more information. + +With Borg version 2.xSee the +[match archives documentation](https://borgbackup.readthedocs.io/en/2.0.0b12/usage/help.html#borg-help-match-archives). Some borgmatic command-line actions also have a `--match-archives` flag that diff --git a/tests/unit/borg/test_create.py b/tests/unit/borg/test_create.py index 6b381d49..55a65aea 100644 --- a/tests/unit/borg/test_create.py +++ b/tests/unit/borg/test_create.py @@ -581,6 +581,9 @@ def test_make_base_create_command_includes_patterns_file_in_borg_command(): None ) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') pattern_flags = ('--patterns-from', mock_pattern_file.name) @@ -631,6 +634,9 @@ def test_make_base_create_command_includes_sources_and_config_paths_in_borg_comm flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) @@ -676,6 +682,9 @@ def test_make_base_create_command_with_store_config_false_omits_config_files(): flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) @@ -719,6 +728,9 @@ def test_make_base_create_command_includes_exclude_patterns_in_borg_command(): mock_exclude_file = flexmock(name='/tmp/excludes') flexmock(module).should_receive('write_pattern_file').and_return(mock_exclude_file) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) @@ -795,6 +807,9 @@ def test_make_base_create_command_includes_configuration_option_as_command_flag( flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(feature_available) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) @@ -837,6 +852,9 @@ def test_make_base_create_command_includes_dry_run_in_borg_command(): flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) @@ -879,6 +897,9 @@ def test_make_base_create_command_includes_local_path_in_borg_command(): flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) @@ -921,6 +942,9 @@ def test_make_base_create_command_includes_remote_path_in_borg_command(): flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) @@ -963,6 +987,9 @@ def test_make_base_create_command_includes_log_json_in_borg_command(): flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) @@ -1004,6 +1031,9 @@ def test_make_base_create_command_includes_list_flags_in_borg_command(): flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) @@ -1046,6 +1076,9 @@ def test_make_base_create_command_with_stream_processes_ignores_read_special_fal flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) @@ -1095,6 +1128,9 @@ def test_make_base_create_command_with_stream_processes_and_read_special_true_sk flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) @@ -1141,6 +1177,9 @@ def test_make_base_create_command_with_non_matching_source_directories_glob_pass flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) @@ -1183,6 +1222,9 @@ def test_make_base_create_command_expands_glob_in_source_directories(): flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) @@ -1225,6 +1267,9 @@ def test_make_base_create_command_includes_archive_name_format_in_borg_command() flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) @@ -1255,7 +1300,52 @@ def test_make_base_create_command_includes_archive_name_format_in_borg_command() assert not exclude_file -def test_base_create_command_includes_archive_name_format_with_placeholders_in_borg_command(): +def test_make_base_create_command_includes_default_archive_name_format_in_borg_command(): + flexmock(module.borgmatic.config.options).should_receive('get_working_directory').and_return( + None + ) + flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) + flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) + flexmock(module).should_receive('map_directories_to_devices').and_return({}) + flexmock(module).should_receive('expand_directories').and_return(()) + flexmock(module).should_receive('pattern_root_directories').and_return([]) + flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) + flexmock(module).should_receive('expand_home_directories').and_return(()) + flexmock(module).should_receive('write_pattern_file').and_return(None) + flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) + flexmock(module.feature).should_receive('available').and_return(True) + flexmock(module).should_receive('ensure_files_readable') + flexmock(module).should_receive('make_pattern_flags').and_return(()) + flexmock(module).should_receive('make_exclude_flags').and_return(()) + flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( + ('repo::{hostname}',) + ) + + (create_flags, create_positional_arguments, pattern_file, exclude_file) = ( + module.make_base_create_command( + dry_run=False, + repository_path='repo', + config={ + 'source_directories': ['foo', 'bar'], + 'repositories': ['repo'], + }, + config_paths=['/tmp/test.yaml'], + local_borg_version='1.2.3', + global_arguments=flexmock(log_json=False), + borgmatic_source_directories=(), + ) + ) + + assert create_flags == ('borg', 'create') + assert create_positional_arguments == ('repo::{hostname}', 'foo', 'bar') + assert not pattern_file + assert not exclude_file + + +def test_make_base_create_command_includes_archive_name_format_with_placeholders_in_borg_command(): repository_archive_pattern = 'repo::Documents_{hostname}-{now}' # noqa: FS003 flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) @@ -1265,6 +1355,9 @@ def test_base_create_command_includes_archive_name_format_with_placeholders_in_b flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) @@ -1295,7 +1388,7 @@ def test_base_create_command_includes_archive_name_format_with_placeholders_in_b assert not exclude_file -def test_base_create_command_includes_repository_and_archive_name_format_with_placeholders_in_borg_command(): +def test_make_base_create_command_includes_repository_and_archive_name_format_with_placeholders_in_borg_command(): repository_archive_pattern = '{fqdn}::Documents_{hostname}-{now}' # noqa: FS003 flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) @@ -1305,6 +1398,9 @@ def test_base_create_command_includes_repository_and_archive_name_format_with_pl flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) @@ -1347,6 +1443,9 @@ def test_make_base_create_command_includes_extra_borg_options_in_borg_command(): flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') + flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( + '{hostname}' + ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) diff --git a/tests/unit/borg/test_flags.py b/tests/unit/borg/test_flags.py index 2204f497..38228e1d 100644 --- a/tests/unit/borg/test_flags.py +++ b/tests/unit/borg/test_flags.py @@ -85,6 +85,24 @@ def test_make_repository_archive_flags_with_borg_features_joins_repository_and_a ) == ('repo::archive',) +def test_get_default_archive_name_format_with_archive_series_feature_uses_series_archive_name_format(): + flexmock(module.feature).should_receive('available').and_return(True) + + assert ( + module.get_default_archive_name_format(local_borg_version='1.2.3') + == module.DEFAULT_ARCHIVE_NAME_FORMAT_WITH_SERIES + ) + + +def test_get_default_archive_name_format_without_archive_series_feature_uses_non_series_archive_name_format(): + flexmock(module.feature).should_receive('available').and_return(False) + + assert ( + module.get_default_archive_name_format(local_borg_version='1.2.3') + == module.DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES + ) + + @pytest.mark.parametrize( 'match_archives,archive_name_format,feature_available,expected_result', ( @@ -175,12 +193,27 @@ def test_make_repository_archive_flags_with_borg_features_joins_repository_and_a True, (), ), + ( + 'abcdefabcdef', + None, + True, + ('--match-archives', 'aid:abcdefabcdef'), + ), + ( + 'aid:abcdefabcdef', + None, + True, + ('--match-archives', 'aid:abcdefabcdef'), + ), ), ) def test_make_match_archives_flags_makes_flags_with_globs( match_archives, archive_name_format, feature_available, expected_result ): flexmock(module.feature).should_receive('available').and_return(feature_available) + flexmock(module).should_receive('get_default_archive_name_format').and_return( + module.DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES + ) assert ( module.make_match_archives_flags( diff --git a/tests/unit/borg/test_prune.py b/tests/unit/borg/test_prune.py index 7352ba70..d540ec69 100644 --- a/tests/unit/borg/test_prune.py +++ b/tests/unit/borg/test_prune.py @@ -36,7 +36,9 @@ def test_make_prune_flags_returns_flags_from_config(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) - result = module.make_prune_flags(config, local_borg_version='1.2.3') + result = module.make_prune_flags( + config, flexmock(match_archives=None), local_borg_version='1.2.3' + ) assert result == BASE_PRUNE_FLAGS @@ -49,7 +51,9 @@ def test_make_prune_flags_accepts_prefix_with_placeholders(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) - result = module.make_prune_flags(config, local_borg_version='1.2.3') + result = module.make_prune_flags( + config, flexmock(match_archives=None), local_borg_version='1.2.3' + ) expected = ( '--keep-daily', @@ -69,7 +73,9 @@ def test_make_prune_flags_with_prefix_without_borg_features_uses_glob_archives() flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) - result = module.make_prune_flags(config, local_borg_version='1.2.3') + result = module.make_prune_flags( + config, flexmock(match_archives=None), local_borg_version='1.2.3' + ) expected = ( '--keep-daily', @@ -90,7 +96,9 @@ def test_make_prune_flags_prefers_prefix_to_archive_name_format(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').never() - result = module.make_prune_flags(config, local_borg_version='1.2.3') + result = module.make_prune_flags( + config, flexmock(match_archives=None), local_borg_version='1.2.3' + ) expected = ( '--keep-daily', @@ -111,9 +119,63 @@ def test_make_prune_flags_without_prefix_uses_archive_name_format_instead(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, 'bar-{now}', '1.2.3' # noqa: FS003 - ).and_return(('--match-archives', 'sh:bar-*')) + ).and_return(('--match-archives', 'sh:bar-*')).once() - result = module.make_prune_flags(config, local_borg_version='1.2.3') + result = module.make_prune_flags( + config, flexmock(match_archives=None), local_borg_version='1.2.3' + ) + + expected = ( + '--keep-daily', + '1', + '--match-archives', + 'sh:bar-*', # noqa: FS003 + ) + + assert result == expected + + +def test_make_prune_flags_without_prefix_uses_match_archives_flag_instead_of_option(): + config = { + 'archive_name_format': 'bar-{now}', # noqa: FS003 + 'match_archives': 'foo*', + 'keep_daily': 1, + 'prefix': None, + } + flexmock(module.feature).should_receive('available').and_return(True) + flexmock(module.flags).should_receive('make_match_archives_flags').with_args( + 'baz*', 'bar-{now}', '1.2.3' # noqa: FS003 + ).and_return(('--match-archives', 'sh:bar-*')).once() + + result = module.make_prune_flags( + config, flexmock(match_archives='baz*'), local_borg_version='1.2.3' + ) + + expected = ( + '--keep-daily', + '1', + '--match-archives', + 'sh:bar-*', # noqa: FS003 + ) + + assert result == expected + + +def test_make_prune_flags_without_prefix_uses_match_archives_option(): + config = { + 'archive_name_format': 'bar-{now}', # noqa: FS003 + 'match_archives': 'foo*', + 'keep_daily': 1, + 'prefix': None, + } + flexmock(module.feature).should_receive('available').and_return(True) + flexmock(module.flags).should_receive('make_match_archives_flags').with_args( + 'foo*', 'bar-{now}', '1.2.3' # noqa: FS003 + ).and_return(('--match-archives', 'sh:bar-*')).once() + + result = module.make_prune_flags( + config, flexmock(match_archives=None), local_borg_version='1.2.3' + ) expected = ( '--keep-daily', @@ -133,7 +195,9 @@ def test_make_prune_flags_ignores_keep_exclude_tags_in_config(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) - result = module.make_prune_flags(config, local_borg_version='1.2.3') + result = module.make_prune_flags( + config, flexmock(match_archives=None), local_borg_version='1.2.3' + ) assert result == ('--keep-daily', '1')