Răsfoiți Sursa

Merge branch 'master' of https://github.com/diivi/borgmatic into feat/file-urls-support

Divyansh Singh 2 ani în urmă
părinte
comite
e86d223bbf
48 a modificat fișierele cu 922 adăugiri și 291 ștergeri
  1. 1 0
      .flake8
  2. 18 1
      NEWS
  3. 2 2
      README.md
  4. 6 0
      borgmatic/actions/check.py
  5. 6 0
      borgmatic/actions/compact.py
  6. 6 0
      borgmatic/actions/create.py
  7. 6 0
      borgmatic/actions/prune.py
  8. 1 1
      borgmatic/borg/check.py
  9. 16 0
      borgmatic/borg/create.py
  10. 2 1
      borgmatic/borg/environment.py
  11. 7 0
      borgmatic/borg/extract.py
  12. 1 1
      borgmatic/borg/rlist.py
  13. 29 13
      borgmatic/commands/arguments.py
  14. 178 152
      borgmatic/commands/borgmatic.py
  15. 28 14
      borgmatic/config/schema.yaml
  16. 1 1
      borgmatic/config/validate.py
  17. 6 0
      borgmatic/hooks/cronhub.py
  18. 6 0
      borgmatic/hooks/cronitor.py
  19. 2 1
      borgmatic/hooks/healthchecks.py
  20. 1 0
      borgmatic/hooks/monitor.py
  21. 0 8
      borgmatic/hooks/ntfy.py
  22. 1 1
      docs/Dockerfile
  23. 4 1
      docs/how-to/backup-your-databases.md
  24. 38 22
      docs/how-to/deal-with-very-large-backups.md
  25. 1 1
      docs/how-to/develop-on-borgmatic.md
  26. 11 4
      docs/how-to/extract-a-backup.md
  27. 13 0
      docs/how-to/inspect-your-backups.md
  28. 6 6
      docs/how-to/monitor-your-backups.md
  29. 1 0
      docs/how-to/set-up-backups.md
  30. 2 0
      setup.cfg
  31. 1 1
      setup.py
  32. 1 0
      test_requirements.txt
  33. 0 7
      tests/integration/commands/test_arguments.py
  34. 66 3
      tests/unit/actions/test_check.py
  35. 60 3
      tests/unit/actions/test_compact.py
  36. 74 3
      tests/unit/actions/test_create.py
  37. 54 3
      tests/unit/actions/test_prune.py
  38. 25 2
      tests/unit/borg/test_create.py
  39. 5 0
      tests/unit/borg/test_environment.py
  40. 51 0
      tests/unit/borg/test_extract.py
  41. 22 0
      tests/unit/commands/test_arguments.py
  42. 78 16
      tests/unit/commands/test_borgmatic.py
  43. 8 0
      tests/unit/hooks/test_cronhub.py
  44. 8 0
      tests/unit/hooks/test_cronitor.py
  45. 17 0
      tests/unit/hooks/test_healthchecks.py
  46. 1 1
      tests/unit/hooks/test_mongodb.py
  47. 49 20
      tests/unit/hooks/test_ntfy.py
  48. 2 2
      tox.ini

+ 1 - 0
.flake8

@@ -0,0 +1 @@
+select = Q0

+ 18 - 1
NEWS

@@ -1,5 +1,22 @@
-1.7.9.dev0
+1.7.10.dev0
+ * #501: Optionally error if a source directory does not exist via "source_directories_must_exist"
+   option in borgmatic's location configuration.
+ * #618: Support for BORG_FILES_CACHE_TTL environment variable via "borg_files_cache_ttl" option in
+   borgmatic's storage configuration.
+
+1.7.9
  * #295: Add a SQLite database dump/restore hook.
  * #295: Add a SQLite database dump/restore hook.
+ * #304: Change the default action order when no actions are specified on the command-line to:
+   "create", "prune", "compact", "check". If you'd like to retain the old ordering ("prune" and
+   "compact" first), then specify actions explicitly on the command-line.
+ * #304: Run any command-line actions in the order specified instead of using a fixed ordering.
+ * #564: Add "--repository" flag to all actions where it makes sense, so you can run borgmatic on
+   a single configured repository instead of all of them.
+ * #628: Add a Healthchecks "log" state to send borgmatic logs to Healthchecks without signalling
+   success or failure.
+ * #647: Add "--strip-components all" feature on the "extract" action to remove leading path
+   components of files you extract. Must be used with the "--path" flag.
+ * Add support for Python 3.11.
 
 
 1.7.8
 1.7.8
  * #620: With the "create" action and the "--list" ("--files") flag, only show excluded files at
  * #620: With the "create" action and the "--list" ("--files") flag, only show excluded files at

+ 2 - 2
README.md

@@ -81,8 +81,8 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
 Your first step is to [install and configure
 Your first step is to [install and configure
 borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/).
 borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/).
 
 
-For additional documentation, check out the links above for <a
-href="https://torsion.org/borgmatic/#documentation">borgmatic how-to and
+For additional documentation, check out the links above (left panel on wide screens)
+for <a href="https://torsion.org/borgmatic/#documentation">borgmatic how-to and
 reference guides</a>.
 reference guides</a>.
 
 
 
 

+ 6 - 0
borgmatic/actions/check.py

@@ -1,6 +1,7 @@
 import logging
 import logging
 
 
 import borgmatic.borg.check
 import borgmatic.borg.check
+import borgmatic.config.validate
 import borgmatic.hooks.command
 import borgmatic.hooks.command
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -23,6 +24,11 @@ def run_check(
     '''
     '''
     Run the "check" action for the given repository.
     Run the "check" action for the given repository.
     '''
     '''
+    if check_arguments.repository and not borgmatic.config.validate.repositories_match(
+        repository, check_arguments.repository
+    ):
+        return
+
     borgmatic.hooks.command.execute_hook(
     borgmatic.hooks.command.execute_hook(
         hooks.get('before_check'),
         hooks.get('before_check'),
         hooks.get('umask'),
         hooks.get('umask'),

+ 6 - 0
borgmatic/actions/compact.py

@@ -2,6 +2,7 @@ import logging
 
 
 import borgmatic.borg.compact
 import borgmatic.borg.compact
 import borgmatic.borg.feature
 import borgmatic.borg.feature
+import borgmatic.config.validate
 import borgmatic.hooks.command
 import borgmatic.hooks.command
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -24,6 +25,11 @@ def run_compact(
     '''
     '''
     Run the "compact" action for the given repository.
     Run the "compact" action for the given repository.
     '''
     '''
+    if compact_arguments.repository and not borgmatic.config.validate.repositories_match(
+        repository, compact_arguments.repository
+    ):
+        return
+
     borgmatic.hooks.command.execute_hook(
     borgmatic.hooks.command.execute_hook(
         hooks.get('before_compact'),
         hooks.get('before_compact'),
         hooks.get('umask'),
         hooks.get('umask'),

+ 6 - 0
borgmatic/actions/create.py

@@ -2,6 +2,7 @@ import json
 import logging
 import logging
 
 
 import borgmatic.borg.create
 import borgmatic.borg.create
+import borgmatic.config.validate
 import borgmatic.hooks.command
 import borgmatic.hooks.command
 import borgmatic.hooks.dispatch
 import borgmatic.hooks.dispatch
 import borgmatic.hooks.dump
 import borgmatic.hooks.dump
@@ -28,6 +29,11 @@ def run_create(
 
 
     If create_arguments.json is True, yield the JSON output from creating the archive.
     If create_arguments.json is True, yield the JSON output from creating the archive.
     '''
     '''
+    if create_arguments.repository and not borgmatic.config.validate.repositories_match(
+        repository, create_arguments.repository
+    ):
+        return
+
     borgmatic.hooks.command.execute_hook(
     borgmatic.hooks.command.execute_hook(
         hooks.get('before_backup'),
         hooks.get('before_backup'),
         hooks.get('umask'),
         hooks.get('umask'),

+ 6 - 0
borgmatic/actions/prune.py

@@ -1,6 +1,7 @@
 import logging
 import logging
 
 
 import borgmatic.borg.prune
 import borgmatic.borg.prune
+import borgmatic.config.validate
 import borgmatic.hooks.command
 import borgmatic.hooks.command
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -23,6 +24,11 @@ def run_prune(
     '''
     '''
     Run the "prune" action for the given repository.
     Run the "prune" action for the given repository.
     '''
     '''
+    if prune_arguments.repository and not borgmatic.config.validate.repositories_match(
+        repository, prune_arguments.repository
+    ):
+        return
+
     borgmatic.hooks.command.execute_hook(
     borgmatic.hooks.command.execute_hook(
         hooks.get('before_prune'),
         hooks.get('before_prune'),
         hooks.get('umask'),
         hooks.get('umask'),

+ 1 - 1
borgmatic/borg/check.py

@@ -139,7 +139,7 @@ def filter_checks_on_frequency(
         if datetime.datetime.now() < check_time + frequency_delta:
         if datetime.datetime.now() < check_time + frequency_delta:
             remaining = check_time + frequency_delta - datetime.datetime.now()
             remaining = check_time + frequency_delta - datetime.datetime.now()
             logger.info(
             logger.info(
-                f"Skipping {check} check due to configured frequency; {remaining} until next check"
+                f'Skipping {check} check due to configured frequency; {remaining} until next check'
             )
             )
             filtered_checks.remove(check)
             filtered_checks.remove(check)
 
 

+ 16 - 0
borgmatic/borg/create.py

@@ -306,6 +306,20 @@ def collect_special_file_paths(
     )
     )
 
 
 
 
+def check_all_source_directories_exist(source_directories):
+    '''
+    Given a sequence of source directories, check that they all exist. If any do not, raise an
+    exception.
+    '''
+    missing_directories = [
+        source_directory
+        for source_directory in source_directories
+        if not os.path.exists(source_directory)
+    ]
+    if missing_directories:
+        raise ValueError(f"Source directories do not exist: {', '.join(missing_directories)}")
+
+
 def create_archive(
 def create_archive(
     dry_run,
     dry_run,
     repository,
     repository,
@@ -331,6 +345,8 @@ def create_archive(
     borgmatic_source_directories = expand_directories(
     borgmatic_source_directories = expand_directories(
         collect_borgmatic_source_directories(location_config.get('borgmatic_source_directory'))
         collect_borgmatic_source_directories(location_config.get('borgmatic_source_directory'))
     )
     )
+    if location_config.get('source_directories_must_exist', False):
+        check_all_source_directories_exist(location_config.get('source_directories'))
     sources = deduplicate_directories(
     sources = deduplicate_directories(
         map_directories_to_devices(
         map_directories_to_devices(
             expand_directories(
             expand_directories(

+ 2 - 1
borgmatic/borg/environment.py

@@ -2,6 +2,7 @@ OPTION_TO_ENVIRONMENT_VARIABLE = {
     'borg_base_directory': 'BORG_BASE_DIR',
     'borg_base_directory': 'BORG_BASE_DIR',
     'borg_config_directory': 'BORG_CONFIG_DIR',
     'borg_config_directory': 'BORG_CONFIG_DIR',
     'borg_cache_directory': 'BORG_CACHE_DIR',
     'borg_cache_directory': 'BORG_CACHE_DIR',
+    'borg_files_cache_ttl': 'BORG_FILES_CACHE_TTL',
     'borg_security_directory': 'BORG_SECURITY_DIR',
     'borg_security_directory': 'BORG_SECURITY_DIR',
     'borg_keys_directory': 'BORG_KEYS_DIR',
     'borg_keys_directory': 'BORG_KEYS_DIR',
     'encryption_passcommand': 'BORG_PASSCOMMAND',
     'encryption_passcommand': 'BORG_PASSCOMMAND',
@@ -27,7 +28,7 @@ def make_environment(storage_config):
         value = storage_config.get(option_name)
         value = storage_config.get(option_name)
 
 
         if value:
         if value:
-            environment[environment_variable_name] = value
+            environment[environment_variable_name] = str(value)
 
 
     for (
     for (
         option_name,
         option_name,

+ 7 - 0
borgmatic/borg/extract.py

@@ -87,6 +87,13 @@ def extract_archive(
     else:
     else:
         numeric_ids_flags = ('--numeric-owner',) if location_config.get('numeric_ids') else ()
         numeric_ids_flags = ('--numeric-owner',) if location_config.get('numeric_ids') else ()
 
 
+    if strip_components == 'all':
+        if not paths:
+            raise ValueError('The --strip-components flag with "all" requires at least one --path')
+
+        # Calculate the maximum number of leading path components of the given paths.
+        strip_components = max(0, *(len(path.split(os.path.sep)) - 1 for path in paths))
+
     full_command = (
     full_command = (
         (local_path, 'extract')
         (local_path, 'extract')
         + (('--remote-path', remote_path) if remote_path else ())
         + (('--remote-path', remote_path) if remote_path else ())

+ 1 - 1
borgmatic/borg/rlist.py

@@ -17,7 +17,7 @@ def resolve_archive_name(
 
 
     Raise ValueError if "latest" is given but there are no archives in the repository.
     Raise ValueError if "latest" is given but there are no archives in the repository.
     '''
     '''
-    if archive != "latest":
+    if archive != 'latest':
         return archive
         return archive
 
 
     lock_wait = storage_config.get('lock_wait', None)
     lock_wait = storage_config.get('lock_wait', None)

+ 29 - 13
borgmatic/commands/arguments.py

@@ -46,11 +46,12 @@ def parse_subparser_arguments(unparsed_arguments, subparsers):
     if 'borg' in unparsed_arguments:
     if 'borg' in unparsed_arguments:
         subparsers = {'borg': subparsers['borg']}
         subparsers = {'borg': subparsers['borg']}
 
 
-    for subparser_name, subparser in subparsers.items():
-        if subparser_name not in remaining_arguments:
-            continue
+    for argument in remaining_arguments:
+        canonical_name = alias_to_subparser_name.get(argument, argument)
+        subparser = subparsers.get(canonical_name)
 
 
-        canonical_name = alias_to_subparser_name.get(subparser_name, subparser_name)
+        if not subparser:
+            continue
 
 
         # If a parsed value happens to be the same as the name of a subparser, remove it from the
         # If a parsed value happens to be the same as the name of a subparser, remove it from the
         # remaining arguments. This prevents, for instance, "check --only extract" from triggering
         # remaining arguments. This prevents, for instance, "check --only extract" from triggering
@@ -67,9 +68,9 @@ def parse_subparser_arguments(unparsed_arguments, subparsers):
 
 
         arguments[canonical_name] = parsed
         arguments[canonical_name] = parsed
 
 
-    # If no actions are explicitly requested, assume defaults: prune, compact, create, and check.
+    # If no actions are explicitly requested, assume defaults.
     if not arguments and '--help' not in unparsed_arguments and '-h' not in unparsed_arguments:
     if not arguments and '--help' not in unparsed_arguments and '-h' not in unparsed_arguments:
-        for subparser_name in ('prune', 'compact', 'create', 'check'):
+        for subparser_name in ('create', 'prune', 'compact', 'check'):
             subparser = subparsers[subparser_name]
             subparser = subparsers[subparser_name]
             parsed, unused_remaining = subparser.parse_known_args(unparsed_arguments)
             parsed, unused_remaining = subparser.parse_known_args(unparsed_arguments)
             arguments[subparser_name] = parsed
             arguments[subparser_name] = parsed
@@ -215,7 +216,7 @@ def make_parsers():
     top_level_parser = ArgumentParser(
     top_level_parser = ArgumentParser(
         description='''
         description='''
             Simple, configuration-driven backup software for servers and workstations. If none of
             Simple, configuration-driven backup software for servers and workstations. If none of
-            the action options are given, then borgmatic defaults to: prune, compact, create, and
+            the action options are given, then borgmatic defaults to: create, prune, compact, and
             check.
             check.
             ''',
             ''',
         parents=[global_parser],
         parents=[global_parser],
@@ -224,7 +225,7 @@ def make_parsers():
     subparsers = top_level_parser.add_subparsers(
     subparsers = top_level_parser.add_subparsers(
         title='actions',
         title='actions',
         metavar='',
         metavar='',
-        help='Specify zero or more actions. Defaults to prune, compact, create, and check. Use --help with action for details:',
+        help='Specify zero or more actions. Defaults to creat, prune, compact, and check. Use --help with action for details:',
     )
     )
     rcreate_parser = subparsers.add_parser(
     rcreate_parser = subparsers.add_parser(
         'rcreate',
         'rcreate',
@@ -332,6 +333,10 @@ def make_parsers():
         add_help=False,
         add_help=False,
     )
     )
     prune_group = prune_parser.add_argument_group('prune arguments')
     prune_group = prune_parser.add_argument_group('prune arguments')
+    prune_group.add_argument(
+        '--repository',
+        help='Path of specific existing repository to prune (must be already specified in a borgmatic configuration file)',
+    )
     prune_group.add_argument(
     prune_group.add_argument(
         '--stats',
         '--stats',
         dest='stats',
         dest='stats',
@@ -352,6 +357,10 @@ def make_parsers():
         add_help=False,
         add_help=False,
     )
     )
     compact_group = compact_parser.add_argument_group('compact arguments')
     compact_group = compact_parser.add_argument_group('compact arguments')
+    compact_group.add_argument(
+        '--repository',
+        help='Path of specific existing repository to compact (must be already specified in a borgmatic configuration file)',
+    )
     compact_group.add_argument(
     compact_group.add_argument(
         '--progress',
         '--progress',
         dest='progress',
         dest='progress',
@@ -384,6 +393,10 @@ def make_parsers():
         add_help=False,
         add_help=False,
     )
     )
     create_group = create_parser.add_argument_group('create arguments')
     create_group = create_parser.add_argument_group('create arguments')
+    create_group.add_argument(
+        '--repository',
+        help='Path of specific existing repository to backup to (must be already specified in a borgmatic configuration file)',
+    )
     create_group.add_argument(
     create_group.add_argument(
         '--progress',
         '--progress',
         dest='progress',
         dest='progress',
@@ -414,6 +427,10 @@ def make_parsers():
         add_help=False,
         add_help=False,
     )
     )
     check_group = check_parser.add_argument_group('check arguments')
     check_group = check_parser.add_argument_group('check arguments')
+    check_group.add_argument(
+        '--repository',
+        help='Path of specific existing repository to check (must be already specified in a borgmatic configuration file)',
+    )
     check_group.add_argument(
     check_group.add_argument(
         '--progress',
         '--progress',
         dest='progress',
         dest='progress',
@@ -475,10 +492,9 @@ def make_parsers():
     )
     )
     extract_group.add_argument(
     extract_group.add_argument(
         '--strip-components',
         '--strip-components',
-        type=int,
+        type=lambda number: number if number == 'all' else int(number),
         metavar='NUMBER',
         metavar='NUMBER',
-        dest='strip_components',
-        help='Number of leading path components to remove from each extracted path. Skip paths with fewer elements',
+        help='Number of leading path components to remove from each extracted path or "all" to strip all leading path components. Skip paths with fewer elements',
     )
     )
     extract_group.add_argument(
     extract_group.add_argument(
         '--progress',
         '--progress',
@@ -611,7 +627,7 @@ def make_parsers():
         metavar='NAME',
         metavar='NAME',
         nargs='+',
         nargs='+',
         dest='databases',
         dest='databases',
-        help='Names of databases to restore from archive, defaults to all databases. Note that any databases to restore must be defined in borgmatic\'s configuration',
+        help="Names of databases to restore from archive, defaults to all databases. Note that any databases to restore must be defined in borgmatic's configuration",
     )
     )
     restore_group.add_argument(
     restore_group.add_argument(
         '-h', '--help', action='help', help='Show this help message and exit'
         '-h', '--help', action='help', help='Show this help message and exit'
@@ -805,7 +821,7 @@ def make_parsers():
         'borg',
         'borg',
         aliases=SUBPARSER_ALIASES['borg'],
         aliases=SUBPARSER_ALIASES['borg'],
         help='Run an arbitrary Borg command',
         help='Run an arbitrary Borg command',
-        description='Run an arbitrary Borg command based on borgmatic\'s configuration',
+        description="Run an arbitrary Borg command based on borgmatic's configuration",
         add_help=False,
         add_help=False,
     )
     )
     borg_group = borg_parser.add_argument_group('borg arguments')
     borg_group = borg_parser.add_argument_group('borg arguments')

+ 178 - 152
borgmatic/commands/borgmatic.py

@@ -44,8 +44,8 @@ LEGACY_CONFIG_PATH = '/etc/borgmatic/config'
 def run_configuration(config_filename, config, arguments):
 def run_configuration(config_filename, config, arguments):
     '''
     '''
     Given a config filename, the corresponding parsed config dict, and command-line arguments as a
     Given a config filename, the corresponding parsed config dict, and command-line arguments as a
-    dict from subparser name to a namespace of parsed arguments, execute the defined prune, compact,
-    create, check, and/or other actions.
+    dict from subparser name to a namespace of parsed arguments, execute the defined create, prune,
+    compact, check, and/or other actions.
 
 
     Yield a combination of:
     Yield a combination of:
 
 
@@ -64,7 +64,7 @@ def run_configuration(config_filename, config, arguments):
     retry_wait = storage.get('retry_wait', 0)
     retry_wait = storage.get('retry_wait', 0)
     encountered_error = None
     encountered_error = None
     error_repository = ''
     error_repository = ''
-    using_primary_action = {'prune', 'compact', 'create', 'check'}.intersection(arguments)
+    using_primary_action = {'create', 'prune', 'compact', 'check'}.intersection(arguments)
     monitoring_log_level = verbosity_to_log_level(global_arguments.monitoring_verbosity)
     monitoring_log_level = verbosity_to_log_level(global_arguments.monitoring_verbosity)
 
 
     try:
     try:
@@ -152,6 +152,25 @@ def run_configuration(config_filename, config, arguments):
                 encountered_error = error
                 encountered_error = error
                 error_repository = repository_path
                 error_repository = repository_path
 
 
+    try:
+        if using_primary_action:
+            # send logs irrespective of error
+            dispatch.call_hooks(
+                'ping_monitor',
+                hooks,
+                config_filename,
+                monitor.MONITOR_HOOK_NAMES,
+                monitor.State.LOG,
+                monitoring_log_level,
+                global_arguments.dry_run,
+            )
+    except (OSError, CalledProcessError) as error:
+        if command.considered_soft_failure(config_filename, error):
+            return
+
+        encountered_error = error
+        yield from log_error_records('{}: Error pinging monitor'.format(config_filename), error)
+
     if not encountered_error:
     if not encountered_error:
         try:
         try:
             if using_primary_action:
             if using_primary_action:
@@ -262,155 +281,162 @@ def run_actions(
         **hook_context,
         **hook_context,
     )
     )
 
 
-    if 'rcreate' in arguments:
-        borgmatic.actions.rcreate.run_rcreate(
-            repository,
-            storage,
-            local_borg_version,
-            arguments['rcreate'],
-            global_arguments,
-            local_path,
-            remote_path,
-        )
-    if 'transfer' in arguments:
-        borgmatic.actions.transfer.run_transfer(
-            repository,
-            storage,
-            local_borg_version,
-            arguments['transfer'],
-            global_arguments,
-            local_path,
-            remote_path,
-        )
-    if 'prune' in arguments:
-        borgmatic.actions.prune.run_prune(
-            config_filename,
-            repository,
-            storage,
-            retention,
-            hooks,
-            hook_context,
-            local_borg_version,
-            arguments['prune'],
-            global_arguments,
-            dry_run_label,
-            local_path,
-            remote_path,
-        )
-    if 'compact' in arguments:
-        borgmatic.actions.compact.run_compact(
-            config_filename,
-            repository,
-            storage,
-            retention,
-            hooks,
-            hook_context,
-            local_borg_version,
-            arguments['compact'],
-            global_arguments,
-            dry_run_label,
-            local_path,
-            remote_path,
-        )
-    if 'create' in arguments:
-        yield from borgmatic.actions.create.run_create(
-            config_filename,
-            repository,
-            location,
-            storage,
-            hooks,
-            hook_context,
-            local_borg_version,
-            arguments['create'],
-            global_arguments,
-            dry_run_label,
-            local_path,
-            remote_path,
-        )
-    if 'check' in arguments and checks.repository_enabled_for_checks(repository, consistency):
-        borgmatic.actions.check.run_check(
-            config_filename,
-            repository,
-            location,
-            storage,
-            consistency,
-            hooks,
-            hook_context,
-            local_borg_version,
-            arguments['check'],
-            global_arguments,
-            local_path,
-            remote_path,
-        )
-    if 'extract' in arguments:
-        borgmatic.actions.extract.run_extract(
-            config_filename,
-            repository,
-            location,
-            storage,
-            hooks,
-            hook_context,
-            local_borg_version,
-            arguments['extract'],
-            global_arguments,
-            local_path,
-            remote_path,
-        )
-    if 'export-tar' in arguments:
-        borgmatic.actions.export_tar.run_export_tar(
-            repository,
-            storage,
-            local_borg_version,
-            arguments['export-tar'],
-            global_arguments,
-            local_path,
-            remote_path,
-        )
-    if 'mount' in arguments:
-        borgmatic.actions.mount.run_mount(
-            repository, storage, local_borg_version, arguments['mount'], local_path, remote_path,
-        )
-    if 'restore' in arguments:
-        borgmatic.actions.restore.run_restore(
-            repository,
-            location,
-            storage,
-            hooks,
-            local_borg_version,
-            arguments['restore'],
-            global_arguments,
-            local_path,
-            remote_path,
-        )
-    if 'rlist' in arguments:
-        yield from borgmatic.actions.rlist.run_rlist(
-            repository, storage, local_borg_version, arguments['rlist'], local_path, remote_path,
-        )
-    if 'list' in arguments:
-        yield from borgmatic.actions.list.run_list(
-            repository, storage, local_borg_version, arguments['list'], local_path, remote_path,
-        )
-    if 'rinfo' in arguments:
-        yield from borgmatic.actions.rinfo.run_rinfo(
-            repository, storage, local_borg_version, arguments['rinfo'], local_path, remote_path,
-        )
-    if 'info' in arguments:
-        yield from borgmatic.actions.info.run_info(
-            repository, storage, local_borg_version, arguments['info'], local_path, remote_path,
-        )
-    if 'break-lock' in arguments:
-        borgmatic.actions.break_lock.run_break_lock(
-            repository,
-            storage,
-            local_borg_version,
-            arguments['break-lock'],
-            local_path,
-            remote_path,
-        )
-    if 'borg' in arguments:
-        borgmatic.actions.borg.run_borg(
-            repository, storage, local_borg_version, arguments['borg'], local_path, remote_path,
-        )
+    for (action_name, action_arguments) in arguments.items():
+        if action_name == 'rcreate':
+            borgmatic.actions.rcreate.run_rcreate(
+                repository,
+                storage,
+                local_borg_version,
+                action_arguments,
+                global_arguments,
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'transfer':
+            borgmatic.actions.transfer.run_transfer(
+                repository,
+                storage,
+                local_borg_version,
+                action_arguments,
+                global_arguments,
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'create':
+            yield from borgmatic.actions.create.run_create(
+                config_filename,
+                repository,
+                location,
+                storage,
+                hooks,
+                hook_context,
+                local_borg_version,
+                action_arguments,
+                global_arguments,
+                dry_run_label,
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'prune':
+            borgmatic.actions.prune.run_prune(
+                config_filename,
+                repository,
+                storage,
+                retention,
+                hooks,
+                hook_context,
+                local_borg_version,
+                action_arguments,
+                global_arguments,
+                dry_run_label,
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'compact':
+            borgmatic.actions.compact.run_compact(
+                config_filename,
+                repository,
+                storage,
+                retention,
+                hooks,
+                hook_context,
+                local_borg_version,
+                action_arguments,
+                global_arguments,
+                dry_run_label,
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'check':
+            if checks.repository_enabled_for_checks(repository, consistency):
+                borgmatic.actions.check.run_check(
+                    config_filename,
+                    repository,
+                    location,
+                    storage,
+                    consistency,
+                    hooks,
+                    hook_context,
+                    local_borg_version,
+                    action_arguments,
+                    global_arguments,
+                    local_path,
+                    remote_path,
+                )
+        elif action_name == 'extract':
+            borgmatic.actions.extract.run_extract(
+                config_filename,
+                repository,
+                location,
+                storage,
+                hooks,
+                hook_context,
+                local_borg_version,
+                action_arguments,
+                global_arguments,
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'export-tar':
+            borgmatic.actions.export_tar.run_export_tar(
+                repository,
+                storage,
+                local_borg_version,
+                action_arguments,
+                global_arguments,
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'mount':
+            borgmatic.actions.mount.run_mount(
+                repository,
+                storage,
+                local_borg_version,
+                arguments['mount'],
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'restore':
+            borgmatic.actions.restore.run_restore(
+                repository,
+                location,
+                storage,
+                hooks,
+                local_borg_version,
+                action_arguments,
+                global_arguments,
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'rlist':
+            yield from borgmatic.actions.rlist.run_rlist(
+                repository, storage, local_borg_version, action_arguments, local_path, remote_path,
+            )
+        elif action_name == 'list':
+            yield from borgmatic.actions.list.run_list(
+                repository, storage, local_borg_version, action_arguments, local_path, remote_path,
+            )
+        elif action_name == 'rinfo':
+            yield from borgmatic.actions.rinfo.run_rinfo(
+                repository, storage, local_borg_version, action_arguments, local_path, remote_path,
+            )
+        elif action_name == 'info':
+            yield from borgmatic.actions.info.run_info(
+                repository, storage, local_borg_version, action_arguments, local_path, remote_path,
+            )
+        elif action_name == 'break-lock':
+            borgmatic.actions.break_lock.run_break_lock(
+                repository,
+                storage,
+                local_borg_version,
+                arguments['break-lock'],
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'borg':
+            borgmatic.actions.borg.run_borg(
+                repository, storage, local_borg_version, action_arguments, local_path, remote_path,
+            )
 
 
     command.execute_hook(
     command.execute_hook(
         hooks.get('after_actions'),
         hooks.get('after_actions'),

+ 28 - 14
borgmatic/config/schema.yaml

@@ -202,6 +202,12 @@ properties:
                     path prevents "borgmatic restore" from finding any database
                     path prevents "borgmatic restore" from finding any database
                     dumps created before the change. Defaults to ~/.borgmatic
                     dumps created before the change. Defaults to ~/.borgmatic
                 example: /tmp/borgmatic
                 example: /tmp/borgmatic
+            source_directories_must_exist:
+                type: boolean
+                description: |
+                    If true, then source directories must exist, otherwise an
+                    error is raised. Defaults to false.
+                example: true
     storage:
     storage:
         type: object
         type: object
         description: |
         description: |
@@ -315,6 +321,12 @@ properties:
                     Path for Borg cache files. Defaults to
                     Path for Borg cache files. Defaults to
                     $borg_base_directory/.cache/borg
                     $borg_base_directory/.cache/borg
                 example: /path/to/base/cache
                 example: /path/to/base/cache
+            borg_files_cache_ttl:
+                type: integer
+                description: |
+                    Maximum time to live (ttl) for entries in the Borg files
+                    cache.
+                example: 20
             borg_security_directory:
             borg_security_directory:
                 type: string
                 type: string
                 description: |
                 description: |
@@ -369,6 +381,11 @@ properties:
                         description: |
                         description: |
                           Extra command-line options to pass to "borg init".
                           Extra command-line options to pass to "borg init".
                         example: "--extra-option"
                         example: "--extra-option"
+                    create:
+                        type: string
+                        description: |
+                          Extra command-line options to pass to "borg create".
+                        example: "--extra-option"
                     prune:
                     prune:
                         type: string
                         type: string
                         description: |
                         description: |
@@ -379,11 +396,6 @@ properties:
                         description: |
                         description: |
                           Extra command-line options to pass to "borg compact".
                           Extra command-line options to pass to "borg compact".
                         example: "--extra-option"
                         example: "--extra-option"
-                    create:
-                        type: string
-                        description: |
-                          Extra command-line options to pass to "borg create".
-                        example: "--extra-option"
                     check:
                     check:
                         type: string
                         type: string
                         description: |
                         description: |
@@ -663,11 +675,11 @@ properties:
                     type: string
                     type: string
                 description: |
                 description: |
                     List of one or more shell commands or scripts to execute
                     List of one or more shell commands or scripts to execute
-                    when an exception occurs during a "prune", "compact",
-                    "create", or "check" action or an associated before/after
+                    when an exception occurs during a "create", "prune",
+                    "compact", or "check" action or an associated before/after
                     hook.
                     hook.
                 example:
                 example:
-                    - echo "Error during prune/compact/create/check."
+                    - echo "Error during create/prune/compact/check."
             before_everything:
             before_everything:
                 type: array
                 type: array
                 items:
                 items:
@@ -951,9 +963,9 @@ properties:
                         name:
                         name:
                             type: string
                             type: string
                             description: |
                             description: |
-                                This is used to tag the database dump file 
-                                with a name. It is not the path to the database 
-                                file itself. The name "all" has no special 
+                                This is used to tag the database dump file
+                                with a name. It is not the path to the database
+                                file itself. The name "all" has no special
                                 meaning for SQLite databases.
                                 meaning for SQLite databases.
                             example: users
                             example: users
                         path:
                         path:
@@ -1168,7 +1180,7 @@ properties:
                         type: string
                         type: string
                         description: |
                         description: |
                             Healthchecks ping URL or UUID to notify when a
                             Healthchecks ping URL or UUID to notify when a
-                            backup begins, ends, or errors.
+                            backup begins, ends, errors or just to send logs.
                         example: https://hc-ping.com/your-uuid-here
                         example: https://hc-ping.com/your-uuid-here
                     verify_tls:
                     verify_tls:
                         type: boolean
                         type: boolean
@@ -1180,7 +1192,8 @@ properties:
                         type: boolean
                         type: boolean
                         description: |
                         description: |
                             Send borgmatic logs to Healthchecks as part the
                             Send borgmatic logs to Healthchecks as part the
-                            "finish" state. Defaults to true.
+                            "finish", "fail", and "log" states. Defaults to
+                            true.
                         example: false
                         example: false
                     ping_body_limit:
                     ping_body_limit:
                         type: integer
                         type: integer
@@ -1199,10 +1212,11 @@ properties:
                                 - start
                                 - start
                                 - finish
                                 - finish
                                 - fail
                                 - fail
+                                - log
                             uniqueItems: true
                             uniqueItems: true
                         description: |
                         description: |
                             List of one or more monitoring states to ping for:
                             List of one or more monitoring states to ping for:
-                            "start", "finish", and/or "fail". Defaults to
+                            "start", "finish", "fail", and/or "log". Defaults to
                             pinging for all states.
                             pinging for all states.
                         example:
                         example:
                             - finish
                             - finish

+ 1 - 1
borgmatic/config/validate.py

@@ -189,5 +189,5 @@ def guard_single_repository_selected(repository, configurations):
 
 
     if count != 1:
     if count != 1:
         raise ValueError(
         raise ValueError(
-            'Can\'t determine which repository to use. Use --repository to disambiguate'
+            "Can't determine which repository to use. Use --repository to disambiguate"
         )
         )

+ 6 - 0
borgmatic/hooks/cronhub.py

@@ -27,6 +27,12 @@ def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_
     Ping the configured Cronhub URL, modified with the monitor.State. Use the given configuration
     Ping the configured Cronhub URL, modified with the monitor.State. Use the given configuration
     filename in any log entries. If this is a dry run, then don't actually ping anything.
     filename in any log entries. If this is a dry run, then don't actually ping anything.
     '''
     '''
+    if state not in MONITOR_STATE_TO_CRONHUB:
+        logger.debug(
+            f'{config_filename}: Ignoring unsupported monitoring {state.name.lower()} in Cronhub hook'
+        )
+        return
+
     dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
     dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
     formatted_state = '/{}/'.format(MONITOR_STATE_TO_CRONHUB[state])
     formatted_state = '/{}/'.format(MONITOR_STATE_TO_CRONHUB[state])
     ping_url = (
     ping_url = (

+ 6 - 0
borgmatic/hooks/cronitor.py

@@ -27,6 +27,12 @@ def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_
     Ping the configured Cronitor URL, modified with the monitor.State. Use the given configuration
     Ping the configured Cronitor URL, modified with the monitor.State. Use the given configuration
     filename in any log entries. If this is a dry run, then don't actually ping anything.
     filename in any log entries. If this is a dry run, then don't actually ping anything.
     '''
     '''
+    if state not in MONITOR_STATE_TO_CRONITOR:
+        logger.debug(
+            f'{config_filename}: Ignoring unsupported monitoring {state.name.lower()} in Cronitor hook'
+        )
+        return
+
     dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
     dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
     ping_url = '{}/{}'.format(hook_config['ping_url'], MONITOR_STATE_TO_CRONITOR[state])
     ping_url = '{}/{}'.format(hook_config['ping_url'], MONITOR_STATE_TO_CRONITOR[state])
 
 

+ 2 - 1
borgmatic/hooks/healthchecks.py

@@ -10,6 +10,7 @@ MONITOR_STATE_TO_HEALTHCHECKS = {
     monitor.State.START: 'start',
     monitor.State.START: 'start',
     monitor.State.FINISH: None,  # Healthchecks doesn't append to the URL for the finished state.
     monitor.State.FINISH: None,  # Healthchecks doesn't append to the URL for the finished state.
     monitor.State.FAIL: 'fail',
     monitor.State.FAIL: 'fail',
+    monitor.State.LOG: 'log',
 }
 }
 
 
 PAYLOAD_TRUNCATION_INDICATOR = '...\n'
 PAYLOAD_TRUNCATION_INDICATOR = '...\n'
@@ -117,7 +118,7 @@ def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_
     )
     )
     logger.debug('{}: Using Healthchecks ping URL {}'.format(config_filename, ping_url))
     logger.debug('{}: Using Healthchecks ping URL {}'.format(config_filename, ping_url))
 
 
-    if state in (monitor.State.FINISH, monitor.State.FAIL):
+    if state in (monitor.State.FINISH, monitor.State.FAIL, monitor.State.LOG):
         payload = format_buffered_logs_for_payload()
         payload = format_buffered_logs_for_payload()
     else:
     else:
         payload = ''
         payload = ''

+ 1 - 0
borgmatic/hooks/monitor.py

@@ -7,3 +7,4 @@ class State(Enum):
     START = 1
     START = 1
     FINISH = 2
     FINISH = 2
     FAIL = 3
     FAIL = 3
+    LOG = 4

+ 0 - 8
borgmatic/hooks/ntfy.py

@@ -2,16 +2,8 @@ import logging
 
 
 import requests
 import requests
 
 
-from borgmatic.hooks import monitor
-
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
-MONITOR_STATE_TO_NTFY = {
-    monitor.State.START: None,
-    monitor.State.FINISH: None,
-    monitor.State.FAIL: None,
-}
-
 
 
 def initialize_monitor(
 def initialize_monitor(
     ping_url, config_filename, monitoring_log_level, dry_run
     ping_url, config_filename, monitoring_log_level, dry_run

+ 1 - 1
docs/Dockerfile

@@ -4,7 +4,7 @@ COPY . /app
 RUN apk add --no-cache py3-pip py3-ruamel.yaml py3-ruamel.yaml.clib
 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 pip install --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml
 RUN borgmatic --help > /command-line.txt \
 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 break-lock borg; do \
+    && for action in rcreate transfer create prune compact check extract export-tar mount umount restore rlist list rinfo info break-lock borg; do \
            echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \
            echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \
            && borgmatic "$action" --help >> /command-line.txt; done
            && borgmatic "$action" --help >> /command-line.txt; done
 
 

+ 4 - 1
docs/how-to/backup-your-databases.md

@@ -316,7 +316,10 @@ user and you're extracting to `/tmp`, then the dump will be in
 `/tmp/root/.borgmatic`.
 `/tmp/root/.borgmatic`.
 
 
 After extraction, you can manually restore the dump file using native database
 After extraction, you can manually restore the dump file using native database
-commands like `pg_restore`, `mysql`, `mongorestore` or similar.
+commands like `pg_restore`, `mysql`, `mongorestore`, `sqlite`, or similar.
+
+Also see the documentation on [listing database
+dumps](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/#listing-database-dumps).
 
 
 
 
 ## Preparation and cleanup hooks
 ## Preparation and cleanup hooks

+ 38 - 22
docs/how-to/deal-with-very-large-backups.md

@@ -9,37 +9,47 @@ eleventyNavigation:
 
 
 Borg itself is great for efficiently de-duplicating data across successive
 Borg itself is great for efficiently de-duplicating data across successive
 backup archives, even when dealing with very large repositories. But you may
 backup archives, even when dealing with very large repositories. But you may
-find that while borgmatic's default mode of `prune`, `compact`, `create`, and
-`check` works well on small repositories, it's not so great on larger ones.
-That's because running the default pruning, compact, and consistency checks
-take a long time on large repositories.
+find that while borgmatic's default actions of `create`, `prune`, `compact`,
+and `check` works well on small repositories, it's not so great on larger
+ones. That's because running the default pruning, compact, and consistency
+checks take a long time on large repositories.
+
+<span class="minilink minilink-addedin">Prior to version 1.7.9</span> The
+default action ordering was `prune`, `compact`, `create`, and `check`.
 
 
 ### A la carte actions
 ### A la carte actions
 
 
-If you find yourself in this situation, you have some options. First, you can
-run borgmatic's `prune`, `compact`, `create`, or `check` actions separately.
-For instance, the following optional actions are available:
+If you find yourself wanting to customize the actions, you have some options.
+First, you can run borgmatic's `prune`, `compact`, `create`, or `check`
+actions separately. For instance, the following optional actions are
+available (among others):
 
 
 ```bash
 ```bash
+borgmatic create
 borgmatic prune
 borgmatic prune
 borgmatic compact
 borgmatic compact
-borgmatic create
 borgmatic check
 borgmatic check
 ```
 ```
 
 
-You can run with only one of these actions provided, or you can mix and match
-any number of them in a single borgmatic run. This supports approaches like
-skipping certain actions while running others. For instance, this skips
-`prune` and `compact` and only runs `create` and `check`:
+You can run borgmatic with only one of these actions provided, or you can mix
+and match any number of them in a single borgmatic run. This supports
+approaches like skipping certain actions while running others. For instance,
+this skips `prune` and `compact` and only runs `create` and `check`:
 
 
 ```bash
 ```bash
 borgmatic create check
 borgmatic create check
 ```
 ```
 
 
-Or, you can make backups with `create` on a frequent schedule (e.g. with
-`borgmatic create` called from one cron job), while only running expensive
-consistency checks with `check` on a much less frequent basis (e.g. with
-`borgmatic check` called from a separate cron job).
+<span class="minilink minilink-addedin">New in version 1.7.9</span> borgmatic
+now respects your specified command-line action order, running actions in the
+order you specify. In previous versions, borgmatic ran your specified actions
+in a fixed ordering regardless of the order they appeared on the command-line.
+
+But instead of running actions together, another option is to run backups with
+`create` on a frequent schedule (e.g. with `borgmatic create` called from one
+cron job), while only running expensive consistency checks with `check` on a
+much less frequent basis (e.g. with `borgmatic check` called from a separate
+cron job).
 
 
 
 
 ### Consistency check configuration
 ### Consistency check configuration
@@ -47,8 +57,8 @@ consistency checks with `check` on a much less frequent basis (e.g. with
 Another option is to customize your consistency checks. By default, if you
 Another option is to customize your consistency checks. By default, if you
 omit consistency checks from configuration, borgmatic runs full-repository
 omit consistency checks from configuration, borgmatic runs full-repository
 checks (`repository`) and per-archive checks (`archives`) within each
 checks (`repository`) and per-archive checks (`archives`) within each
-repository, no more than once a month. This is equivalent to what `borg check`
-does if run without options.
+repository. (Although see below about check frequency.) This is equivalent to
+what `borg check` does if run without options.
 
 
 But if you find that archive checks are too slow, for example, you can
 But if you find that archive checks are too slow, for example, you can
 configure borgmatic to run repository checks only. Configure this in the
 configure borgmatic to run repository checks only. Configure this in the
@@ -60,8 +70,9 @@ consistency:
         - name: repository
         - name: repository
 ```
 ```
 
 
-<span class="minilink minilink-addedin">Prior to version 1.6.2</span> `checks`
-was a plain list of strings without the `name:` part. For example:
+<span class="minilink minilink-addedin">Prior to version 1.6.2</span> The
+`checks` option was a plain list of strings without the `name:` part, and
+borgmatic ran each configured check every time checks were run. For example:
 
 
 ```yaml
 ```yaml
 consistency:
 consistency:
@@ -102,8 +113,13 @@ consistency:
 This tells borgmatic to run the `repository` consistency check at most once
 This tells borgmatic to run the `repository` consistency check at most once
 every two weeks for a given repository and the `archives` check at most once a
 every two weeks for a given repository and the `archives` check at most once a
 month. The `frequency` value is a number followed by a unit of time, e.g. "3
 month. The `frequency` value is a number followed by a unit of time, e.g. "3
-days", "1 week", "2 months", etc. The `frequency` defaults to `always`, which
-means run this check every time checks run.
+days", "1 week", "2 months", etc.
+
+The `frequency` defaults to `always` for a check configured without a
+`frequency`, which means run this check every time checks run. But if you omit
+consistency checks from configuration entirely, borgmatic runs full-repository
+checks (`repository`) and per-archive checks (`archives`) within each
+repository, at most once a month.
 
 
 Unlike a real scheduler like cron, borgmatic only makes a best effort to run
 Unlike a real scheduler like cron, borgmatic only makes a best effort to run
 checks on the configured frequency. It compares that frequency with how long
 checks on the configured frequency. It compares that frequency with how long

+ 1 - 1
docs/how-to/develop-on-borgmatic.md

@@ -26,7 +26,7 @@ make sure your changes work.
 
 
 ```bash
 ```bash
 cd borgmatic/
 cd borgmatic/
-pip3 install --editable --user .
+pip3 install --user --editable .
 ```
 ```
 
 
 Note that this will typically install the borgmatic commands into
 Note that this will typically install the borgmatic commands into

+ 11 - 4
docs/how-to/extract-a-backup.md

@@ -20,15 +20,15 @@ borgmatic rlist
 That should yield output looking something like:
 That should yield output looking something like:
 
 
 ```text
 ```text
-host-2019-01-01T04:05:06.070809      Tue, 2019-01-01 04:05:06 [...]
-host-2019-01-02T04:06:07.080910      Wed, 2019-01-02 04:06:07 [...]
+host-2023-01-01T04:05:06.070809      Tue, 2023-01-01 04:05:06 [...]
+host-2023-01-02T04:06:07.080910      Wed, 2023-01-02 04:06:07 [...]
 ```
 ```
 
 
 Assuming that you want to extract the archive with the most up-to-date files
 Assuming that you want to extract the archive with the most up-to-date files
 and therefore the latest timestamp, run a command like:
 and therefore the latest timestamp, run a command like:
 
 
 ```bash
 ```bash
-borgmatic extract --archive host-2019-01-02T04:06:07.080910
+borgmatic extract --archive host-2023-01-02T04:06:07.080910
 ```
 ```
 
 
 (No borgmatic `extract` action? Upgrade borgmatic!)
 (No borgmatic `extract` action? Upgrade borgmatic!)
@@ -54,7 +54,7 @@ But if you have multiple repositories configured, then you'll need to specify
 the repository path containing the archive to extract. Here's an example:
 the repository path containing the archive to extract. Here's an example:
 
 
 ```bash
 ```bash
-borgmatic extract --repository repo.borg --archive host-2019-...
+borgmatic extract --repository repo.borg --archive host-2023-...
 ```
 ```
 
 
 ## Extract particular files
 ## Extract particular files
@@ -74,6 +74,13 @@ run the `extract` command above, borgmatic will extract `/var/path/1` and
 `/var/path/2`.
 `/var/path/2`.
 
 
 
 
+### Searching for files
+
+If you're not sure which archive contains the files you're looking for, you
+can [search across
+archives](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/#searching-for-a-file).
+
+
 ## Extract to a particular destination
 ## Extract to a particular destination
 
 
 By default, borgmatic extracts files into the current directory. To instead
 By default, borgmatic extracts files into the current directory. To instead

+ 13 - 0
docs/how-to/inspect-your-backups.md

@@ -91,6 +91,19 @@ example, to search only the last five archives:
 borgmatic list --find foo.txt --last 5
 borgmatic list --find foo.txt --last 5
 ```
 ```
 
 
+## Listing database dumps
+
+If you have enabled borgmatic's [database
+hooks](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/), you
+can list backed up database dumps via borgmatic. For example:
+
+```bash 
+borgmatic list --archive latest --find .borgmatic/*_databases
+```
+
+This gives you a listing of all database dump files contained in the latest
+archive, complete with file sizes.
+
 
 
 ## Logging
 ## Logging
 
 

+ 6 - 6
docs/how-to/monitor-your-backups.md

@@ -83,7 +83,7 @@ tests](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/).
 
 
 ## Error hooks
 ## Error hooks
 
 
-When an error occurs during a `prune`, `compact`, `create`, or `check` action,
+When an error occurs during a `create`, `prune`, `compact`, or `check` action,
 borgmatic can run configurable shell commands to fire off custom error
 borgmatic can run configurable shell commands to fire off custom error
 notifications or take other actions, so you can get alerted as soon as
 notifications or take other actions, so you can get alerted as soon as
 something goes wrong. Here's a not-so-useful example:
 something goes wrong. Here's a not-so-useful example:
@@ -116,8 +116,8 @@ the repository. Here's the full set of supported variables you can use here:
  * `output`: output of the command that failed (may be blank if an error
  * `output`: output of the command that failed (may be blank if an error
    occurred without running a command)
    occurred without running a command)
 
 
-Note that borgmatic runs the `on_error` hooks only for `prune`, `compact`,
-`create`, or `check` actions or hooks in which an error occurs, and not other
+Note that borgmatic runs the `on_error` hooks only for `create`, `prune`,
+`compact`, or `check` actions or hooks in which an error occurs, and not other
 actions. borgmatic does not run `on_error` hooks if an error occurs within a
 actions. borgmatic does not run `on_error` hooks if an error occurs within a
 `before_everything` or `after_everything` hook. For more about hooks, see the
 `before_everything` or `after_everything` hook. For more about hooks, see the
 [borgmatic hooks
 [borgmatic hooks
@@ -144,7 +144,7 @@ With this hook in place, borgmatic pings your Healthchecks project when a
 backup begins, ends, or errors. Specifically, after the <a
 backup begins, ends, or errors. Specifically, after the <a
 href="https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/">`before_backup`
 href="https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/">`before_backup`
 hooks</a> run, borgmatic lets Healthchecks know that it has started if any of
 hooks</a> run, borgmatic lets Healthchecks know that it has started if any of
-the `prune`, `compact`, `create`, or `check` actions are run.
+the `create`, `prune`, `compact`, or `check` actions are run.
 
 
 Then, if the actions complete successfully, borgmatic notifies Healthchecks of
 Then, if the actions complete successfully, borgmatic notifies Healthchecks of
 the success after the `after_backup` hooks run, and includes borgmatic logs in
 the success after the `after_backup` hooks run, and includes borgmatic logs in
@@ -154,8 +154,8 @@ in the Healthchecks UI, although be aware that Healthchecks currently has a
 
 
 If an error occurs during any action or hook, borgmatic notifies Healthchecks
 If an error occurs during any action or hook, borgmatic notifies Healthchecks
 after the `on_error` hooks run, also tacking on logs including the error
 after the `on_error` hooks run, also tacking on logs including the error
-itself. But the logs are only included for errors that occur when a `prune`,
-`compact`, `create`, or `check` action is run.
+itself. But the logs are only included for errors that occur when a `create`,
+`prune`, `compact`, or `check` action is run.
 
 
 You can customize the verbosity of the logs that are sent to Healthchecks with
 You can customize the verbosity of the logs that are sent to Healthchecks with
 borgmatic's `--monitoring-verbosity` flag. The `--list` and `--stats` flags
 borgmatic's `--monitoring-verbosity` flag. The `--list` and `--stats` flags

+ 1 - 0
docs/how-to/set-up-backups.md

@@ -94,6 +94,7 @@ installing borgmatic:
  * [openSUSE](https://software.opensuse.org/package/borgmatic)
  * [openSUSE](https://software.opensuse.org/package/borgmatic)
  * [macOS (via Homebrew)](https://formulae.brew.sh/formula/borgmatic)
  * [macOS (via Homebrew)](https://formulae.brew.sh/formula/borgmatic)
  * [macOS (via MacPorts)](https://ports.macports.org/port/borgmatic/)
  * [macOS (via MacPorts)](https://ports.macports.org/port/borgmatic/)
+ * [NixOS](https://search.nixos.org/packages?show=borgmatic&sort=relevance&type=packages&query=borgmatic)
  * [Ansible role](https://github.com/borgbase/ansible-role-borgbackup)
  * [Ansible role](https://github.com/borgbase/ansible-role-borgbackup)
  * [virtualenv](https://virtualenv.pypa.io/en/stable/)
  * [virtualenv](https://virtualenv.pypa.io/en/stable/)
 
 

+ 2 - 0
setup.cfg

@@ -10,6 +10,8 @@ filterwarnings =
 [flake8]
 [flake8]
 ignore = E501,W503
 ignore = E501,W503
 exclude = *.*/*
 exclude = *.*/*
+multiline-quotes = '''
+docstring-quotes = '''
 
 
 [tool:isort]
 [tool:isort]
 force_single_line = False
 force_single_line = False

+ 1 - 1
setup.py

@@ -1,6 +1,6 @@
 from setuptools import find_packages, setup
 from setuptools import find_packages, setup
 
 
-VERSION = '1.7.9.dev0'
+VERSION = '1.7.10.dev0'
 
 
 
 
 setup(
 setup(

+ 1 - 0
test_requirements.txt

@@ -5,6 +5,7 @@ click==7.1.2; python_version >= '3.8'
 colorama==0.4.4
 colorama==0.4.4
 coverage==5.3
 coverage==5.3
 flake8==4.0.1
 flake8==4.0.1
+flake8-quotes==3.3.2
 flexmock==0.10.4
 flexmock==0.10.4
 isort==5.9.1
 isort==5.9.1
 mccabe==0.6.1
 mccabe==0.6.1

+ 0 - 7
tests/integration/commands/test_arguments.py

@@ -254,13 +254,6 @@ def test_parse_arguments_allows_init_and_create():
     module.parse_arguments('--config', 'myconfig', 'init', '--encryption', 'repokey', 'create')
     module.parse_arguments('--config', 'myconfig', 'init', '--encryption', 'repokey', 'create')
 
 
 
 
-def test_parse_arguments_disallows_repository_unless_action_consumes_it():
-    flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
-
-    with pytest.raises(SystemExit):
-        module.parse_arguments('--config', 'myconfig', '--repository', 'test.borg')
-
-
 def test_parse_arguments_allows_repository_with_extract():
 def test_parse_arguments_allows_repository_with_extract():
     flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
     flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
 
 

+ 66 - 3
tests/unit/actions/test_check.py

@@ -3,15 +3,78 @@ from flexmock import flexmock
 from borgmatic.actions import check as module
 from borgmatic.actions import check as module
 
 
 
 
-def test_run_check_calls_hooks():
+def test_run_check_calls_hooks_for_configured_repository():
     flexmock(module.logger).answer = lambda message: None
     flexmock(module.logger).answer = lambda message: None
     flexmock(module.borgmatic.config.checks).should_receive(
     flexmock(module.borgmatic.config.checks).should_receive(
         'repository_enabled_for_checks'
         'repository_enabled_for_checks'
     ).and_return(True)
     ).and_return(True)
-    flexmock(module.borgmatic.borg.check).should_receive('check_archives')
+    flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
+    flexmock(module.borgmatic.borg.check).should_receive('check_archives').once()
     flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
     flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
     check_arguments = flexmock(
     check_arguments = flexmock(
-        progress=flexmock(), repair=flexmock(), only=flexmock(), force=flexmock()
+        repository=None, progress=flexmock(), repair=flexmock(), only=flexmock(), force=flexmock(),
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_check(
+        config_filename='test.yaml',
+        repository='repo',
+        location={'repositories': ['repo']},
+        storage={},
+        consistency={},
+        hooks={},
+        hook_context={},
+        local_borg_version=None,
+        check_arguments=check_arguments,
+        global_arguments=global_arguments,
+        local_path=None,
+        remote_path=None,
+    )
+
+
+def test_run_check_runs_with_selected_repository():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive(
+        'repositories_match'
+    ).once().and_return(True)
+    flexmock(module.borgmatic.borg.check).should_receive('check_archives').once()
+    check_arguments = flexmock(
+        repository=flexmock(),
+        progress=flexmock(),
+        repair=flexmock(),
+        only=flexmock(),
+        force=flexmock(),
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_check(
+        config_filename='test.yaml',
+        repository=flexmock(),
+        location={'repositories': ['repo']},
+        storage={},
+        consistency={},
+        hooks={},
+        hook_context={},
+        local_borg_version=None,
+        check_arguments=check_arguments,
+        global_arguments=global_arguments,
+        local_path=None,
+        remote_path=None,
+    )
+
+
+def test_run_check_bails_if_repository_does_not_match():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive(
+        'repositories_match'
+    ).once().and_return(False)
+    flexmock(module.borgmatic.borg.check).should_receive('check_archives').never()
+    check_arguments = flexmock(
+        repository=flexmock(),
+        progress=flexmock(),
+        repair=flexmock(),
+        only=flexmock(),
+        force=flexmock(),
     )
     )
     global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
     global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
 
 

+ 60 - 3
tests/unit/actions/test_compact.py

@@ -3,13 +3,70 @@ from flexmock import flexmock
 from borgmatic.actions import compact as module
 from borgmatic.actions import compact as module
 
 
 
 
-def test_compact_actions_calls_hooks():
+def test_compact_actions_calls_hooks_for_configured_repository():
     flexmock(module.logger).answer = lambda message: None
     flexmock(module.logger).answer = lambda message: None
     flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
-    flexmock(module.borgmatic.borg.compact).should_receive('compact_segments')
+    flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
+    flexmock(module.borgmatic.borg.compact).should_receive('compact_segments').once()
     flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
     flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
     compact_arguments = flexmock(
     compact_arguments = flexmock(
-        progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock()
+        repository=None, progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock()
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_compact(
+        config_filename='test.yaml',
+        repository='repo',
+        storage={},
+        retention={},
+        hooks={},
+        hook_context={},
+        local_borg_version=None,
+        compact_arguments=compact_arguments,
+        global_arguments=global_arguments,
+        dry_run_label='',
+        local_path=None,
+        remote_path=None,
+    )
+
+
+def test_compact_runs_with_selected_repository():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive(
+        'repositories_match'
+    ).once().and_return(True)
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
+    flexmock(module.borgmatic.borg.compact).should_receive('compact_segments').once()
+    compact_arguments = flexmock(
+        repository=flexmock(), progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock()
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_compact(
+        config_filename='test.yaml',
+        repository='repo',
+        storage={},
+        retention={},
+        hooks={},
+        hook_context={},
+        local_borg_version=None,
+        compact_arguments=compact_arguments,
+        global_arguments=global_arguments,
+        dry_run_label='',
+        local_path=None,
+        remote_path=None,
+    )
+
+
+def test_compact_bails_if_repository_does_not_match():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
+    flexmock(module.borgmatic.config.validate).should_receive(
+        'repositories_match'
+    ).once().and_return(False)
+    flexmock(module.borgmatic.borg.compact).should_receive('compact_segments').never()
+    compact_arguments = flexmock(
+        repository=flexmock(), progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock()
     )
     )
     global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
     global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
 
 

+ 74 - 3
tests/unit/actions/test_create.py

@@ -3,16 +3,87 @@ from flexmock import flexmock
 from borgmatic.actions import create as module
 from borgmatic.actions import create as module
 
 
 
 
-def test_run_create_executes_and_calls_hooks():
+def test_run_create_executes_and_calls_hooks_for_configured_repository():
     flexmock(module.logger).answer = lambda message: None
     flexmock(module.logger).answer = lambda message: None
-    flexmock(module.borgmatic.borg.create).should_receive('create_archive')
+    flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
+    flexmock(module.borgmatic.borg.create).should_receive('create_archive').once()
     flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
     flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
     flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return({})
     flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return({})
     flexmock(module.borgmatic.hooks.dispatch).should_receive(
     flexmock(module.borgmatic.hooks.dispatch).should_receive(
         'call_hooks_even_if_unconfigured'
         'call_hooks_even_if_unconfigured'
     ).and_return({})
     ).and_return({})
     create_arguments = flexmock(
     create_arguments = flexmock(
-        progress=flexmock(), stats=flexmock(), json=flexmock(), list_files=flexmock()
+        repository=None,
+        progress=flexmock(),
+        stats=flexmock(),
+        json=flexmock(),
+        list_files=flexmock(),
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    list(
+        module.run_create(
+            config_filename='test.yaml',
+            repository='repo',
+            location={},
+            storage={},
+            hooks={},
+            hook_context={},
+            local_borg_version=None,
+            create_arguments=create_arguments,
+            global_arguments=global_arguments,
+            dry_run_label='',
+            local_path=None,
+            remote_path=None,
+        )
+    )
+
+
+def test_run_create_runs_with_selected_repository():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive(
+        'repositories_match'
+    ).once().and_return(True)
+    flexmock(module.borgmatic.borg.create).should_receive('create_archive').once()
+    create_arguments = flexmock(
+        repository=flexmock(),
+        progress=flexmock(),
+        stats=flexmock(),
+        json=flexmock(),
+        list_files=flexmock(),
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    list(
+        module.run_create(
+            config_filename='test.yaml',
+            repository='repo',
+            location={},
+            storage={},
+            hooks={},
+            hook_context={},
+            local_borg_version=None,
+            create_arguments=create_arguments,
+            global_arguments=global_arguments,
+            dry_run_label='',
+            local_path=None,
+            remote_path=None,
+        )
+    )
+
+
+def test_run_create_bails_if_repository_does_not_match():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive(
+        'repositories_match'
+    ).once().and_return(False)
+    flexmock(module.borgmatic.borg.create).should_receive('create_archive').never()
+    create_arguments = flexmock(
+        repository=flexmock(),
+        progress=flexmock(),
+        stats=flexmock(),
+        json=flexmock(),
+        list_files=flexmock(),
     )
     )
     global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
     global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
 
 

+ 54 - 3
tests/unit/actions/test_prune.py

@@ -3,11 +3,62 @@ from flexmock import flexmock
 from borgmatic.actions import prune as module
 from borgmatic.actions import prune as module
 
 
 
 
-def test_run_prune_calls_hooks():
+def test_run_prune_calls_hooks_for_configured_repository():
     flexmock(module.logger).answer = lambda message: None
     flexmock(module.logger).answer = lambda message: None
-    flexmock(module.borgmatic.borg.prune).should_receive('prune_archives')
+    flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
+    flexmock(module.borgmatic.borg.prune).should_receive('prune_archives').once()
     flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
     flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
-    prune_arguments = flexmock(stats=flexmock(), list_archives=flexmock())
+    prune_arguments = flexmock(repository=None, stats=flexmock(), list_archives=flexmock())
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_prune(
+        config_filename='test.yaml',
+        repository='repo',
+        storage={},
+        retention={},
+        hooks={},
+        hook_context={},
+        local_borg_version=None,
+        prune_arguments=prune_arguments,
+        global_arguments=global_arguments,
+        dry_run_label='',
+        local_path=None,
+        remote_path=None,
+    )
+
+
+def test_run_prune_runs_with_selected_repository():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive(
+        'repositories_match'
+    ).once().and_return(True)
+    flexmock(module.borgmatic.borg.prune).should_receive('prune_archives').once()
+    prune_arguments = flexmock(repository=flexmock(), stats=flexmock(), list_archives=flexmock())
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_prune(
+        config_filename='test.yaml',
+        repository='repo',
+        storage={},
+        retention={},
+        hooks={},
+        hook_context={},
+        local_borg_version=None,
+        prune_arguments=prune_arguments,
+        global_arguments=global_arguments,
+        dry_run_label='',
+        local_path=None,
+        remote_path=None,
+    )
+
+
+def test_run_prune_bails_if_repository_does_not_match():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive(
+        'repositories_match'
+    ).once().and_return(False)
+    flexmock(module.borgmatic.borg.prune).should_receive('prune_archives').never()
+    prune_arguments = flexmock(repository=flexmock(), stats=flexmock(), list_archives=flexmock())
     global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
     global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
 
 
     module.run_prune(
     module.run_prune(

+ 25 - 2
tests/unit/borg/test_create.py

@@ -207,7 +207,6 @@ def test_make_exclude_flags_includes_exclude_patterns_filename_when_given():
 
 
 
 
 def test_make_exclude_flags_includes_exclude_from_filenames_when_in_config():
 def test_make_exclude_flags_includes_exclude_from_filenames_when_in_config():
-
     exclude_flags = module.make_exclude_flags(
     exclude_flags = module.make_exclude_flags(
         location_config={'exclude_from': ['excludes', 'other']}
         location_config={'exclude_from': ['excludes', 'other']}
     )
     )
@@ -1916,7 +1915,7 @@ def test_create_archive_with_stream_processes_ignores_read_special_false_and_log
         (f'repo::{DEFAULT_ARCHIVE_NAME}',)
         (f'repo::{DEFAULT_ARCHIVE_NAME}',)
     )
     )
     flexmock(module.environment).should_receive('make_environment')
     flexmock(module.environment).should_receive('make_environment')
-    flexmock(module).should_receive('collect_special_file_paths').and_return(("/dev/null",))
+    flexmock(module).should_receive('collect_special_file_paths').and_return(('/dev/null',))
     create_command = (
     create_command = (
         'borg',
         'borg',
         'create',
         'create',
@@ -2530,3 +2529,27 @@ def test_create_archive_with_stream_processes_calls_borg_with_processes_and_read
         local_borg_version='1.2.3',
         local_borg_version='1.2.3',
         stream_processes=processes,
         stream_processes=processes,
     )
     )
+
+
+def test_create_archive_with_non_existent_directory_and_source_directories_must_exist_raises_error():
+    '''
+    If a source directory doesn't exist and source_directories_must_exist is True, raise an error.
+    '''
+    flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
+    flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
+    flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([])
+    flexmock(module.os.path).should_receive('exists').and_return(False)
+
+    with pytest.raises(ValueError):
+        module.create_archive(
+            dry_run=False,
+            repository='repo',
+            location_config={
+                'source_directories': ['foo', 'bar'],
+                'repositories': ['repo'],
+                'exclude_patterns': None,
+                'source_directories_must_exist': True,
+            },
+            storage_config={},
+            local_borg_version='1.2.3',
+        )

+ 5 - 0
tests/unit/borg/test_environment.py

@@ -32,3 +32,8 @@ def test_make_environment_with_relocated_repo_access_should_override_default():
     environment = module.make_environment({'relocated_repo_access_is_ok': True})
     environment = module.make_environment({'relocated_repo_access_is_ok': True})
 
 
     assert environment.get('BORG_RELOCATED_REPO_ACCESS_IS_OK') == 'yes'
     assert environment.get('BORG_RELOCATED_REPO_ACCESS_IS_OK') == 'yes'
+
+
+def test_make_environment_with_integer_variable_value():
+    environment = module.make_environment({'borg_files_cache_ttl': 40})
+    assert environment.get('BORG_FILES_CACHE_TTL') == '40'

+ 51 - 0
tests/unit/borg/test_extract.py

@@ -312,6 +312,57 @@ def test_extract_archive_calls_borg_with_strip_components():
     )
     )
 
 
 
 
+def test_extract_archive_calls_borg_with_strip_components_calculated_from_all():
+    flexmock(module.os.path).should_receive('abspath').and_return('repo')
+    insert_execute_command_mock(
+        (
+            'borg',
+            'extract',
+            '--strip-components',
+            '2',
+            'repo::archive',
+            'foo/bar/baz.txt',
+            'foo/bar.txt',
+        )
+    )
+    flexmock(module.feature).should_receive('available').and_return(True)
+    flexmock(module.flags).should_receive('make_repository_archive_flags').and_return(
+        ('repo::archive',)
+    )
+
+    module.extract_archive(
+        dry_run=False,
+        repository='repo',
+        archive='archive',
+        paths=['foo/bar/baz.txt', 'foo/bar.txt'],
+        location_config={},
+        storage_config={},
+        local_borg_version='1.2.3',
+        strip_components='all',
+    )
+
+
+def test_extract_archive_with_strip_components_all_and_no_paths_raises():
+    flexmock(module.os.path).should_receive('abspath').and_return('repo')
+    flexmock(module.feature).should_receive('available').and_return(True)
+    flexmock(module.flags).should_receive('make_repository_archive_flags').and_return(
+        ('repo::archive',)
+    )
+    flexmock(module).should_receive('execute_command').never()
+
+    with pytest.raises(ValueError):
+        module.extract_archive(
+            dry_run=False,
+            repository='repo',
+            archive='archive',
+            paths=None,
+            location_config={},
+            storage_config={},
+            local_borg_version='1.2.3',
+            strip_components='all',
+        )
+
+
 def test_extract_archive_calls_borg_with_progress_parameter():
 def test_extract_archive_calls_borg_with_progress_parameter():
     flexmock(module.os.path).should_receive('abspath').and_return('repo')
     flexmock(module.os.path).should_receive('abspath').and_return('repo')
     flexmock(module.environment).should_receive('make_environment')
     flexmock(module.environment).should_receive('make_environment')

+ 22 - 0
tests/unit/commands/test_arguments.py

@@ -1,3 +1,5 @@
+import collections
+
 from flexmock import flexmock
 from flexmock import flexmock
 
 
 from borgmatic.commands import arguments as module
 from borgmatic.commands import arguments as module
@@ -70,6 +72,26 @@ def test_parse_subparser_arguments_consumes_multiple_subparser_arguments():
     assert remaining_arguments == []
     assert remaining_arguments == []
 
 
 
 
+def test_parse_subparser_arguments_respects_command_line_action_ordering():
+    other_namespace = flexmock()
+    action_namespace = flexmock(foo=True)
+    subparsers = {
+        'action': flexmock(
+            parse_known_args=lambda arguments: (action_namespace, ['action', '--foo', 'true'])
+        ),
+        'other': flexmock(parse_known_args=lambda arguments: (other_namespace, ['other'])),
+    }
+
+    arguments, remaining_arguments = module.parse_subparser_arguments(
+        ('other', '--foo', 'true', 'action'), subparsers
+    )
+
+    assert arguments == collections.OrderedDict(
+        [('other', other_namespace), ('action', action_namespace)]
+    )
+    assert remaining_arguments == []
+
+
 def test_parse_subparser_arguments_applies_default_subparsers():
 def test_parse_subparser_arguments_applies_default_subparsers():
     prune_namespace = flexmock()
     prune_namespace = flexmock()
     compact_namespace = flexmock()
     compact_namespace = flexmock()

+ 78 - 16
tests/unit/commands/test_borgmatic.py

@@ -40,7 +40,7 @@ def test_run_configuration_logs_monitor_start_error():
     flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
     flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
     flexmock(module.dispatch).should_receive('call_hooks').and_raise(OSError).and_return(
     flexmock(module.dispatch).should_receive('call_hooks').and_raise(OSError).and_return(
         None
         None
-    ).and_return(None)
+    ).and_return(None).and_return(None)
     expected_results = [flexmock()]
     expected_results = [flexmock()]
     flexmock(module).should_receive('log_error_records').and_return(expected_results)
     flexmock(module).should_receive('log_error_records').and_return(expected_results)
     flexmock(module).should_receive('run_actions').never()
     flexmock(module).should_receive('run_actions').never()
@@ -99,7 +99,7 @@ def test_run_configuration_bails_for_actions_soft_failure():
     assert results == []
     assert results == []
 
 
 
 
-def test_run_configuration_logs_monitor_finish_error():
+def test_run_configuration_logs_monitor_log_error():
     flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
     flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
     flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
     flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
     flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
     flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
@@ -116,7 +116,7 @@ def test_run_configuration_logs_monitor_finish_error():
     assert results == expected_results
     assert results == expected_results
 
 
 
 
-def test_run_configuration_bails_for_monitor_finish_soft_failure():
+def test_run_configuration_bails_for_monitor_log_soft_failure():
     flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
     flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
     flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
     flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
     error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
     error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
@@ -134,6 +134,41 @@ def test_run_configuration_bails_for_monitor_finish_soft_failure():
     assert results == []
     assert results == []
 
 
 
 
+def test_run_configuration_logs_monitor_finish_error():
+    flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
+    flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
+    flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
+        None
+    ).and_return(None).and_raise(OSError)
+    expected_results = [flexmock()]
+    flexmock(module).should_receive('log_error_records').and_return(expected_results)
+    flexmock(module).should_receive('run_actions').and_return([])
+    config = {'location': {'repositories': ['foo']}}
+    arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
+
+    results = list(module.run_configuration('test.yaml', config, arguments))
+
+    assert results == expected_results
+
+
+def test_run_configuration_bails_for_monitor_finish_soft_failure():
+    flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
+    flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
+    error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
+    flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
+        None
+    ).and_raise(None).and_raise(error)
+    flexmock(module).should_receive('log_error_records').never()
+    flexmock(module).should_receive('run_actions').and_return([])
+    flexmock(module.command).should_receive('considered_soft_failure').and_return(True)
+    config = {'location': {'repositories': ['foo']}}
+    arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
+
+    results = list(module.run_configuration('test.yaml', config, arguments))
+
+    assert results == []
+
+
 def test_run_configuration_logs_on_error_hook_error():
 def test_run_configuration_logs_on_error_hook_error():
     flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
     flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
     flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
     flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
@@ -401,14 +436,15 @@ def test_run_actions_runs_transfer():
     )
     )
 
 
 
 
-def test_run_actions_runs_prune():
+def test_run_actions_runs_create():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module.command).should_receive('execute_hook')
     flexmock(module.command).should_receive('execute_hook')
-    flexmock(borgmatic.actions.prune).should_receive('run_prune').once()
+    expected = flexmock()
+    flexmock(borgmatic.actions.create).should_receive('run_create').and_yield(expected).once()
 
 
-    tuple(
+    result = tuple(
         module.run_actions(
         module.run_actions(
-            arguments={'global': flexmock(dry_run=False), 'prune': flexmock()},
+            arguments={'global': flexmock(dry_run=False), 'create': flexmock()},
             config_filename=flexmock(),
             config_filename=flexmock(),
             location={'repositories': []},
             location={'repositories': []},
             storage=flexmock(),
             storage=flexmock(),
@@ -421,16 +457,17 @@ def test_run_actions_runs_prune():
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )
+    assert result == (expected,)
 
 
 
 
-def test_run_actions_runs_compact():
+def test_run_actions_runs_prune():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module.command).should_receive('execute_hook')
     flexmock(module.command).should_receive('execute_hook')
-    flexmock(borgmatic.actions.compact).should_receive('run_compact').once()
+    flexmock(borgmatic.actions.prune).should_receive('run_prune').once()
 
 
     tuple(
     tuple(
         module.run_actions(
         module.run_actions(
-            arguments={'global': flexmock(dry_run=False), 'compact': flexmock()},
+            arguments={'global': flexmock(dry_run=False), 'prune': flexmock()},
             config_filename=flexmock(),
             config_filename=flexmock(),
             location={'repositories': []},
             location={'repositories': []},
             storage=flexmock(),
             storage=flexmock(),
@@ -445,15 +482,14 @@ def test_run_actions_runs_compact():
     )
     )
 
 
 
 
-def test_run_actions_runs_create():
+def test_run_actions_runs_compact():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module.command).should_receive('execute_hook')
     flexmock(module.command).should_receive('execute_hook')
-    expected = flexmock()
-    flexmock(borgmatic.actions.create).should_receive('run_create').and_yield(expected).once()
+    flexmock(borgmatic.actions.compact).should_receive('run_compact').once()
 
 
-    result = tuple(
+    tuple(
         module.run_actions(
         module.run_actions(
-            arguments={'global': flexmock(dry_run=False), 'create': flexmock()},
+            arguments={'global': flexmock(dry_run=False), 'compact': flexmock()},
             config_filename=flexmock(),
             config_filename=flexmock(),
             location={'repositories': []},
             location={'repositories': []},
             storage=flexmock(),
             storage=flexmock(),
@@ -466,7 +502,6 @@ def test_run_actions_runs_create():
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )
-    assert result == (expected,)
 
 
 
 
 def test_run_actions_runs_check_when_repository_enabled_for_checks():
 def test_run_actions_runs_check_when_repository_enabled_for_checks():
@@ -743,6 +778,33 @@ def test_run_actions_runs_borg():
     )
     )
 
 
 
 
+def test_run_actions_runs_multiple_actions_in_argument_order():
+    flexmock(module).should_receive('add_custom_log_levels')
+    flexmock(module.command).should_receive('execute_hook')
+    flexmock(borgmatic.actions.borg).should_receive('run_borg').once().ordered()
+    flexmock(borgmatic.actions.restore).should_receive('run_restore').once().ordered()
+
+    tuple(
+        module.run_actions(
+            arguments={
+                'global': flexmock(dry_run=False),
+                'borg': flexmock(),
+                'restore': flexmock(),
+            },
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
+            hooks={},
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
+            repository_path='repo',
+        )
+    )
+
+
 def test_load_configurations_collects_parsed_configurations_and_logs():
 def test_load_configurations_collects_parsed_configurations_and_logs():
     configuration = flexmock()
     configuration = flexmock()
     other_configuration = flexmock()
     other_configuration = flexmock()

+ 8 - 0
tests/unit/hooks/test_cronhub.py

@@ -102,3 +102,11 @@ def test_ping_monitor_with_other_error_logs_warning():
         monitoring_log_level=1,
         monitoring_log_level=1,
         dry_run=False,
         dry_run=False,
     )
     )
+
+
+def test_ping_monitor_with_unsupported_monitoring_state():
+    hook_config = {'ping_url': 'https://example.com'}
+    flexmock(module.requests).should_receive('get').never()
+    module.ping_monitor(
+        hook_config, 'config.yaml', module.monitor.State.LOG, monitoring_log_level=1, dry_run=False,
+    )

+ 8 - 0
tests/unit/hooks/test_cronitor.py

@@ -87,3 +87,11 @@ def test_ping_monitor_with_other_error_logs_warning():
         monitoring_log_level=1,
         monitoring_log_level=1,
         dry_run=False,
         dry_run=False,
     )
     )
+
+
+def test_ping_monitor_with_unsupported_monitoring_state():
+    hook_config = {'ping_url': 'https://example.com'}
+    flexmock(module.requests).should_receive('get').never()
+    module.ping_monitor(
+        hook_config, 'config.yaml', module.monitor.State.LOG, monitoring_log_level=1, dry_run=False,
+    )

+ 17 - 0
tests/unit/hooks/test_healthchecks.py

@@ -184,6 +184,23 @@ def test_ping_monitor_hits_ping_url_for_fail_state():
     )
     )
 
 
 
 
+def test_ping_monitor_hits_ping_url_for_log_state():
+    hook_config = {'ping_url': 'https://example.com'}
+    payload = 'data'
+    flexmock(module).should_receive('format_buffered_logs_for_payload').and_return(payload)
+    flexmock(module.requests).should_receive('post').with_args(
+        'https://example.com/log', data=payload.encode('utf'), verify=True
+    ).and_return(flexmock(ok=True))
+
+    module.ping_monitor(
+        hook_config,
+        'config.yaml',
+        state=module.monitor.State.LOG,
+        monitoring_log_level=1,
+        dry_run=False,
+    )
+
+
 def test_ping_monitor_with_ping_uuid_hits_corresponding_url():
 def test_ping_monitor_with_ping_uuid_hits_corresponding_url():
     hook_config = {'ping_url': 'abcd-efgh-ijkl-mnop'}
     hook_config = {'ping_url': 'abcd-efgh-ijkl-mnop'}
     payload = 'data'
     payload = 'data'

+ 1 - 1
tests/unit/hooks/test_mongodb.py

@@ -72,7 +72,7 @@ def test_dump_databases_runs_mongodump_with_username_and_password():
             'name': 'foo',
             'name': 'foo',
             'username': 'mongo',
             'username': 'mongo',
             'password': 'trustsome1',
             'password': 'trustsome1',
-            'authentication_database': "admin",
+            'authentication_database': 'admin',
         }
         }
     ]
     ]
     process = flexmock()
     process = flexmock()

+ 49 - 20
tests/unit/hooks/test_ntfy.py

@@ -2,6 +2,7 @@ from enum import Enum
 
 
 from flexmock import flexmock
 from flexmock import flexmock
 
 
+import borgmatic.hooks.monitor
 from borgmatic.hooks import ntfy as module
 from borgmatic.hooks import ntfy as module
 
 
 default_base_url = 'https://ntfy.sh'
 default_base_url = 'https://ntfy.sh'
@@ -37,12 +38,16 @@ def test_ping_monitor_minimal_config_hits_hosted_ntfy_on_fail():
     hook_config = {'topic': topic}
     hook_config = {'topic': topic}
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         f'{default_base_url}/{topic}',
         f'{default_base_url}/{topic}',
-        headers=return_default_message_headers(module.monitor.State.FAIL),
+        headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
         auth=None,
         auth=None,
     ).and_return(flexmock(ok=True)).once()
     ).and_return(flexmock(ok=True)).once()
 
 
     module.ping_monitor(
     module.ping_monitor(
-        hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
+        hook_config,
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
     )
     )
 
 
 
 
@@ -54,12 +59,16 @@ def test_ping_monitor_with_auth_hits_hosted_ntfy_on_fail():
     }
     }
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         f'{default_base_url}/{topic}',
         f'{default_base_url}/{topic}',
-        headers=return_default_message_headers(module.monitor.State.FAIL),
+        headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
         auth=module.requests.auth.HTTPBasicAuth('testuser', 'fakepassword'),
         auth=module.requests.auth.HTTPBasicAuth('testuser', 'fakepassword'),
     ).and_return(flexmock(ok=True)).once()
     ).and_return(flexmock(ok=True)).once()
 
 
     module.ping_monitor(
     module.ping_monitor(
-        hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
+        hook_config,
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
     )
     )
 
 
 
 
@@ -67,13 +76,17 @@ def test_ping_monitor_auth_with_no_username_warning():
     hook_config = {'topic': topic, 'password': 'fakepassword'}
     hook_config = {'topic': topic, 'password': 'fakepassword'}
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         f'{default_base_url}/{topic}',
         f'{default_base_url}/{topic}',
-        headers=return_default_message_headers(module.monitor.State.FAIL),
+        headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
         auth=None,
         auth=None,
     ).and_return(flexmock(ok=True)).once()
     ).and_return(flexmock(ok=True)).once()
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.logger).should_receive('warning').once()
 
 
     module.ping_monitor(
     module.ping_monitor(
-        hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
+        hook_config,
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
     )
     )
 
 
 
 
@@ -81,13 +94,17 @@ def test_ping_monitor_auth_with_no_password_warning():
     hook_config = {'topic': topic, 'username': 'testuser'}
     hook_config = {'topic': topic, 'username': 'testuser'}
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         f'{default_base_url}/{topic}',
         f'{default_base_url}/{topic}',
-        headers=return_default_message_headers(module.monitor.State.FAIL),
+        headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
         auth=None,
         auth=None,
     ).and_return(flexmock(ok=True)).once()
     ).and_return(flexmock(ok=True)).once()
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.logger).should_receive('warning').once()
 
 
     module.ping_monitor(
     module.ping_monitor(
-        hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
+        hook_config,
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
     )
     )
 
 
 
 
@@ -98,7 +115,7 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_start():
     module.ping_monitor(
     module.ping_monitor(
         hook_config,
         hook_config,
         'config.yaml',
         'config.yaml',
-        module.monitor.State.START,
+        borgmatic.hooks.monitor.State.START,
         monitoring_log_level=1,
         monitoring_log_level=1,
         dry_run=False,
         dry_run=False,
     )
     )
@@ -111,7 +128,7 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_finish():
     module.ping_monitor(
     module.ping_monitor(
         hook_config,
         hook_config,
         'config.yaml',
         'config.yaml',
-        module.monitor.State.FINISH,
+        borgmatic.hooks.monitor.State.FINISH,
         monitoring_log_level=1,
         monitoring_log_level=1,
         dry_run=False,
         dry_run=False,
     )
     )
@@ -121,12 +138,16 @@ def test_ping_monitor_minimal_config_hits_selfhosted_ntfy_on_fail():
     hook_config = {'topic': topic, 'server': custom_base_url}
     hook_config = {'topic': topic, 'server': custom_base_url}
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         f'{custom_base_url}/{topic}',
         f'{custom_base_url}/{topic}',
-        headers=return_default_message_headers(module.monitor.State.FAIL),
+        headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
         auth=None,
         auth=None,
     ).and_return(flexmock(ok=True)).once()
     ).and_return(flexmock(ok=True)).once()
 
 
     module.ping_monitor(
     module.ping_monitor(
-        hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
+        hook_config,
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
     )
     )
 
 
 
 
@@ -135,7 +156,11 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_fail_dry_run():
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
     module.ping_monitor(
     module.ping_monitor(
-        hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=True
+        hook_config,
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=True,
     )
     )
 
 
 
 
@@ -146,7 +171,11 @@ def test_ping_monitor_custom_message_hits_hosted_ntfy_on_fail():
     ).and_return(flexmock(ok=True)).once()
     ).and_return(flexmock(ok=True)).once()
 
 
     module.ping_monitor(
     module.ping_monitor(
-        hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
+        hook_config,
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
     )
     )
 
 
 
 
@@ -154,14 +183,14 @@ def test_ping_monitor_custom_state_hits_hosted_ntfy_on_start():
     hook_config = {'topic': topic, 'states': ['start', 'fail']}
     hook_config = {'topic': topic, 'states': ['start', 'fail']}
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         f'{default_base_url}/{topic}',
         f'{default_base_url}/{topic}',
-        headers=return_default_message_headers(module.monitor.State.START),
+        headers=return_default_message_headers(borgmatic.hooks.monitor.State.START),
         auth=None,
         auth=None,
     ).and_return(flexmock(ok=True)).once()
     ).and_return(flexmock(ok=True)).once()
 
 
     module.ping_monitor(
     module.ping_monitor(
         hook_config,
         hook_config,
         'config.yaml',
         'config.yaml',
-        module.monitor.State.START,
+        borgmatic.hooks.monitor.State.START,
         monitoring_log_level=1,
         monitoring_log_level=1,
         dry_run=False,
         dry_run=False,
     )
     )
@@ -171,7 +200,7 @@ def test_ping_monitor_with_connection_error_logs_warning():
     hook_config = {'topic': topic}
     hook_config = {'topic': topic}
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         f'{default_base_url}/{topic}',
         f'{default_base_url}/{topic}',
-        headers=return_default_message_headers(module.monitor.State.FAIL),
+        headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
         auth=None,
         auth=None,
     ).and_raise(module.requests.exceptions.ConnectionError)
     ).and_raise(module.requests.exceptions.ConnectionError)
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.logger).should_receive('warning').once()
@@ -179,7 +208,7 @@ def test_ping_monitor_with_connection_error_logs_warning():
     module.ping_monitor(
     module.ping_monitor(
         hook_config,
         hook_config,
         'config.yaml',
         'config.yaml',
-        module.monitor.State.FAIL,
+        borgmatic.hooks.monitor.State.FAIL,
         monitoring_log_level=1,
         monitoring_log_level=1,
         dry_run=False,
         dry_run=False,
     )
     )
@@ -193,7 +222,7 @@ def test_ping_monitor_with_other_error_logs_warning():
     )
     )
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         f'{default_base_url}/{topic}',
         f'{default_base_url}/{topic}',
-        headers=return_default_message_headers(module.monitor.State.FAIL),
+        headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
         auth=None,
         auth=None,
     ).and_return(response)
     ).and_return(response)
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.logger).should_receive('warning').once()
@@ -201,7 +230,7 @@ def test_ping_monitor_with_other_error_logs_warning():
     module.ping_monitor(
     module.ping_monitor(
         hook_config,
         hook_config,
         'config.yaml',
         'config.yaml',
-        module.monitor.State.FAIL,
+        borgmatic.hooks.monitor.State.FAIL,
         monitoring_log_level=1,
         monitoring_log_level=1,
         dry_run=False,
         dry_run=False,
     )
     )

+ 2 - 2
tox.ini

@@ -1,5 +1,5 @@
 [tox]
 [tox]
-envlist = py37,py38,py39,py310
+envlist = py37,py38,py39,py310,py311
 skip_missing_interpreters = True
 skip_missing_interpreters = True
 skipsdist = True
 skipsdist = True
 minversion = 3.14.1
 minversion = 3.14.1
@@ -13,7 +13,7 @@ whitelist_externals =
 passenv = COVERAGE_FILE
 passenv = COVERAGE_FILE
 commands =
 commands =
     pytest {posargs}
     pytest {posargs}
-    py38,py39,py310: black --check .
+    py38,py39,py310,py311: black --check .
     isort --check-only --settings-path setup.cfg .
     isort --check-only --settings-path setup.cfg .
     flake8 borgmatic tests
     flake8 borgmatic tests