Explorar o código

Instead of executing "before" command hooks before all borgmatic actions run (and "after" hooks after), execute these hooks right before/after the corresponding action (#473).

Dan Helfman %!s(int64=3) %!d(string=hai) anos
pai
achega
ed7fe5c6d0

+ 8 - 1
NEWS

@@ -1,4 +1,11 @@
-1.5.25.dev0
+1.6.0.dev0
+ * #473: Instead of executing "before" command hooks before all borgmatic actions run (and "after"
+   hooks after), execute these hooks right before/after the corresponding action. E.g.,
+   "before_check" now runs immediately before the "check" action. This better supports running
+   timing-sensitive tasks like pausing containers. Side effect: before/after command hooks now run
+   once for each configured repository instead of once per configuration file. Additionally, the
+   "repositories" interpolated variable has been changed to "repository", containing the path to the
+   current repository for the hook.
  * #516: Fix handling of TERM signal to exit borgmatic, not just forward the signal to Borg.
  * #517: Fix borgmatic exit code (so it's zero) when initial Borg calls fail but later retries
    succeed.

+ 115 - 117
borgmatic/commands/borgmatic.py

@@ -65,10 +65,6 @@ def run_configuration(config_filename, config, arguments):
     using_primary_action = {'prune', 'compact', 'create', 'check'}.intersection(arguments)
     monitoring_log_level = verbosity_to_log_level(global_arguments.monitoring_verbosity)
 
-    hook_context = {
-        'repositories': ','.join(location['repositories']),
-    }
-
     try:
         local_borg_version = borg_version.local_borg_version(local_path)
     except (OSError, CalledProcessError, ValueError) as error:
@@ -87,50 +83,6 @@ def run_configuration(config_filename, config, arguments):
                 monitoring_log_level,
                 global_arguments.dry_run,
             )
-        if 'prune' in arguments:
-            command.execute_hook(
-                hooks.get('before_prune'),
-                hooks.get('umask'),
-                config_filename,
-                'pre-prune',
-                global_arguments.dry_run,
-                **hook_context,
-            )
-        if 'compact' in arguments:
-            command.execute_hook(
-                hooks.get('before_compact'),
-                hooks.get('umask'),
-                config_filename,
-                'pre-compact',
-                global_arguments.dry_run,
-            )
-        if 'create' in arguments:
-            command.execute_hook(
-                hooks.get('before_backup'),
-                hooks.get('umask'),
-                config_filename,
-                'pre-backup',
-                global_arguments.dry_run,
-                **hook_context,
-            )
-        if 'check' in arguments:
-            command.execute_hook(
-                hooks.get('before_check'),
-                hooks.get('umask'),
-                config_filename,
-                'pre-check',
-                global_arguments.dry_run,
-                **hook_context,
-            )
-        if 'extract' in arguments:
-            command.execute_hook(
-                hooks.get('before_extract'),
-                hooks.get('umask'),
-                config_filename,
-                'pre-extract',
-                global_arguments.dry_run,
-                **hook_context,
-            )
         if using_primary_action:
             dispatch.call_hooks(
                 'ping_monitor',
@@ -146,7 +98,7 @@ def run_configuration(config_filename, config, arguments):
             return
 
         encountered_error = error
-        yield from log_error_records('{}: Error running pre hook'.format(config_filename), error)
+        yield from log_error_records('{}: Error pinging monitor'.format(config_filename), error)
 
     if not encountered_error:
         repo_queue = Queue()
@@ -162,6 +114,7 @@ def run_configuration(config_filename, config, arguments):
             try:
                 yield from run_actions(
                     arguments=arguments,
+                    config_filename=config_filename,
                     location=location,
                     storage=storage,
                     retention=retention,
@@ -188,6 +141,9 @@ def run_configuration(config_filename, config, arguments):
                     )
                     continue
 
+                if command.considered_soft_failure(config_filename, error):
+                    return
+
                 yield from log_error_records(
                     '{}: Error running actions for repository'.format(repository_path), error
                 )
@@ -196,58 +152,6 @@ def run_configuration(config_filename, config, arguments):
 
     if not encountered_error:
         try:
-            if 'prune' in arguments:
-                command.execute_hook(
-                    hooks.get('after_prune'),
-                    hooks.get('umask'),
-                    config_filename,
-                    'post-prune',
-                    global_arguments.dry_run,
-                    **hook_context,
-                )
-            if 'compact' in arguments:
-                command.execute_hook(
-                    hooks.get('after_compact'),
-                    hooks.get('umask'),
-                    config_filename,
-                    'post-compact',
-                    global_arguments.dry_run,
-                )
-            if 'create' in arguments:
-                dispatch.call_hooks(
-                    'remove_database_dumps',
-                    hooks,
-                    config_filename,
-                    dump.DATABASE_HOOK_NAMES,
-                    location,
-                    global_arguments.dry_run,
-                )
-                command.execute_hook(
-                    hooks.get('after_backup'),
-                    hooks.get('umask'),
-                    config_filename,
-                    'post-backup',
-                    global_arguments.dry_run,
-                    **hook_context,
-                )
-            if 'check' in arguments:
-                command.execute_hook(
-                    hooks.get('after_check'),
-                    hooks.get('umask'),
-                    config_filename,
-                    'post-check',
-                    global_arguments.dry_run,
-                    **hook_context,
-                )
-            if 'extract' in arguments:
-                command.execute_hook(
-                    hooks.get('after_extract'),
-                    hooks.get('umask'),
-                    config_filename,
-                    'post-extract',
-                    global_arguments.dry_run,
-                    **hook_context,
-                )
             if using_primary_action:
                 dispatch.call_hooks(
                     'ping_monitor',
@@ -271,9 +175,7 @@ def run_configuration(config_filename, config, arguments):
                 return
 
             encountered_error = error
-            yield from log_error_records(
-                '{}: Error running post hook'.format(config_filename), error
-            )
+            yield from log_error_records('{}: Error pinging monitor'.format(config_filename), error)
 
     if encountered_error and using_primary_action:
         try:
@@ -316,6 +218,7 @@ def run_configuration(config_filename, config, arguments):
 def run_actions(
     *,
     arguments,
+    config_filename,
     location,
     storage,
     retention,
@@ -325,20 +228,28 @@ def run_actions(
     remote_path,
     local_borg_version,
     repository_path,
-):  # pragma: no cover
+):
     '''
-    Given parsed command-line arguments as an argparse.ArgumentParser instance, several different
-    configuration dicts, local and remote paths to Borg, a local Borg version string, and a
-    repository name, run all actions from the command-line arguments on the given repository.
+    Given parsed command-line arguments as an argparse.ArgumentParser instance, the configuration
+    filename, several different configuration dicts, local and remote paths to Borg, a local Borg
+    version string, and a repository name, run all actions from the command-line arguments on the
+    given repository.
 
     Yield JSON output strings from executing any actions that produce JSON.
 
     Raise OSError or subprocess.CalledProcessError if an error occurs running a command for an
-    action. Raise ValueError if the arguments or configuration passed to action are invalid.
+    action or a hook. Raise ValueError if the arguments or configuration passed to action are
+    invalid.
     '''
     repository = os.path.expanduser(repository_path)
     global_arguments = arguments['global']
     dry_run_label = ' (dry run; not making any changes)' if global_arguments.dry_run else ''
+    hook_context = {
+        'repository': repository_path,
+        # Deprecated: For backwards compatibility with borgmatic < 1.6.0.
+        'repositories': ','.join(location['repositories']),
+    }
+
     if 'init' in arguments:
         logger.info('{}: Initializing repository'.format(repository))
         borg_init.initialize_repository(
@@ -351,6 +262,14 @@ def run_actions(
             remote_path=remote_path,
         )
     if 'prune' in arguments:
+        command.execute_hook(
+            hooks.get('before_prune'),
+            hooks.get('umask'),
+            config_filename,
+            'pre-prune',
+            global_arguments.dry_run,
+            **hook_context,
+        )
         logger.info('{}: Pruning archives{}'.format(repository, dry_run_label))
         borg_prune.prune_archives(
             global_arguments.dry_run,
@@ -362,7 +281,22 @@ def run_actions(
             stats=arguments['prune'].stats,
             files=arguments['prune'].files,
         )
+        command.execute_hook(
+            hooks.get('after_prune'),
+            hooks.get('umask'),
+            config_filename,
+            'post-prune',
+            global_arguments.dry_run,
+            **hook_context,
+        )
     if 'compact' in arguments:
+        command.execute_hook(
+            hooks.get('before_compact'),
+            hooks.get('umask'),
+            config_filename,
+            'pre-compact',
+            global_arguments.dry_run,
+        )
         if borg_feature.available(borg_feature.Feature.COMPACT, local_borg_version):
             logger.info('{}: Compacting segments{}'.format(repository, dry_run_label))
             borg_compact.compact_segments(
@@ -375,11 +309,26 @@ def run_actions(
                 cleanup_commits=arguments['compact'].cleanup_commits,
                 threshold=arguments['compact'].threshold,
             )
-        else:
+        else:  # pragma: nocover
             logger.info(
                 '{}: Skipping compact (only available/needed in Borg 1.2+)'.format(repository)
             )
+        command.execute_hook(
+            hooks.get('after_compact'),
+            hooks.get('umask'),
+            config_filename,
+            'post-compact',
+            global_arguments.dry_run,
+        )
     if 'create' in arguments:
+        command.execute_hook(
+            hooks.get('before_backup'),
+            hooks.get('umask'),
+            config_filename,
+            'pre-backup',
+            global_arguments.dry_run,
+            **hook_context,
+        )
         logger.info('{}: Creating archive{}'.format(repository, dry_run_label))
         dispatch.call_hooks(
             'remove_database_dumps',
@@ -413,10 +362,35 @@ def run_actions(
             files=arguments['create'].files,
             stream_processes=stream_processes,
         )
-        if json_output:
+        if json_output:  # pragma: nocover
             yield json.loads(json_output)
 
+        dispatch.call_hooks(
+            'remove_database_dumps',
+            hooks,
+            config_filename,
+            dump.DATABASE_HOOK_NAMES,
+            location,
+            global_arguments.dry_run,
+        )
+        command.execute_hook(
+            hooks.get('after_backup'),
+            hooks.get('umask'),
+            config_filename,
+            'post-backup',
+            global_arguments.dry_run,
+            **hook_context,
+        )
+
     if 'check' in arguments and checks.repository_enabled_for_checks(repository, consistency):
+        command.execute_hook(
+            hooks.get('before_check'),
+            hooks.get('umask'),
+            config_filename,
+            'pre-check',
+            global_arguments.dry_run,
+            **hook_context,
+        )
         logger.info('{}: Running consistency checks'.format(repository))
         borg_check.check_archives(
             repository,
@@ -428,7 +402,23 @@ def run_actions(
             repair=arguments['check'].repair,
             only_checks=arguments['check'].only,
         )
+        command.execute_hook(
+            hooks.get('after_check'),
+            hooks.get('umask'),
+            config_filename,
+            'post-check',
+            global_arguments.dry_run,
+            **hook_context,
+        )
     if 'extract' in arguments:
+        command.execute_hook(
+            hooks.get('before_extract'),
+            hooks.get('umask'),
+            config_filename,
+            'pre-extract',
+            global_arguments.dry_run,
+            **hook_context,
+        )
         if arguments['extract'].repository is None or validate.repositories_match(
             repository, arguments['extract'].repository
         ):
@@ -451,6 +441,14 @@ def run_actions(
                 strip_components=arguments['extract'].strip_components,
                 progress=arguments['extract'].progress,
             )
+        command.execute_hook(
+            hooks.get('after_extract'),
+            hooks.get('umask'),
+            config_filename,
+            'post-extract',
+            global_arguments.dry_run,
+            **hook_context,
+        )
     if 'export-tar' in arguments:
         if arguments['export-tar'].repository is None or validate.repositories_match(
             repository, arguments['export-tar'].repository
@@ -483,7 +481,7 @@ def run_actions(
                 logger.info(
                     '{}: Mounting archive {}'.format(repository, arguments['mount'].archive)
                 )
-            else:
+            else:  # pragma: nocover
                 logger.info('{}: Mounting repository'.format(repository))
 
             borg_mount.mount_archive(
@@ -499,7 +497,7 @@ def run_actions(
                 local_path=local_path,
                 remote_path=remote_path,
             )
-    if 'restore' in arguments:
+    if 'restore' in arguments:  # pragma: nocover
         if arguments['restore'].repository is None or validate.repositories_match(
             repository, arguments['restore'].repository
         ):
@@ -598,7 +596,7 @@ def run_actions(
             repository, arguments['list'].repository
         ):
             list_arguments = copy.copy(arguments['list'])
-            if not list_arguments.json:
+            if not list_arguments.json:  # pragma: nocover
                 logger.warning('{}: Listing archives'.format(repository))
             list_arguments.archive = borg_list.resolve_archive_name(
                 repository, list_arguments.archive, storage, local_path, remote_path
@@ -610,14 +608,14 @@ def run_actions(
                 local_path=local_path,
                 remote_path=remote_path,
             )
-            if json_output:
+            if json_output:  # pragma: nocover
                 yield json.loads(json_output)
     if 'info' in arguments:
         if arguments['info'].repository is None or validate.repositories_match(
             repository, arguments['info'].repository
         ):
             info_arguments = copy.copy(arguments['info'])
-            if not info_arguments.json:
+            if not info_arguments.json:  # pragma: nocover
                 logger.warning('{}: Displaying summary info for archives'.format(repository))
             info_arguments.archive = borg_list.resolve_archive_name(
                 repository, info_arguments.archive, storage, local_path, remote_path
@@ -629,7 +627,7 @@ def run_actions(
                 local_path=local_path,
                 remote_path=remote_path,
             )
-            if json_output:
+            if json_output:  # pragma: nocover
                 yield json.loads(json_output)
     if 'borg' in arguments:
         if arguments['borg'].repository is None or validate.repositories_match(

+ 15 - 15
docs/how-to/add-preparation-and-cleanup-steps-to-backups.md

@@ -7,11 +7,12 @@ eleventyNavigation:
 ---
 ## Preparation and cleanup hooks
 
-If you find yourself performing prepraration tasks before your backup runs, or
+If you find yourself performing preparation tasks before your backup runs, or
 cleanup work afterwards, borgmatic hooks may be of interest. Hooks are shell
-commands that borgmatic executes for you at various points, and they're
-configured in the `hooks` section of your configuration file. But if you're
-looking to backup a database, it's probably easier to use the [database backup
+commands that borgmatic executes for you at various points as it runs, and
+they're configured in the `hooks` section of your configuration file. But if
+you're looking to backup a database, it's probably easier to use the [database
+backup
 feature](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/)
 instead.
 
@@ -27,15 +28,14 @@ hooks:
         - umount /some/filesystem
 ```
 
-The `before_backup` and `after_backup` hooks each run once per configuration
-file. `before_backup` hooks run prior to backups of all repositories in a
-configuration file, right before the `create` action. `after_backup` hooks run
-afterwards, but not if an error occurs in a previous hook or in the backups
-themselves.
+The `before_backup` and `after_backup` hooks each run once per repository in a
+configuration file. `before_backup` hooks runs right before the `create`
+action for a particular repository, and `after_backup` hooks run afterwards,
+but not if an error occurs in a previous hook or in the backups themselves.
 
 There are additional hooks that run before/after other actions as well. For
-instance, `before_prune` runs before a `prune` action, while `after_prune`
-runs after it.
+instance, `before_prune` runs before a `prune` action for a repository, while
+`after_prune` runs after it.
 
 ## Variable interpolation
 
@@ -46,18 +46,18 @@ separate shell script:
 ```yaml
 hooks:
     after_prune:
-        - record-prune.sh "{configuration_filename}" "{repositories}"
+        - record-prune.sh "{configuration_filename}" "{repository}"
 ```
 
 In this example, when the hook is triggered, borgmatic interpolates runtime
 values into the hook command: the borgmatic configuration filename and the
-paths of all configured repositories. Here's the full set of supported
+paths of the current Borg repository. Here's the full set of supported
 variables you can use here:
 
  * `configuration_filename`: borgmatic configuration filename in which the
    hook was defined
- * `repositories`: comma-separated paths of all repositories configured in the
-   current borgmatic configuration file
+ * `repository`: path of the current repository as configured in the current
+   borgmatic configuration file
 
 ## Global hooks
 

+ 1 - 1
setup.py

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

+ 338 - 85
tests/unit/commands/test_borgmatic.py

@@ -35,75 +35,36 @@ def test_run_configuration_with_invalid_borg_version_errors():
     list(module.run_configuration('test.yaml', config, arguments))
 
 
-def test_run_configuration_calls_hooks_for_prune_action():
+def test_run_configuration_logs_monitor_start_error():
     flexmock(module.borg_environment).should_receive('initialize')
     flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
-    flexmock(module.command).should_receive('execute_hook').twice()
-    flexmock(module.dispatch).should_receive('call_hooks').at_least().twice()
-    flexmock(module).should_receive('run_actions').and_return([])
-    config = {'location': {'repositories': ['foo']}}
-    arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'prune': flexmock()}
-
-    list(module.run_configuration('test.yaml', config, arguments))
-
-
-def test_run_configuration_calls_hooks_for_compact_action():
-    flexmock(module.borg_environment).should_receive('initialize')
-    flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
-    flexmock(module.command).should_receive('execute_hook').twice()
-    flexmock(module).should_receive('run_actions').and_return([])
-    config = {'location': {'repositories': ['foo']}}
-    arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'compact': flexmock()}
-
-    list(module.run_configuration('test.yaml', config, arguments))
-
-
-def test_run_configuration_executes_and_calls_hooks_for_create_action():
-    flexmock(module.borg_environment).should_receive('initialize')
-    flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
-    flexmock(module.command).should_receive('execute_hook').twice()
-    flexmock(module.dispatch).should_receive('call_hooks').at_least().twice()
-    flexmock(module).should_receive('run_actions').and_return([])
+    flexmock(module.dispatch).should_receive('call_hooks').and_raise(OSError).and_return(
+        None
+    ).and_return(None)
+    expected_results = [flexmock()]
+    flexmock(module).should_receive('log_error_records').and_return(expected_results)
+    flexmock(module).should_receive('run_actions').never()
     config = {'location': {'repositories': ['foo']}}
     arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
 
-    list(module.run_configuration('test.yaml', config, arguments))
-
-
-def test_run_configuration_calls_hooks_for_check_action():
-    flexmock(module.borg_environment).should_receive('initialize')
-    flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
-    flexmock(module.command).should_receive('execute_hook').twice()
-    flexmock(module.dispatch).should_receive('call_hooks').at_least().twice()
-    flexmock(module).should_receive('run_actions').and_return([])
-    config = {'location': {'repositories': ['foo']}}
-    arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'check': flexmock()}
+    results = list(module.run_configuration('test.yaml', config, arguments))
 
-    list(module.run_configuration('test.yaml', config, arguments))
+    assert results == expected_results
 
 
-def test_run_configuration_calls_hooks_for_extract_action():
+def test_run_configuration_bails_for_monitor_start_soft_failure():
     flexmock(module.borg_environment).should_receive('initialize')
     flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
-    flexmock(module.command).should_receive('execute_hook').twice()
-    flexmock(module.dispatch).should_receive('call_hooks').never()
-    flexmock(module).should_receive('run_actions').and_return([])
+    error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
+    flexmock(module.dispatch).should_receive('call_hooks').and_raise(error)
+    flexmock(module).should_receive('log_error_records').never()
+    flexmock(module).should_receive('run_actions').never()
     config = {'location': {'repositories': ['foo']}}
-    arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'extract': flexmock()}
-
-    list(module.run_configuration('test.yaml', config, arguments))
-
+    arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
 
-def test_run_configuration_does_not_trigger_hooks_for_list_action():
-    flexmock(module.borg_environment).should_receive('initialize')
-    flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
-    flexmock(module.command).should_receive('execute_hook').never()
-    flexmock(module.dispatch).should_receive('call_hooks').never()
-    flexmock(module).should_receive('run_actions').and_return([])
-    config = {'location': {'repositories': ['foo']}}
-    arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'list': flexmock()}
+    results = list(module.run_configuration('test.yaml', config, arguments))
 
-    list(module.run_configuration('test.yaml', config, arguments))
+    assert results == []
 
 
 def test_run_configuration_logs_actions_error():
@@ -122,28 +83,14 @@ def test_run_configuration_logs_actions_error():
     assert results == expected_results
 
 
-def test_run_configuration_logs_pre_hook_error():
-    flexmock(module.borg_environment).should_receive('initialize')
-    flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
-    flexmock(module.command).should_receive('execute_hook').and_raise(OSError).and_return(None)
-    expected_results = [flexmock()]
-    flexmock(module).should_receive('log_error_records').and_return(expected_results)
-    flexmock(module).should_receive('run_actions').never()
-    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_pre_hook_soft_failure():
+def test_run_configuration_bails_for_actions_soft_failure():
     flexmock(module.borg_environment).should_receive('initialize')
     flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
+    flexmock(module.dispatch).should_receive('call_hooks')
     error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
-    flexmock(module.command).should_receive('execute_hook').and_raise(error).and_return(None)
+    flexmock(module).should_receive('run_actions').and_raise(error)
     flexmock(module).should_receive('log_error_records').never()
-    flexmock(module).should_receive('run_actions').never()
+    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()}
 
@@ -152,13 +99,12 @@ def test_run_configuration_bails_for_pre_hook_soft_failure():
     assert results == []
 
 
-def test_run_configuration_logs_post_hook_error():
+def test_run_configuration_logs_monitor_finish_error():
     flexmock(module.borg_environment).should_receive('initialize')
     flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
-    flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise(
-        OSError
-    ).and_return(None)
-    flexmock(module.dispatch).should_receive('call_hooks')
+    flexmock(module.dispatch).should_receive('call_hooks').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([])
@@ -170,16 +116,16 @@ def test_run_configuration_logs_post_hook_error():
     assert results == expected_results
 
 
-def test_run_configuration_bails_for_post_hook_soft_failure():
+def test_run_configuration_bails_for_monitor_finish_soft_failure():
     flexmock(module.borg_environment).should_receive('initialize')
     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.command).should_receive('execute_hook').and_return(None).and_raise(
-        error
-    ).and_return(None)
-    flexmock(module.dispatch).should_receive('call_hooks')
+    flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
+        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()}
 
@@ -209,7 +155,7 @@ def test_run_configuration_bails_for_on_error_hook_soft_failure():
     flexmock(module.borg_environment).should_receive('initialize')
     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.command).should_receive('execute_hook').and_return(None).and_raise(error)
+    flexmock(module.command).should_receive('execute_hook').and_raise(error)
     expected_results = [flexmock()]
     flexmock(module).should_receive('log_error_records').and_return(expected_results)
     flexmock(module).should_receive('run_actions').and_raise(OSError)
@@ -411,6 +357,313 @@ def test_run_configuration_retries_timeout_multiple_repos():
     assert results == error_logs
 
 
+def test_run_actions_does_not_raise_for_init_action():
+    flexmock(module.borg_init).should_receive('initialize_repository')
+    arguments = {
+        'global': flexmock(monitoring_verbosity=1, dry_run=False),
+        'init': flexmock(
+            encryption_mode=flexmock(), append_only=flexmock(), storage_quota=flexmock()
+        ),
+    }
+
+    list(
+        module.run_actions(
+            arguments=arguments,
+            config_filename='test.yaml',
+            location={'repositories': ['repo']},
+            storage={},
+            retention={},
+            consistency={},
+            hooks={},
+            local_path=None,
+            remote_path=None,
+            local_borg_version=None,
+            repository_path='repo',
+        )
+    )
+
+
+def test_run_actions_calls_hooks_for_prune_action():
+    flexmock(module.borg_prune).should_receive('prune_archives')
+    flexmock(module.command).should_receive('execute_hook').twice()
+    arguments = {
+        'global': flexmock(monitoring_verbosity=1, dry_run=False),
+        'prune': flexmock(stats=flexmock(), files=flexmock()),
+    }
+
+    list(
+        module.run_actions(
+            arguments=arguments,
+            config_filename='test.yaml',
+            location={'repositories': ['repo']},
+            storage={},
+            retention={},
+            consistency={},
+            hooks={},
+            local_path=None,
+            remote_path=None,
+            local_borg_version=None,
+            repository_path='repo',
+        )
+    )
+
+
+def test_run_actions_calls_hooks_for_compact_action():
+    flexmock(module.borg_feature).should_receive('available').and_return(True)
+    flexmock(module.borg_compact).should_receive('compact_segments')
+    flexmock(module.command).should_receive('execute_hook').twice()
+    arguments = {
+        'global': flexmock(monitoring_verbosity=1, dry_run=False),
+        'compact': flexmock(progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock()),
+    }
+
+    list(
+        module.run_actions(
+            arguments=arguments,
+            config_filename='test.yaml',
+            location={'repositories': ['repo']},
+            storage={},
+            retention={},
+            consistency={},
+            hooks={},
+            local_path=None,
+            remote_path=None,
+            local_borg_version=None,
+            repository_path='repo',
+        )
+    )
+
+
+def test_run_actions_executes_and_calls_hooks_for_create_action():
+    flexmock(module.borg_create).should_receive('create_archive')
+    flexmock(module.command).should_receive('execute_hook').twice()
+    flexmock(module.dispatch).should_receive('call_hooks').and_return({}).times(3)
+    arguments = {
+        'global': flexmock(monitoring_verbosity=1, dry_run=False),
+        'create': flexmock(
+            progress=flexmock(), stats=flexmock(), json=flexmock(), files=flexmock()
+        ),
+    }
+
+    list(
+        module.run_actions(
+            arguments=arguments,
+            config_filename='test.yaml',
+            location={'repositories': ['repo']},
+            storage={},
+            retention={},
+            consistency={},
+            hooks={},
+            local_path=None,
+            remote_path=None,
+            local_borg_version=None,
+            repository_path='repo',
+        )
+    )
+
+
+def test_run_actions_calls_hooks_for_check_action():
+    flexmock(module.checks).should_receive('repository_enabled_for_checks').and_return(True)
+    flexmock(module.borg_check).should_receive('check_archives')
+    flexmock(module.command).should_receive('execute_hook').twice()
+    arguments = {
+        'global': flexmock(monitoring_verbosity=1, dry_run=False),
+        'check': flexmock(progress=flexmock(), repair=flexmock(), only=flexmock()),
+    }
+
+    list(
+        module.run_actions(
+            arguments=arguments,
+            config_filename='test.yaml',
+            location={'repositories': ['repo']},
+            storage={},
+            retention={},
+            consistency={},
+            hooks={},
+            local_path=None,
+            remote_path=None,
+            local_borg_version=None,
+            repository_path='repo',
+        )
+    )
+
+
+def test_run_actions_calls_hooks_for_extract_action():
+    flexmock(module.validate).should_receive('repositories_match').and_return(True)
+    flexmock(module.borg_extract).should_receive('extract_archive')
+    flexmock(module.command).should_receive('execute_hook').twice()
+    arguments = {
+        'global': flexmock(monitoring_verbosity=1, dry_run=False),
+        'extract': flexmock(
+            paths=flexmock(),
+            progress=flexmock(),
+            destination=flexmock(),
+            strip_components=flexmock(),
+            archive=flexmock(),
+            repository='repo',
+        ),
+    }
+
+    list(
+        module.run_actions(
+            arguments=arguments,
+            config_filename='test.yaml',
+            location={'repositories': ['repo']},
+            storage={},
+            retention={},
+            consistency={},
+            hooks={},
+            local_path=None,
+            remote_path=None,
+            local_borg_version=None,
+            repository_path='repo',
+        )
+    )
+
+
+def test_run_actions_does_not_raise_for_export_tar_action():
+    flexmock(module.validate).should_receive('repositories_match').and_return(True)
+    flexmock(module.borg_export_tar).should_receive('export_tar_archive')
+    arguments = {
+        'global': flexmock(monitoring_verbosity=1, dry_run=False),
+        'export-tar': flexmock(
+            repository=flexmock(),
+            archive=flexmock(),
+            paths=flexmock(),
+            destination=flexmock(),
+            tar_filter=flexmock(),
+            files=flexmock(),
+            strip_components=flexmock(),
+        ),
+    }
+
+    list(
+        module.run_actions(
+            arguments=arguments,
+            config_filename='test.yaml',
+            location={'repositories': ['repo']},
+            storage={},
+            retention={},
+            consistency={},
+            hooks={},
+            local_path=None,
+            remote_path=None,
+            local_borg_version=None,
+            repository_path='repo',
+        )
+    )
+
+
+def test_run_actions_does_not_raise_for_mount_action():
+    flexmock(module.validate).should_receive('repositories_match').and_return(True)
+    flexmock(module.borg_mount).should_receive('mount_archive')
+    arguments = {
+        'global': flexmock(monitoring_verbosity=1, dry_run=False),
+        'mount': flexmock(
+            repository=flexmock(),
+            archive=flexmock(),
+            mount_point=flexmock(),
+            paths=flexmock(),
+            foreground=flexmock(),
+            options=flexmock(),
+        ),
+    }
+
+    list(
+        module.run_actions(
+            arguments=arguments,
+            config_filename='test.yaml',
+            location={'repositories': ['repo']},
+            storage={},
+            retention={},
+            consistency={},
+            hooks={},
+            local_path=None,
+            remote_path=None,
+            local_borg_version=None,
+            repository_path='repo',
+        )
+    )
+
+
+def test_run_actions_does_not_raise_for_list_action():
+    flexmock(module.validate).should_receive('repositories_match').and_return(True)
+    flexmock(module.borg_list).should_receive('resolve_archive_name').and_return(flexmock())
+    flexmock(module.borg_list).should_receive('list_archives')
+    arguments = {
+        'global': flexmock(monitoring_verbosity=1, dry_run=False),
+        'list': flexmock(repository=flexmock(), archive=flexmock(), json=flexmock()),
+    }
+
+    list(
+        module.run_actions(
+            arguments=arguments,
+            config_filename='test.yaml',
+            location={'repositories': ['repo']},
+            storage={},
+            retention={},
+            consistency={},
+            hooks={},
+            local_path=None,
+            remote_path=None,
+            local_borg_version=None,
+            repository_path='repo',
+        )
+    )
+
+
+def test_run_actions_does_not_raise_for_info_action():
+    flexmock(module.validate).should_receive('repositories_match').and_return(True)
+    flexmock(module.borg_list).should_receive('resolve_archive_name').and_return(flexmock())
+    flexmock(module.borg_info).should_receive('display_archives_info')
+    arguments = {
+        'global': flexmock(monitoring_verbosity=1, dry_run=False),
+        'info': flexmock(repository=flexmock(), archive=flexmock(), json=flexmock()),
+    }
+
+    list(
+        module.run_actions(
+            arguments=arguments,
+            config_filename='test.yaml',
+            location={'repositories': ['repo']},
+            storage={},
+            retention={},
+            consistency={},
+            hooks={},
+            local_path=None,
+            remote_path=None,
+            local_borg_version=None,
+            repository_path='repo',
+        )
+    )
+
+
+def test_run_actions_does_not_raise_for_borg_action():
+    flexmock(module.validate).should_receive('repositories_match').and_return(True)
+    flexmock(module.borg_list).should_receive('resolve_archive_name').and_return(flexmock())
+    flexmock(module.borg_borg).should_receive('run_arbitrary_borg')
+    arguments = {
+        'global': flexmock(monitoring_verbosity=1, dry_run=False),
+        'borg': flexmock(repository=flexmock(), archive=flexmock(), options=flexmock()),
+    }
+
+    list(
+        module.run_actions(
+            arguments=arguments,
+            config_filename='test.yaml',
+            location={'repositories': ['repo']},
+            storage={},
+            retention={},
+            consistency={},
+            hooks={},
+            local_path=None,
+            remote_path=None,
+            local_borg_version=None,
+            repository_path='repo',
+        )
+    )
+
+
 def test_load_configurations_collects_parsed_configurations():
     configuration = flexmock()
     other_configuration = flexmock()