浏览代码

Add a "!credential" tag for loading systemd credentials into borgmatic configuration (#966).

Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/993
Dan Helfman 3 月之前
父节点
当前提交
c9c6913547
共有 38 个文件被更改,包括 1144 次插入117 次删除
  1. 5 0
      NEWS
  2. 12 1
      README.md
  3. 7 1
      borgmatic/borg/environment.py
  4. 16 7
      borgmatic/commands/borgmatic.py
  5. 17 1
      borgmatic/config/load.py
  6. 44 30
      borgmatic/config/schema.yaml
  7. 4 2
      borgmatic/config/validate.py
  8. 35 0
      borgmatic/hooks/credential/systemd.py
  9. 27 0
      borgmatic/hooks/credential/tag.py
  10. 22 7
      borgmatic/hooks/data_source/mariadb.py
  11. 25 6
      borgmatic/hooks/data_source/mongodb.py
  12. 22 7
      borgmatic/hooks/data_source/mysql.py
  13. 21 7
      borgmatic/hooks/data_source/postgresql.py
  14. 16 3
      borgmatic/hooks/monitoring/ntfy.py
  15. 10 1
      borgmatic/hooks/monitoring/pagerduty.py
  16. 8 2
      borgmatic/hooks/monitoring/pushover.py
  17. 10 3
      borgmatic/hooks/monitoring/zabbix.py
  18. 6 1
      borgmatic/logger.py
  19. 66 17
      docs/how-to/provide-your-passwords.md
  20. 二进制
      docs/static/pushover.png
  21. 二进制
      docs/static/systemd.png
  22. 71 0
      tests/end-to-end/hooks/credential/test_systemd.py
  23. 14 0
      tests/integration/config/test_load.py
  24. 19 0
      tests/unit/borg/test_environment.py
  25. 21 0
      tests/unit/borg/test_passcommand.py
  26. 11 2
      tests/unit/commands/test_borgmatic.py
  27. 14 0
      tests/unit/config/test_load.py
  28. 51 0
      tests/unit/hooks/credential/test_systemd.py
  29. 51 0
      tests/unit/hooks/credential/test_tag.py
  30. 66 0
      tests/unit/hooks/data_source/test_mariadb.py
  31. 36 0
      tests/unit/hooks/data_source/test_mongodb.py
  32. 60 0
      tests/unit/hooks/data_source/test_mysql.py
  33. 90 0
      tests/unit/hooks/data_source/test_postgresql.py
  34. 60 0
      tests/unit/hooks/monitoring/test_ntfy.py
  35. 35 0
      tests/unit/hooks/monitoring/test_pagerduty.py
  36. 66 2
      tests/unit/hooks/monitoring/test_pushover.py
  37. 88 15
      tests/unit/hooks/monitoring/test_zabbix.py
  38. 18 2
      tests/unit/test_logger.py

+ 5 - 0
NEWS

@@ -1,4 +1,7 @@
 1.9.10.dev0
 1.9.10.dev0
+ * #966: Add a "!credential" tag for loading systemd credentials into borgmatic configuration
+   files. See the documentation for more information:
+   https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/
  * #987: Fix a "list" action error when the "encryption_passcommand" option is set.
  * #987: Fix a "list" action error when the "encryption_passcommand" option is set.
  * #987: When both "encryption_passcommand" and "encryption_passphrase" are configured, prefer
  * #987: When both "encryption_passcommand" and "encryption_passphrase" are configured, prefer
    "encryption_passphrase" even if it's an empty value.
    "encryption_passphrase" even if it's an empty value.
@@ -7,6 +10,8 @@
    refused to run checks in this situation.
    refused to run checks in this situation.
  * #989: Fix the log message code to avoid using Python 3.10+ logging features. Now borgmatic will
  * #989: Fix the log message code to avoid using Python 3.10+ logging features. Now borgmatic will
    work with Python 3.9 again.
    work with Python 3.9 again.
+ * Capture and delay any log records produced before logging is fully configured, so early log
+   records don't get lost.
  * Add support for Python 3.13.
  * Add support for Python 3.13.
 
 
 1.9.9
 1.9.9

+ 12 - 1
README.md

@@ -56,6 +56,8 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
 
 
 ## Integrations
 ## Integrations
 
 
+### Data
+
 <a href="https://www.postgresql.org/"><img src="docs/static/postgresql.png" alt="PostgreSQL" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://www.postgresql.org/"><img src="docs/static/postgresql.png" alt="PostgreSQL" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://www.mysql.com/"><img src="docs/static/mysql.png" alt="MySQL" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://www.mysql.com/"><img src="docs/static/mysql.png" alt="MySQL" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://mariadb.com/"><img src="docs/static/mariadb.png" alt="MariaDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://mariadb.com/"><img src="docs/static/mariadb.png" alt="MariaDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
@@ -65,6 +67,11 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
 <a href="https://btrfs.readthedocs.io/"><img src="docs/static/btrfs.png" alt="Btrfs" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://btrfs.readthedocs.io/"><img src="docs/static/btrfs.png" alt="Btrfs" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://sourceware.org/lvm2/"><img src="docs/static/lvm.png" alt="LVM" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://sourceware.org/lvm2/"><img src="docs/static/lvm.png" alt="LVM" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://rclone.org"><img src="docs/static/rclone.png" alt="rclone" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://rclone.org"><img src="docs/static/rclone.png" alt="rclone" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
+<a href="https://www.borgbase.com/?utm_source=borgmatic"><img src="docs/static/borgbase.png" alt="BorgBase" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
+
+
+### Monitoring
+
 <a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://uptime.kuma.pet/"><img src="docs/static/uptimekuma.png" alt="Uptime Kuma" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://uptime.kuma.pet/"><img src="docs/static/uptimekuma.png" alt="Uptime Kuma" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
@@ -76,7 +83,11 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
 <a href="https://github.com/caronc/apprise/wiki"><img src="docs/static/apprise.png" alt="Apprise" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://github.com/caronc/apprise/wiki"><img src="docs/static/apprise.png" alt="Apprise" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://www.zabbix.com/"><img src="docs/static/zabbix.png" alt="Zabbix" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://www.zabbix.com/"><img src="docs/static/zabbix.png" alt="Zabbix" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://sentry.io/"><img src="docs/static/sentry.png" alt="Sentry" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://sentry.io/"><img src="docs/static/sentry.png" alt="Sentry" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
-<a href="https://www.borgbase.com/?utm_source=borgmatic"><img src="docs/static/borgbase.png" alt="BorgBase" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
+
+
+### Credentials
+
+<a href="https://systemd.io/"><img src="docs/static/systemd.png" alt="Sentry" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
 
 
 
 
 ## Getting started
 ## Getting started

+ 7 - 1
borgmatic/borg/environment.py

@@ -1,6 +1,7 @@
 import os
 import os
 
 
 import borgmatic.borg.passcommand
 import borgmatic.borg.passcommand
+import borgmatic.hooks.credential.tag
 
 
 OPTION_TO_ENVIRONMENT_VARIABLE = {
 OPTION_TO_ENVIRONMENT_VARIABLE = {
     'borg_base_directory': 'BORG_BASE_DIR',
     'borg_base_directory': 'BORG_BASE_DIR',
@@ -14,6 +15,8 @@ OPTION_TO_ENVIRONMENT_VARIABLE = {
     'temporary_directory': 'TMPDIR',
     'temporary_directory': 'TMPDIR',
 }
 }
 
 
+CREDENTIAL_OPTIONS = {'encryption_passphrase'}
+
 DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE = {
 DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE = {
     'relocated_repo_access_is_ok': 'BORG_RELOCATED_REPO_ACCESS_IS_OK',
     'relocated_repo_access_is_ok': 'BORG_RELOCATED_REPO_ACCESS_IS_OK',
     'unknown_unencrypted_repo_access_is_ok': 'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK',
     'unknown_unencrypted_repo_access_is_ok': 'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK',
@@ -37,7 +40,10 @@ def make_environment(config):
     for option_name, environment_variable_name in OPTION_TO_ENVIRONMENT_VARIABLE.items():
     for option_name, environment_variable_name in OPTION_TO_ENVIRONMENT_VARIABLE.items():
         value = config.get(option_name)
         value = config.get(option_name)
 
 
-        if value:
+        if option_name in CREDENTIAL_OPTIONS and value is not None:
+            value = borgmatic.hooks.credential.tag.resolve_credential(value)
+
+        if value is not None:
             environment[environment_variable_name] = str(value)
             environment[environment_variable_name] = str(value)
 
 
     passphrase = borgmatic.borg.passcommand.get_passphrase_from_passcommand(config)
     passphrase = borgmatic.borg.passcommand.get_passphrase_from_passcommand(config)

+ 16 - 7
borgmatic/commands/borgmatic.py

@@ -535,13 +535,16 @@ def run_actions(
 
 
 def load_configurations(config_filenames, overrides=None, resolve_env=True):
 def load_configurations(config_filenames, overrides=None, resolve_env=True):
     '''
     '''
-    Given a sequence of configuration filenames, load and validate each configuration file. Return
-    the results as a tuple of: dict of configuration filename to corresponding parsed configuration,
-    a sequence of paths for all loaded configuration files (including includes), and a sequence of
-    logging.LogRecord instances containing any parse errors.
+    Given a sequence of configuration filenames, a sequence of configuration file override strings
+    in the form of "option.suboption=value", and whether to resolve environment variables, load and
+    validate each configuration file. Return the results as a tuple of: dict of configuration
+    filename to corresponding parsed configuration, a sequence of paths for all loaded configuration
+    files (including includes), and a sequence of logging.LogRecord instances containing any parse
+    errors.
 
 
     Log records are returned here instead of being logged directly because logging isn't yet
     Log records are returned here instead of being logged directly because logging isn't yet
-    initialized at this point!
+    initialized at this point! (Although with the Delayed_logging_handler now in place, maybe this
+    approach could change.)
     '''
     '''
     # Dict mapping from config filename to corresponding parsed config dict.
     # Dict mapping from config filename to corresponding parsed config dict.
     configs = collections.OrderedDict()
     configs = collections.OrderedDict()
@@ -563,7 +566,10 @@ def load_configurations(config_filenames, overrides=None, resolve_env=True):
         )
         )
         try:
         try:
             configs[config_filename], paths, parse_logs = validate.parse_configuration(
             configs[config_filename], paths, parse_logs = validate.parse_configuration(
-                config_filename, validate.schema_filename(), overrides, resolve_env
+                config_filename,
+                validate.schema_filename(),
+                overrides,
+                resolve_env,
             )
             )
             config_paths.update(paths)
             config_paths.update(paths)
             logs.extend(parse_logs)
             logs.extend(parse_logs)
@@ -907,9 +913,12 @@ def main(extra_summary_logs=[]):  # pragma: no cover
         print(borgmatic.commands.completion.fish.fish_completion())
         print(borgmatic.commands.completion.fish.fish_completion())
         sys.exit(0)
         sys.exit(0)
 
 
+    validate = bool('validate' in arguments)
     config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths))
     config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths))
     configs, config_paths, parse_logs = load_configurations(
     configs, config_paths, parse_logs = load_configurations(
-        config_filenames, global_arguments.overrides, global_arguments.resolve_env
+        config_filenames,
+        global_arguments.overrides,
+        resolve_env=global_arguments.resolve_env and not validate,
     )
     )
     configuration_parse_errors = (
     configuration_parse_errors = (
         (max(log.levelno for log in parse_logs) >= logging.CRITICAL) if parse_logs else False
         (max(log.levelno for log in parse_logs) >= logging.CRITICAL) if parse_logs else False

+ 17 - 1
borgmatic/config/load.py

@@ -69,7 +69,7 @@ def include_configuration(loader, filename_node, include_directory, config_paths
         ]
         ]
 
 
     raise ValueError(
     raise ValueError(
-        '!include value is not supported; use a single filename or a list of filenames'
+        'The value given for the !include tag is invalid; use a single filename or a list of filenames instead'
     )
     )
 
 
 
 
@@ -104,6 +104,21 @@ def raise_omit_node_error(loader, node):
     )
     )
 
 
 
 
+def reserialize_tag_node(loader, tag_node):
+    '''
+    Given a ruamel.yaml loader and a node for a tag and value, convert the node back into a string
+    of the form "!tagname value" and return it. The idea is that downstream code, rather than this
+    file's YAML loading logic, should be responsible for interpreting this particular tag—since the
+    downstream code actually understands the meaning behind the tag.
+
+    Raise ValueError if the tag node's value isn't a string.
+    '''
+    if isinstance(tag_node.value, str):
+        return f'{tag_node.tag} {tag_node.value}'
+
+    raise ValueError(f'The value given for the {tag_node.tag} tag is invalid; use a string instead')
+
+
 class Include_constructor(ruamel.yaml.SafeConstructor):
 class Include_constructor(ruamel.yaml.SafeConstructor):
     '''
     '''
     A YAML "constructor" (a ruamel.yaml concept) that supports a custom "!include" tag for including
     A YAML "constructor" (a ruamel.yaml concept) that supports a custom "!include" tag for including
@@ -122,6 +137,7 @@ class Include_constructor(ruamel.yaml.SafeConstructor):
                 config_paths=config_paths,
                 config_paths=config_paths,
             ),
             ),
         )
         )
+        self.add_constructor('!credential', reserialize_tag_node)
 
 
         # These are catch-all error handlers for tags that don't get applied and removed by
         # These are catch-all error handlers for tags that don't get applied and removed by
         # deep_merge_nodes() below.
         # deep_merge_nodes() below.

+ 44 - 30
borgmatic/config/schema.yaml

@@ -250,7 +250,7 @@ properties:
             repositories that were initialized with passphrase/repokey/keyfile
             repositories that were initialized with passphrase/repokey/keyfile
             encryption. Quote the value if it contains punctuation, so it parses
             encryption. Quote the value if it contains punctuation, so it parses
             correctly. And backslash any quote or backslash literals as well.
             correctly. And backslash any quote or backslash literals as well.
-            Defaults to not set.
+            Defaults to not set. Supports the "!credential" tag.
         example: "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
         example: "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
     checkpoint_interval:
     checkpoint_interval:
         type: integer
         type: integer
@@ -989,13 +989,13 @@ properties:
                         Username with which to connect to the database. Defaults
                         Username with which to connect to the database. Defaults
                         to the username of the current user. You probably want
                         to the username of the current user. You probably want
                         to specify the "postgres" superuser here when the
                         to specify the "postgres" superuser here when the
-                        database name is "all".
+                        database name is "all". Supports the "!credential" tag.
                     example: dbuser
                     example: dbuser
                 restore_username:
                 restore_username:
                     type: string
                     type: string
                     description: |
                     description: |
                         Username with which to restore the database. Defaults to
                         Username with which to restore the database. Defaults to
-                        the "username" option.
+                        the "username" option. Supports the "!credential" tag.
                     example: dbuser
                     example: dbuser
                 password:
                 password:
                     type: string
                     type: string
@@ -1003,13 +1003,14 @@ properties:
                         Password with which to connect to the database. Omitting
                         Password with which to connect to the database. Omitting
                         a password will only work if PostgreSQL is configured to
                         a password will only work if PostgreSQL is configured to
                         trust the configured username without a password or you
                         trust the configured username without a password or you
-                        create a ~/.pgpass file.
+                        create a ~/.pgpass file. Supports the "!credential" tag.
                     example: trustsome1
                     example: trustsome1
                 restore_password:
                 restore_password:
                     type: string
                     type: string
                     description: |
                     description: |
                         Password with which to connect to the restore database.
                         Password with which to connect to the restore database.
-                        Defaults to the "password" option.
+                        Defaults to the "password" option. Supports the
+                        "!credential" tag.
                     example: trustsome1
                     example: trustsome1
                 no_owner:
                 no_owner:
                     type: boolean
                     type: boolean
@@ -1169,13 +1170,14 @@ properties:
                     type: string
                     type: string
                     description: |
                     description: |
                         Username with which to connect to the database. Defaults
                         Username with which to connect to the database. Defaults
-                        to the username of the current user.
+                        to the username of the current user. Supports the
+                        "!credential" tag.
                     example: dbuser
                     example: dbuser
                 restore_username:
                 restore_username:
                     type: string
                     type: string
                     description: |
                     description: |
                         Username with which to restore the database. Defaults to
                         Username with which to restore the database. Defaults to
-                        the "username" option.
+                        the "username" option. Supports the "!credential" tag.
                     example: dbuser
                     example: dbuser
                 password:
                 password:
                     type: string
                     type: string
@@ -1183,6 +1185,14 @@ properties:
                         Password with which to connect to the database. Omitting
                         Password with which to connect to the database. Omitting
                         a password will only work if MariaDB is configured to
                         a password will only work if MariaDB is configured to
                         trust the configured username without a password.
                         trust the configured username without a password.
+                        Supports the "!credential" tag.
+                    example: trustsome1
+                restore_password:
+                    type: string
+                    description: |
+                        Password with which to connect to the restore database.
+                        Defaults to the "password" option. Supports the
+                        "!credential" tag.
                     example: trustsome1
                     example: trustsome1
                 mariadb_dump_command:
                 mariadb_dump_command:
                     type: string
                     type: string
@@ -1201,12 +1211,6 @@ properties:
                         run a specific mariadb version (e.g., one inside a
                         run a specific mariadb version (e.g., one inside a
                         running container). Defaults to "mariadb".
                         running container). Defaults to "mariadb".
                     example: docker exec mariadb_container mariadb           
                     example: docker exec mariadb_container mariadb           
-                restore_password:
-                    type: string
-                    description: |
-                        Password with which to connect to the restore database.
-                        Defaults to the "password" option.
-                    example: trustsome1
                 format:
                 format:
                     type: string
                     type: string
                     enum: ['sql']
                     enum: ['sql']
@@ -1295,13 +1299,14 @@ properties:
                     type: string
                     type: string
                     description: |
                     description: |
                         Username with which to connect to the database. Defaults
                         Username with which to connect to the database. Defaults
-                        to the username of the current user.
+                        to the username of the current user. Supports the
+                        "!credential" tag.
                     example: dbuser
                     example: dbuser
                 restore_username:
                 restore_username:
                     type: string
                     type: string
                     description: |
                     description: |
                         Username with which to restore the database. Defaults to
                         Username with which to restore the database. Defaults to
-                        the "username" option.
+                        the "username" option. Supports the "!credential" tag.
                     example: dbuser
                     example: dbuser
                 password:
                 password:
                     type: string
                     type: string
@@ -1309,12 +1314,14 @@ properties:
                         Password with which to connect to the database. Omitting
                         Password with which to connect to the database. Omitting
                         a password will only work if MySQL is configured to
                         a password will only work if MySQL is configured to
                         trust the configured username without a password.
                         trust the configured username without a password.
+                        Supports the "!credential" tag.
                     example: trustsome1
                     example: trustsome1
                 restore_password:
                 restore_password:
                     type: string
                     type: string
                     description: |
                     description: |
                         Password with which to connect to the restore database.
                         Password with which to connect to the restore database.
-                        Defaults to the "password" option.
+                        Defaults to the "password" option. Supports the
+                        "!credential" tag.
                     example: trustsome1
                     example: trustsome1
                 mysql_dump_command:
                 mysql_dump_command:
                     type: string
                     type: string
@@ -1451,25 +1458,28 @@ properties:
                     type: string
                     type: string
                     description: |
                     description: |
                         Username with which to connect to the database. Skip it
                         Username with which to connect to the database. Skip it
-                        if no authentication is needed.
+                        if no authentication is needed. Supports the
+                        "!credential" tag.
                     example: dbuser
                     example: dbuser
                 restore_username:
                 restore_username:
                     type: string
                     type: string
                     description: |
                     description: |
                         Username with which to restore the database. Defaults to
                         Username with which to restore the database. Defaults to
-                        the "username" option.
+                        the "username" option. Supports the "!credential" tag.
                     example: dbuser
                     example: dbuser
                 password:
                 password:
                     type: string
                     type: string
                     description: |
                     description: |
                         Password with which to connect to the database. Skip it
                         Password with which to connect to the database. Skip it
-                        if no authentication is needed.
+                        if no authentication is needed. Supports the
+                        "!credential" tag.
                     example: trustsome1
                     example: trustsome1
                 restore_password:
                 restore_password:
                     type: string
                     type: string
                     description: |
                     description: |
                         Password with which to connect to the restore database.
                         Password with which to connect to the restore database.
-                        Defaults to the "password" option.
+                        Defaults to the "password" option. Supports the
+                        "!credential" tag.
                     example: trustsome1
                     example: trustsome1
                 authentication_database:
                 authentication_database:
                     type: string
                     type: string
@@ -1528,18 +1538,20 @@ properties:
             username:
             username:
                 type: string
                 type: string
                 description: |
                 description: |
-                    The username used for authentication.
+                    The username used for authentication. Supports the
+                    "!credential" tag.
                 example: testuser
                 example: testuser
             password:
             password:
                 type: string
                 type: string
                 description: |
                 description: |
-                    The password used for authentication.
+                    The password used for authentication. Supports the
+                    "!credential" tag.
                 example: fakepassword
                 example: fakepassword
             access_token:
             access_token:
                 type: string
                 type: string
                 description: |
                 description: |
                     An ntfy access token to authenticate with instead of
                     An ntfy access token to authenticate with instead of
-                    username/password.
+                    username/password. Supports the "!credential" tag.
                 example: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
                 example: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
             start:
             start:
                 type: object
                 type: object
@@ -1634,14 +1646,16 @@ properties:
             token:
             token:
                 type: string
                 type: string
                 description: |
                 description: |
-                    Your application's API token.
+                    Your application's API token. Supports the "!credential"
+                    tag.
                 example: 7ms6TXHpTokTou2P6x4SodDeentHRa
                 example: 7ms6TXHpTokTou2P6x4SodDeentHRa
             user:
             user:
                 type: string
                 type: string
                 description: |
                 description: |
-                    Your user/group key (or that of your target user), viewable 
-                    when logged into your dashboard: often referred to as 
+                    Your user/group key (or that of your target user), viewable
+                    when logged into your dashboard: often referred to as
                     USER_KEY in Pushover documentation and code examples.
                     USER_KEY in Pushover documentation and code examples.
+                    Supports the "!credential" tag.
                 example: hwRwoWsXMBWwgrSecfa9EfPey55WSN
                 example: hwRwoWsXMBWwgrSecfa9EfPey55WSN
             start:
             start:
                 type: object
                 type: object
@@ -1915,19 +1929,19 @@ properties:
                 type: string
                 type: string
                 description: |
                 description: |
                     The username used for authentication. Not needed if using
                     The username used for authentication. Not needed if using
-                    an API key.
+                    an API key. Supports the "!credential" tag.
                 example: testuser
                 example: testuser
             password:
             password:
                 type: string
                 type: string
                 description: |
                 description: |
                     The password used for authentication. Not needed if using
                     The password used for authentication. Not needed if using
-                    an API key.
+                    an API key. Supports the "!credential" tag.
                 example: fakepassword
                 example: fakepassword
             api_key:
             api_key:
                 type: string
                 type: string
                 description: |
                 description: |
                     The API key used for authentication. Not needed if using
                     The API key used for authentication. Not needed if using
-                    an username/password.
+                    an username/password. Supports the "!credential" tag.
                 example: fakekey
                 example: fakekey
             start:
             start:
                 type: object
                 type: object
@@ -2208,7 +2222,7 @@ properties:
                 type: string
                 type: string
                 description: |
                 description: |
                     PagerDuty integration key used to notify PagerDuty
                     PagerDuty integration key used to notify PagerDuty
-                    when a backup errors.
+                    when a backup errors. Supports the "!credential" tag.
                 example: a177cad45bd374409f78906a810a3074
                 example: a177cad45bd374409f78906a810a3074
         description: |
         description: |
             Configuration for a monitoring integration with PagerDuty. Create an
             Configuration for a monitoring integration with PagerDuty. Create an

+ 4 - 2
borgmatic/config/validate.py

@@ -88,8 +88,9 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
     '''
     '''
     Given the path to a config filename in YAML format, the path to a schema filename in a YAML
     Given the path to a config filename in YAML format, the path to a schema filename in a YAML
     rendition of JSON Schema format, a sequence of configuration file override strings in the form
     rendition of JSON Schema format, a sequence of configuration file override strings in the form
-    of "option.suboption=value", return the parsed configuration as a data structure of nested dicts
-    and lists corresponding to the schema. Example return value:
+    of "option.suboption=value", and whether to resolve environment variables, return the parsed
+    configuration as a data structure of nested dicts and lists corresponding to the schema. Example
+    return value:
 
 
         {
         {
             'source_directories': ['/home', '/etc'],
             'source_directories': ['/home', '/etc'],
@@ -124,6 +125,7 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
         validator = jsonschema.Draft7Validator(schema)
         validator = jsonschema.Draft7Validator(schema)
     except AttributeError:  # pragma: no cover
     except AttributeError:  # pragma: no cover
         validator = jsonschema.Draft4Validator(schema)
         validator = jsonschema.Draft4Validator(schema)
+
     validation_errors = tuple(validator.iter_errors(config))
     validation_errors = tuple(validator.iter_errors(config))
 
 
     if validation_errors:
     if validation_errors:

+ 35 - 0
borgmatic/hooks/credential/systemd.py

@@ -0,0 +1,35 @@
+import logging
+import os
+import re
+
+logger = logging.getLogger(__name__)
+
+
+CREDENTIAL_NAME_PATTERN = re.compile(r'^\w+$')
+
+
+def load_credential(hook_config, config, credential_name):
+    '''
+    Given the hook configuration dict, the configuration dict, and a credential name to load, read
+    the credential from the corresponding systemd credential file and return it.
+
+    Raise ValueError if the systemd CREDENTIALS_DIRECTORY environment variable is not set, the
+    credential name is invalid, or the credential file cannot be read.
+    '''
+    credentials_directory = os.environ.get('CREDENTIALS_DIRECTORY')
+
+    if not credentials_directory:
+        raise ValueError(
+            f'Cannot load credential "{credential_name}" because the systemd CREDENTIALS_DIRECTORY environment variable is not set'
+        )
+
+    if not CREDENTIAL_NAME_PATTERN.match(credential_name):
+        raise ValueError(f'Cannot load invalid credential name "{credential_name}"')
+
+    try:
+        with open(os.path.join(credentials_directory, credential_name)) as credential_file:
+            return credential_file.read().rstrip(os.linesep)
+    except (FileNotFoundError, OSError) as error:
+        logger.warning(error)
+
+        raise ValueError(f'Cannot load credential "{credential_name}" from file: {error.filename}')

+ 27 - 0
borgmatic/hooks/credential/tag.py

@@ -0,0 +1,27 @@
+import functools
+
+import borgmatic.hooks.dispatch
+
+IS_A_HOOK = False
+
+
+@functools.cache
+def resolve_credential(tag):
+    '''
+    Given a configuration tag string like "!credential hookname credentialname", resolve it by
+    calling the relevant hook to get the actual credential value. If the given tag is not actually a
+    credential tag, then return the value unchanged.
+
+    Cache the value so repeated calls to this function don't need to load the credential repeatedly.
+
+    Raise ValueError if the config could not be parsed or the credential could not be loaded.
+    '''
+    if tag and tag.startswith('!credential '):
+        try:
+            (tag_name, hook_name, credential_name) = tag.split(' ', 2)
+        except ValueError:
+            raise ValueError(f'Cannot load credential with invalid syntax "{tag}"')
+
+        return borgmatic.hooks.dispatch.call_hook('load_credential', {}, hook_name, credential_name)
+
+    return tag

+ 22 - 7
borgmatic/hooks/data_source/mariadb.py

@@ -5,6 +5,7 @@ import shlex
 
 
 import borgmatic.borg.pattern
 import borgmatic.borg.pattern
 import borgmatic.config.paths
 import borgmatic.config.paths
+import borgmatic.hooks.credential.tag
 from borgmatic.execute import (
 from borgmatic.execute import (
     execute_command,
     execute_command,
     execute_command_and_capture_output,
     execute_command_and_capture_output,
@@ -45,7 +46,11 @@ def database_names_to_dump(database, extra_environment, dry_run):
         + (('--host', database['hostname']) if 'hostname' in database else ())
         + (('--host', database['hostname']) if 'hostname' in database else ())
         + (('--port', str(database['port'])) if 'port' in database else ())
         + (('--port', str(database['port'])) if 'port' in database else ())
         + (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
         + (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
-        + (('--user', database['username']) if 'username' in database else ())
+        + (
+            ('--user', borgmatic.hooks.credential.tag.resolve_credential(database['username']))
+            if 'username' in database
+            else ()
+        )
         + ('--skip-column-names', '--batch')
         + ('--skip-column-names', '--batch')
         + ('--execute', 'show schemas')
         + ('--execute', 'show schemas')
     )
     )
@@ -96,7 +101,11 @@ def execute_dump_command(
         + (('--host', database['hostname']) if 'hostname' in database else ())
         + (('--host', database['hostname']) if 'hostname' in database else ())
         + (('--port', str(database['port'])) if 'port' in database else ())
         + (('--port', str(database['port'])) if 'port' in database else ())
         + (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
         + (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
-        + (('--user', database['username']) if 'username' in database else ())
+        + (
+            ('--user', borgmatic.hooks.credential.tag.resolve_credential(database['username']))
+            if 'username' in database
+            else ()
+        )
         + ('--databases',)
         + ('--databases',)
         + database_names
         + database_names
         + ('--result-file', dump_filename)
         + ('--result-file', dump_filename)
@@ -152,7 +161,11 @@ def dump_data_sources(
 
 
     for database in databases:
     for database in databases:
         dump_path = make_dump_path(borgmatic_runtime_directory)
         dump_path = make_dump_path(borgmatic_runtime_directory)
-        extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
+        extra_environment = (
+            {'MYSQL_PWD': borgmatic.hooks.credential.tag.resolve_credential(database['password'])}
+            if 'password' in database
+            else None
+        )
         dump_database_names = database_names_to_dump(database, extra_environment, dry_run)
         dump_database_names = database_names_to_dump(database, extra_environment, dry_run)
 
 
         if not dump_database_names:
         if not dump_database_names:
@@ -251,11 +264,13 @@ def restore_data_source_dump(
     port = str(
     port = str(
         connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
         connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
     )
     )
-    username = connection_params['username'] or data_source.get(
-        'restore_username', data_source.get('username')
+    username = borgmatic.hooks.credential.tag.resolve_credential(
+        connection_params['username']
+        or data_source.get('restore_username', data_source.get('username'))
     )
     )
-    password = connection_params['password'] or data_source.get(
-        'restore_password', data_source.get('password')
+    password = borgmatic.hooks.credential.tag.resolve_credential(
+        connection_params['password']
+        or data_source.get('restore_password', data_source.get('password'))
     )
     )
 
 
     mariadb_restore_command = tuple(
     mariadb_restore_command = tuple(

+ 25 - 6
borgmatic/hooks/data_source/mongodb.py

@@ -4,6 +4,7 @@ import shlex
 
 
 import borgmatic.borg.pattern
 import borgmatic.borg.pattern
 import borgmatic.config.paths
 import borgmatic.config.paths
+import borgmatic.hooks.credential.tag
 from borgmatic.execute import execute_command, execute_command_with_processes
 from borgmatic.execute import execute_command, execute_command_with_processes
 from borgmatic.hooks.data_source import dump
 from borgmatic.hooks.data_source import dump
 
 
@@ -98,8 +99,26 @@ def build_dump_command(database, dump_filename, dump_format):
         + (('--out', shlex.quote(dump_filename)) if dump_format == 'directory' else ())
         + (('--out', shlex.quote(dump_filename)) if dump_format == 'directory' else ())
         + (('--host', shlex.quote(database['hostname'])) if 'hostname' in database else ())
         + (('--host', shlex.quote(database['hostname'])) if 'hostname' in database else ())
         + (('--port', shlex.quote(str(database['port']))) if 'port' in database else ())
         + (('--port', shlex.quote(str(database['port']))) if 'port' in database else ())
-        + (('--username', shlex.quote(database['username'])) if 'username' in database else ())
-        + (('--password', shlex.quote(database['password'])) if 'password' in database else ())
+        + (
+            (
+                '--username',
+                shlex.quote(
+                    borgmatic.hooks.credential.tag.resolve_credential(database['username'])
+                ),
+            )
+            if 'username' in database
+            else ()
+        )
+        + (
+            (
+                '--password',
+                shlex.quote(
+                    borgmatic.hooks.credential.tag.resolve_credential(database['password'])
+                ),
+            )
+            if 'password' in database
+            else ()
+        )
         + (
         + (
             ('--authenticationDatabase', shlex.quote(database['authentication_database']))
             ('--authenticationDatabase', shlex.quote(database['authentication_database']))
             if 'authentication_database' in database
             if 'authentication_database' in database
@@ -198,11 +217,11 @@ def build_restore_command(extract_process, database, dump_filename, connection_p
         'restore_hostname', database.get('hostname')
         'restore_hostname', database.get('hostname')
     )
     )
     port = str(connection_params['port'] or database.get('restore_port', database.get('port', '')))
     port = str(connection_params['port'] or database.get('restore_port', database.get('port', '')))
-    username = connection_params['username'] or database.get(
-        'restore_username', database.get('username')
+    username = borgmatic.hooks.credential.tag.resolve_credential(
+        connection_params['username'] or database.get('restore_username', database.get('username'))
     )
     )
-    password = connection_params['password'] or database.get(
-        'restore_password', database.get('password')
+    password = borgmatic.hooks.credential.tag.resolve_credential(
+        connection_params['password'] or database.get('restore_password', database.get('password'))
     )
     )
 
 
     command = ['mongorestore']
     command = ['mongorestore']

+ 22 - 7
borgmatic/hooks/data_source/mysql.py

@@ -5,6 +5,7 @@ import shlex
 
 
 import borgmatic.borg.pattern
 import borgmatic.borg.pattern
 import borgmatic.config.paths
 import borgmatic.config.paths
+import borgmatic.hooks.credential.tag
 from borgmatic.execute import (
 from borgmatic.execute import (
     execute_command,
     execute_command,
     execute_command_and_capture_output,
     execute_command_and_capture_output,
@@ -45,7 +46,11 @@ def database_names_to_dump(database, extra_environment, dry_run):
         + (('--host', database['hostname']) if 'hostname' in database else ())
         + (('--host', database['hostname']) if 'hostname' in database else ())
         + (('--port', str(database['port'])) if 'port' in database else ())
         + (('--port', str(database['port'])) if 'port' in database else ())
         + (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
         + (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
-        + (('--user', database['username']) if 'username' in database else ())
+        + (
+            ('--user', borgmatic.hooks.credential.tag.resolve_credential(database['username']))
+            if 'username' in database
+            else ()
+        )
         + ('--skip-column-names', '--batch')
         + ('--skip-column-names', '--batch')
         + ('--execute', 'show schemas')
         + ('--execute', 'show schemas')
     )
     )
@@ -95,7 +100,11 @@ def execute_dump_command(
         + (('--host', database['hostname']) if 'hostname' in database else ())
         + (('--host', database['hostname']) if 'hostname' in database else ())
         + (('--port', str(database['port'])) if 'port' in database else ())
         + (('--port', str(database['port'])) if 'port' in database else ())
         + (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
         + (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
-        + (('--user', database['username']) if 'username' in database else ())
+        + (
+            ('--user', borgmatic.hooks.credential.tag.resolve_credential(database['username']))
+            if 'username' in database
+            else ()
+        )
         + ('--databases',)
         + ('--databases',)
         + database_names
         + database_names
         + ('--result-file', dump_filename)
         + ('--result-file', dump_filename)
@@ -151,7 +160,11 @@ def dump_data_sources(
 
 
     for database in databases:
     for database in databases:
         dump_path = make_dump_path(borgmatic_runtime_directory)
         dump_path = make_dump_path(borgmatic_runtime_directory)
-        extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
+        extra_environment = (
+            {'MYSQL_PWD': borgmatic.hooks.credential.tag.resolve_credential(database['password'])}
+            if 'password' in database
+            else None
+        )
         dump_database_names = database_names_to_dump(database, extra_environment, dry_run)
         dump_database_names = database_names_to_dump(database, extra_environment, dry_run)
 
 
         if not dump_database_names:
         if not dump_database_names:
@@ -250,11 +263,13 @@ def restore_data_source_dump(
     port = str(
     port = str(
         connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
         connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
     )
     )
-    username = connection_params['username'] or data_source.get(
-        'restore_username', data_source.get('username')
+    username = borgmatic.hooks.credential.tag.resolve_credential(
+        connection_params['username']
+        or data_source.get('restore_username', data_source.get('username'))
     )
     )
-    password = connection_params['password'] or data_source.get(
-        'restore_password', data_source.get('password')
+    password = borgmatic.hooks.credential.tag.resolve_credential(
+        connection_params['password']
+        or data_source.get('restore_password', data_source.get('password'))
     )
     )
 
 
     mysql_restore_command = tuple(
     mysql_restore_command = tuple(

+ 21 - 7
borgmatic/hooks/data_source/postgresql.py

@@ -7,6 +7,7 @@ import shlex
 
 
 import borgmatic.borg.pattern
 import borgmatic.borg.pattern
 import borgmatic.config.paths
 import borgmatic.config.paths
+import borgmatic.hooks.credential.tag
 from borgmatic.execute import (
 from borgmatic.execute import (
     execute_command,
     execute_command,
     execute_command_and_capture_output,
     execute_command_and_capture_output,
@@ -33,11 +34,14 @@ def make_extra_environment(database, restore_connection_params=None):
 
 
     try:
     try:
         if restore_connection_params:
         if restore_connection_params:
-            extra['PGPASSWORD'] = restore_connection_params.get('password') or database.get(
-                'restore_password', database['password']
+            extra['PGPASSWORD'] = borgmatic.hooks.credential.tag.resolve_credential(
+                restore_connection_params.get('password')
+                or database.get('restore_password', database['password'])
             )
             )
         else:
         else:
-            extra['PGPASSWORD'] = database['password']
+            extra['PGPASSWORD'] = borgmatic.hooks.credential.tag.resolve_credential(
+                database['password']
+            )
     except (AttributeError, KeyError):
     except (AttributeError, KeyError):
         pass
         pass
 
 
@@ -82,7 +86,11 @@ def database_names_to_dump(database, extra_environment, dry_run):
         + ('--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only')
         + ('--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only')
         + (('--host', database['hostname']) if 'hostname' in database else ())
         + (('--host', database['hostname']) if 'hostname' in database else ())
         + (('--port', str(database['port'])) if 'port' in database else ())
         + (('--port', str(database['port'])) if 'port' in database else ())
-        + (('--username', database['username']) if 'username' in database else ())
+        + (
+            ('--username', borgmatic.hooks.credential.tag.resolve_credential(database['username']))
+            if 'username' in database
+            else ()
+        )
         + (tuple(database['list_options'].split(' ')) if 'list_options' in database else ())
         + (tuple(database['list_options'].split(' ')) if 'list_options' in database else ())
     )
     )
     logger.debug('Querying for "all" PostgreSQL databases to dump')
     logger.debug('Querying for "all" PostgreSQL databases to dump')
@@ -174,7 +182,12 @@ def dump_data_sources(
                 + (('--host', shlex.quote(database['hostname'])) if 'hostname' in database else ())
                 + (('--host', shlex.quote(database['hostname'])) if 'hostname' in database else ())
                 + (('--port', shlex.quote(str(database['port']))) if 'port' in database else ())
                 + (('--port', shlex.quote(str(database['port']))) if 'port' in database else ())
                 + (
                 + (
-                    ('--username', shlex.quote(database['username']))
+                    (
+                        '--username',
+                        shlex.quote(
+                            borgmatic.hooks.credential.tag.resolve_credential(database['username'])
+                        ),
+                    )
                     if 'username' in database
                     if 'username' in database
                     else ()
                     else ()
                 )
                 )
@@ -290,8 +303,9 @@ def restore_data_source_dump(
     port = str(
     port = str(
         connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
         connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
     )
     )
-    username = connection_params['username'] or data_source.get(
-        'restore_username', data_source.get('username')
+    username = borgmatic.hooks.credential.tag.resolve_credential(
+        connection_params['username']
+        or data_source.get('restore_username', data_source.get('username'))
     )
     )
 
 
     all_databases = bool(data_source['name'] == 'all')
     all_databases = bool(data_source['name'] == 'all')

+ 16 - 3
borgmatic/hooks/monitoring/ntfy.py

@@ -2,6 +2,8 @@ import logging
 
 
 import requests
 import requests
 
 
+import borgmatic.hooks.credential.tag
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
@@ -47,9 +49,20 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
             'X-Tags': state_config.get('tags'),
             'X-Tags': state_config.get('tags'),
         }
         }
 
 
-        username = hook_config.get('username')
-        password = hook_config.get('password')
-        access_token = hook_config.get('access_token')
+        try:
+            username = borgmatic.hooks.credential.tag.resolve_credential(
+                hook_config.get('username')
+            )
+            password = borgmatic.hooks.credential.tag.resolve_credential(
+                hook_config.get('password')
+            )
+            access_token = borgmatic.hooks.credential.tag.resolve_credential(
+                hook_config.get('access_token')
+            )
+        except ValueError as error:
+            logger.warning(f'Ntfy credential error: {error}')
+            return
+
         auth = None
         auth = None
 
 
         if access_token is not None:
         if access_token is not None:

+ 10 - 1
borgmatic/hooks/monitoring/pagerduty.py

@@ -5,6 +5,7 @@ import platform
 
 
 import requests
 import requests
 
 
+import borgmatic.hooks.credential.tag
 from borgmatic.hooks.monitoring import monitor
 from borgmatic.hooks.monitoring import monitor
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -39,11 +40,19 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
     if dry_run:
     if dry_run:
         return
         return
 
 
+    try:
+        integration_key = borgmatic.hooks.credential.tag.resolve_credential(
+            hook_config.get('integration_key')
+        )
+    except ValueError as error:
+        logger.warning(f'PagerDuty credential error: {error}')
+        return
+
     hostname = platform.node()
     hostname = platform.node()
     local_timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone().isoformat()
     local_timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone().isoformat()
     payload = json.dumps(
     payload = json.dumps(
         {
         {
-            'routing_key': hook_config['integration_key'],
+            'routing_key': integration_key,
             'event_action': 'trigger',
             'event_action': 'trigger',
             'payload': {
             'payload': {
                 'summary': f'backup failed on {hostname}',
                 'summary': f'backup failed on {hostname}',

+ 8 - 2
borgmatic/hooks/monitoring/pushover.py

@@ -2,6 +2,8 @@ import logging
 
 
 import requests
 import requests
 
 
+import borgmatic.hooks.credential.tag
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
@@ -32,8 +34,12 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
 
 
     state_config = hook_config.get(state.name.lower(), {})
     state_config = hook_config.get(state.name.lower(), {})
 
 
-    token = hook_config.get('token')
-    user = hook_config.get('user')
+    try:
+        token = borgmatic.hooks.credential.tag.resolve_credential(hook_config.get('token'))
+        user = borgmatic.hooks.credential.tag.resolve_credential(hook_config.get('user'))
+    except ValueError as error:
+        logger.warning(f'Pushover credential error: {error}')
+        return
 
 
     logger.info(f'Updating Pushover{dry_run_label}')
     logger.info(f'Updating Pushover{dry_run_label}')
 
 

+ 10 - 3
borgmatic/hooks/monitoring/zabbix.py

@@ -2,6 +2,8 @@ import logging
 
 
 import requests
 import requests
 
 
+import borgmatic.hooks.credential.tag
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
@@ -34,10 +36,15 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
         },
         },
     )
     )
 
 
+    try:
+        username = borgmatic.hooks.credential.tag.resolve_credential(hook_config.get('username'))
+        password = borgmatic.hooks.credential.tag.resolve_credential(hook_config.get('password'))
+        api_key = borgmatic.hooks.credential.tag.resolve_credential(hook_config.get('api_key'))
+    except ValueError as error:
+        logger.warning(f'Zabbix credential error: {error}')
+        return
+
     server = hook_config.get('server')
     server = hook_config.get('server')
-    username = hook_config.get('username')
-    password = hook_config.get('password')
-    api_key = hook_config.get('api_key')
     itemid = hook_config.get('itemid')
     itemid = hook_config.get('itemid')
     host = hook_config.get('host')
     host = hook_config.get('host')
     key = hook_config.get('key')
     key = hook_config.get('key')

+ 6 - 1
borgmatic/logger.py

@@ -269,6 +269,10 @@ class Delayed_logging_handler(logging.handlers.BufferingHandler):
     target handlers are actually set). It's useful for holding onto messages logged before logging
     target handlers are actually set). It's useful for holding onto messages logged before logging
     is configured, ensuring those records eventually make their way to the relevant logging
     is configured, ensuring those records eventually make their way to the relevant logging
     handlers.
     handlers.
+
+    When flushing, don't forward log records to a target handler if the record's log level is below
+    that of the handler. This recreates the standard logging behavior of, say, logging.DEBUG records
+    getting suppressed if a handler's level is only set to logging.INFO.
     '''
     '''
 
 
     def __init__(self):
     def __init__(self):
@@ -288,7 +292,8 @@ class Delayed_logging_handler(logging.handlers.BufferingHandler):
 
 
             for record in self.buffer:
             for record in self.buffer:
                 for target in self.targets:
                 for target in self.targets:
-                    target.handle(record)
+                    if record.levelno >= target.level:
+                        target.handle(record)
 
 
             self.buffer.clear()
             self.buffer.clear()
         finally:
         finally:

+ 66 - 17
docs/how-to/provide-your-passwords.md

@@ -50,52 +50,101 @@ once per borgmatic run.
 
 
 ### Using systemd service credentials
 ### Using systemd service credentials
 
 
-Borgmatic supports using encrypted [credentials](https://systemd.io/CREDENTIALS/).
-
-Save your password as an encrypted credential to `/etc/credstore.encrypted/borgmatic.pw`, e.g.,
+borgmatic supports using encrypted [systemd
+credentials](https://systemd.io/CREDENTIALS/). To use this feature, start by
+saving your password as an encrypted credential to
+`/etc/credstore.encrypted/borgmatic.pw`, e.g.,
 
 
+```bash
+systemd-ask-password -n | systemd-creds encrypt - /etc/credstore.encrypted/borgmatic.pw
 ```
 ```
-# systemd-ask-password -n | systemd-creds encrypt - /etc/credstore.encrypted/borgmatic.pw
+
+Then use the following in your configuration file:
+
+```yaml
+encryption_passphrase: !credential systemd borgmatic.pw
 ```
 ```
 
 
-Then uncomment or use the following in your configuration file:
+<span class="minilink minilink-addedin">Prior to version 1.9.10</span> You can
+accomplish the same thing with this configuration:
 
 
 ```yaml
 ```yaml
-encryption_passcommand: "cat ${CREDENTIALS_DIRECTORY}/borgmatic.pw"
+encryption_passcommand: cat ${CREDENTIALS_DIRECTORY}/borgmatic.pw
 ```
 ```
 
 
 Note that the name `borgmatic.pw` is hardcoded in the systemd service file.
 Note that the name `borgmatic.pw` is hardcoded in the systemd service file.
 
 
-To use multiple different passwords, save them as encrypted credentials to `/etc/credstore.encrypted/borgmatic/`, e.g.,
+The `!credential` tag works for several different options in a borgmatic
+configuration file besides just `encryption_passphrase`. For instance, the
+username, password, and API token options within database and monitoring hooks
+support `!credential`. For example:
 
 
+```yaml
+postgresql_databases:
+    - name: invoices
+      username: postgres
+      password: !credential systemd borgmatic_db1
 ```
 ```
-# mkdir /etc/credstore.encrypted/borgmatic
-# systemd-ask-password -n | systemd-creds encrypt --name=borgmatic_backupserver1 - /etc/credstore.encrypted/borgmatic/backupserver1
-# systemd-ask-password -n | systemd-creds encrypt --name=borgmatic_pw2 - /etc/credstore.encrypted/borgmatic/pw2
+
+For specifics about which options are supported, see the
+[configuration
+reference](https://torsion.org/borgmatic/docs/reference/configuration/).
+
+To use these credentials, you'll need to modify the borgmatic systemd service
+file to support loading multiple credentials (assuming you need to load more
+than one or anything not named `borgmatic.pw`).
+
+Start by saving each encrypted credentials to
+`/etc/credstore.encrypted/borgmatic/`. E.g.,
+
+```bash
+mkdir /etc/credstore.encrypted/borgmatic
+systemd-ask-password -n | systemd-creds encrypt --name=borgmatic_backupserver1 - /etc/credstore.encrypted/borgmatic/backupserver1
+systemd-ask-password -n | systemd-creds encrypt --name=borgmatic_pw2 - /etc/credstore.encrypted/borgmatic/pw2
 ...
 ...
 ```
 ```
 
 
-Ensure that the file names, (e.g. `backupserver1`) match the corresponding part of
-the `--name` option *after* the underscore (_), and that the part *before* 
+Ensure that the file names, (e.g. `backupserver1`) match the corresponding part
+of the `--name` option *after* the underscore (_), and that the part *before*
 the underscore matches the directory name (e.g. `borgmatic`).
 the underscore matches the directory name (e.g. `borgmatic`).
 
 
 Then, uncomment the appropriate line in the systemd service file:
 Then, uncomment the appropriate line in the systemd service file:
 
 
 ```
 ```
-# systemctl edit borgmatic.service
+systemctl edit borgmatic.service
 ...
 ...
 # Load multiple encrypted credentials.
 # Load multiple encrypted credentials.
 LoadCredentialEncrypted=borgmatic:/etc/credstore.encrypted/borgmatic/
 LoadCredentialEncrypted=borgmatic:/etc/credstore.encrypted/borgmatic/
 ```
 ```
 
 
-Finally, use the following in your configuration file:
+Finally, use something like the following in your borgmatic configuration file
+for each option value you'd like to load from systemd:
 
 
+```yaml
+encryption_passphrase: !credential systemd borgmatic_backupserver1
 ```
 ```
-encryption_passcommand: "cat ${CREDENTIALS_DIRECTORY}/borgmatic_backupserver1"
+
+<span class="minilink minilink-addedin">Prior to version 1.9.10</span> Use the
+following instead, but only for the `encryption_passcommand` option and
+not other options:
+
+```yaml
+encryption_passcommand: cat ${CREDENTIALS_DIRECTORY}/borgmatic_backupserver1
 ```
 ```
 
 
-Adjust `borgmatic_backupserver1` according to the name given to the credential 
-and the directory set in the service file.
+Adjust `borgmatic_backupserver1` according to the name of the credential and the
+directory set in the service file.
+
+Be aware that when using this systemd `!credential` feature, you may no longer
+be able to run certain borgmatic actions outside of the systemd service, as the
+credentials are only available from within the context of that service. So for
+instance, `borgmatic list` necessarily relies on the `encryption_passphrase` in
+order to access the Borg repository, but it shouldn't need to load any
+credentials for your database or monitoring hooks.
+
+The one exception is `borgmatic config validate`, which doesn't actually load
+any credentials and should continue working anywhere.
+
 
 
 ### Environment variable interpolation
 ### Environment variable interpolation
 
 

二进制
docs/static/pushover.png


二进制
docs/static/systemd.png


+ 71 - 0
tests/end-to-end/hooks/credential/test_systemd.py

@@ -0,0 +1,71 @@
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+
+
+def generate_configuration(config_path, repository_path):
+    '''
+    Generate borgmatic configuration into a file at the config path, and update the defaults so as
+    to work for testing, including updating the source directories, injecting the given repository
+    path, and tacking on an encryption passphrase loaded from systemd.
+    '''
+    subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' '))
+    config = (
+        open(config_path)
+        .read()
+        .replace('ssh://user@backupserver/./sourcehostname.borg', repository_path)
+        .replace('- path: /mnt/backup', '')
+        .replace('label: local', '')
+        .replace('- /home/user/path with spaces', '')
+        .replace('- /home', f'- {config_path}')
+        .replace('- /etc', '')
+        .replace('- /var/log/syslog*', '')
+        + '\nencryption_passphrase: !credential systemd mycredential'
+    )
+    config_file = open(config_path, 'w')
+    config_file.write(config)
+    config_file.close()
+
+
+def test_borgmatic_command():
+    # Create a Borg repository.
+    temporary_directory = tempfile.mkdtemp()
+    repository_path = os.path.join(temporary_directory, 'test.borg')
+    extract_path = os.path.join(temporary_directory, 'extract')
+
+    original_working_directory = os.getcwd()
+    os.mkdir(extract_path)
+    os.chdir(extract_path)
+
+    try:
+        config_path = os.path.join(temporary_directory, 'test.yaml')
+        generate_configuration(config_path, repository_path)
+
+        credential_path = os.path.join(temporary_directory, 'mycredential')
+        with open(credential_path, 'w') as credential_file:
+            credential_file.write('test')
+
+        subprocess.check_call(
+            f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' '),
+            env=dict(os.environ, **{'CREDENTIALS_DIRECTORY': temporary_directory}),
+        )
+
+        # Run borgmatic to generate a backup archive, and then list it to make sure it exists.
+        subprocess.check_call(
+            f'borgmatic --config {config_path}'.split(' '),
+            env=dict(os.environ, **{'CREDENTIALS_DIRECTORY': temporary_directory}),
+        )
+        output = subprocess.check_output(
+            f'borgmatic --config {config_path} list --json'.split(' '),
+            env=dict(os.environ, **{'CREDENTIALS_DIRECTORY': temporary_directory}),
+        ).decode(sys.stdout.encoding)
+        parsed_output = json.loads(output)
+
+        assert len(parsed_output) == 1
+        assert len(parsed_output[0]['archives']) == 1
+    finally:
+        os.chdir(original_working_directory)
+        shutil.rmtree(temporary_directory)

+ 14 - 0
tests/integration/config/test_load.py

@@ -225,6 +225,20 @@ def test_load_configuration_merges_multiple_file_include():
     assert config_paths == {'config.yaml', '/tmp/include1.yaml', '/tmp/include2.yaml', 'other.yaml'}
     assert config_paths == {'config.yaml', '/tmp/include1.yaml', '/tmp/include2.yaml', 'other.yaml'}
 
 
 
 
+def test_load_configuration_passes_through_credential_tag():
+    builtins = flexmock(sys.modules['builtins'])
+    flexmock(module.os).should_receive('getcwd').and_return('/tmp')
+    flexmock(module.os.path).should_receive('isabs').and_return(False)
+    flexmock(module.os.path).should_receive('exists').and_return(True)
+    config_file = io.StringIO('key: !credential foo bar')
+    config_file.name = 'config.yaml'
+    builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
+    config_paths = {'other.yaml'}
+
+    assert module.load_configuration('config.yaml', config_paths) == {'key': '!credential foo bar'}
+    assert config_paths == {'config.yaml', 'other.yaml'}
+
+
 def test_load_configuration_with_retain_tag_merges_include_but_keeps_local_values():
 def test_load_configuration_with_retain_tag_merges_include_but_keeps_local_values():
     builtins = flexmock(sys.modules['builtins'])
     builtins = flexmock(sys.modules['builtins'])
     flexmock(module.os).should_receive('getcwd').and_return('/tmp')
     flexmock(module.os).should_receive('getcwd').and_return('/tmp')

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

@@ -24,11 +24,30 @@ def test_make_environment_with_passphrase_should_set_environment():
     ).and_return(None)
     ).and_return(None)
     flexmock(module.os).should_receive('pipe').never()
     flexmock(module.os).should_receive('pipe').never()
     flexmock(module.os.environ).should_receive('get').and_return(None)
     flexmock(module.os.environ).should_receive('get').and_return(None)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
+
     environment = module.make_environment({'encryption_passphrase': 'pass'})
     environment = module.make_environment({'encryption_passphrase': 'pass'})
 
 
     assert environment.get('BORG_PASSPHRASE') == 'pass'
     assert environment.get('BORG_PASSPHRASE') == 'pass'
 
 
 
 
+def test_make_environment_with_credential_tag_passphrase_should_load_it_and_set_environment():
+    flexmock(module.borgmatic.borg.passcommand).should_receive(
+        'get_passphrase_from_passcommand'
+    ).and_return(None)
+    flexmock(module.os).should_receive('pipe').never()
+    flexmock(module.os.environ).should_receive('get').and_return(None)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive('resolve_credential').with_args(
+        '!credential systemd pass'
+    ).and_return('pass')
+
+    environment = module.make_environment({'encryption_passphrase': '!credential systemd pass'})
+
+    assert environment.get('BORG_PASSPHRASE') == 'pass'
+
+
 def test_make_environment_with_ssh_command_should_set_environment():
 def test_make_environment_with_ssh_command_should_set_environment():
     flexmock(module.borgmatic.borg.passcommand).should_receive(
     flexmock(module.borgmatic.borg.passcommand).should_receive(
         'get_passphrase_from_passcommand'
         'get_passphrase_from_passcommand'

+ 21 - 0
tests/unit/borg/test_passcommand.py

@@ -4,6 +4,7 @@ from borgmatic.borg import passcommand as module
 
 
 
 
 def test_run_passcommand_with_passphrase_configured_bails():
 def test_run_passcommand_with_passphrase_configured_bails():
+    module.run_passcommand.cache_clear()
     flexmock(module.borgmatic.execute).should_receive('execute_command_and_capture_output').never()
     flexmock(module.borgmatic.execute).should_receive('execute_command_and_capture_output').never()
 
 
     assert (
     assert (
@@ -13,6 +14,7 @@ def test_run_passcommand_with_passphrase_configured_bails():
 
 
 
 
 def test_run_passcommand_without_passphrase_configured_executes_passcommand():
 def test_run_passcommand_without_passphrase_configured_executes_passcommand():
+    module.run_passcommand.cache_clear()
     flexmock(module.borgmatic.execute).should_receive(
     flexmock(module.borgmatic.execute).should_receive(
         'execute_command_and_capture_output'
         'execute_command_and_capture_output'
     ).and_return('passphrase').once()
     ).and_return('passphrase').once()
@@ -24,6 +26,7 @@ def test_run_passcommand_without_passphrase_configured_executes_passcommand():
 
 
 
 
 def test_get_passphrase_from_passcommand_with_configured_passcommand_runs_it():
 def test_get_passphrase_from_passcommand_with_configured_passcommand_runs_it():
+    module.run_passcommand.cache_clear()
     flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
     flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
         '/working'
         '/working'
     )
     )
@@ -40,6 +43,7 @@ def test_get_passphrase_from_passcommand_with_configured_passcommand_runs_it():
 
 
 
 
 def test_get_passphrase_from_passcommand_with_configured_passphrase_and_passcommand_detects_passphrase():
 def test_get_passphrase_from_passcommand_with_configured_passphrase_and_passcommand_detects_passphrase():
+    module.run_passcommand.cache_clear()
     flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
     flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
         '/working'
         '/working'
     )
     )
@@ -56,6 +60,7 @@ def test_get_passphrase_from_passcommand_with_configured_passphrase_and_passcomm
 
 
 
 
 def test_get_passphrase_from_passcommand_with_configured_blank_passphrase_and_passcommand_detects_passphrase():
 def test_get_passphrase_from_passcommand_with_configured_blank_passphrase_and_passcommand_detects_passphrase():
+    module.run_passcommand.cache_clear()
     flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
     flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
         '/working'
         '/working'
     )
     )
@@ -69,3 +74,19 @@ def test_get_passphrase_from_passcommand_with_configured_blank_passphrase_and_pa
         )
         )
         is None
         is None
     )
     )
+
+
+def test_run_passcommand_caches_passcommand_after_first_call():
+    module.run_passcommand.cache_clear()
+    flexmock(module.borgmatic.execute).should_receive(
+        'execute_command_and_capture_output'
+    ).and_return('passphrase').once()
+
+    assert (
+        module.run_passcommand('passcommand', passphrase_configured=False, working_directory=None)
+        == 'passphrase'
+    )
+    assert (
+        module.run_passcommand('passcommand', passphrase_configured=False, working_directory=None)
+        == 'passphrase'
+    )

+ 11 - 2
tests/unit/commands/test_borgmatic.py

@@ -1114,7 +1114,11 @@ def test_run_actions_runs_multiple_actions_in_argument_order():
     )
     )
 
 
 
 
-def test_load_configurations_collects_parsed_configurations_and_logs():
+@pytest.mark.parametrize(
+    'resolve_env',
+    ((True, False),),
+)
+def test_load_configurations_collects_parsed_configurations_and_logs(resolve_env):
     configuration = flexmock()
     configuration = flexmock()
     other_configuration = flexmock()
     other_configuration = flexmock()
     test_expected_logs = [flexmock(), flexmock()]
     test_expected_logs = [flexmock(), flexmock()]
@@ -1123,7 +1127,12 @@ def test_load_configurations_collects_parsed_configurations_and_logs():
         configuration, ['/tmp/test.yaml'], test_expected_logs
         configuration, ['/tmp/test.yaml'], test_expected_logs
     ).and_return(other_configuration, ['/tmp/other.yaml'], other_expected_logs)
     ).and_return(other_configuration, ['/tmp/other.yaml'], other_expected_logs)
 
 
-    configs, config_paths, logs = tuple(module.load_configurations(('test.yaml', 'other.yaml')))
+    configs, config_paths, logs = tuple(
+        module.load_configurations(
+            ('test.yaml', 'other.yaml'),
+            resolve_env=resolve_env,
+        )
+    )
 
 
     assert configs == {'test.yaml': configuration, 'other.yaml': other_configuration}
     assert configs == {'test.yaml': configuration, 'other.yaml': other_configuration}
     assert config_paths == ['/tmp/other.yaml', '/tmp/test.yaml']
     assert config_paths == ['/tmp/other.yaml', '/tmp/test.yaml']

+ 14 - 0
tests/unit/config/test_load.py

@@ -43,3 +43,17 @@ def test_probe_and_include_file_with_relative_path_and_missing_files_raises():
 
 
     with pytest.raises(FileNotFoundError):
     with pytest.raises(FileNotFoundError):
         module.probe_and_include_file('include.yaml', ['/etc', '/var'], config_paths=set())
         module.probe_and_include_file('include.yaml', ['/etc', '/var'], config_paths=set())
+
+
+def test_reserialize_tag_node_turns_it_into_string():
+    assert (
+        module.reserialize_tag_node(loader=flexmock(), tag_node=flexmock(tag='!tag', value='value'))
+        == '!tag value'
+    )
+
+
+def test_reserialize_tag_node_with_invalid_value_raises():
+    with pytest.raises(ValueError):
+        assert module.reserialize_tag_node(
+            loader=flexmock(), tag_node=flexmock(tag='!tag', value=['value'])
+        )

+ 51 - 0
tests/unit/hooks/credential/test_systemd.py

@@ -0,0 +1,51 @@
+import io
+import sys
+
+import pytest
+from flexmock import flexmock
+
+from borgmatic.hooks.credential import systemd as module
+
+
+def test_load_credential_without_credentials_directory_raises():
+    flexmock(module.os.environ).should_receive('get').with_args('CREDENTIALS_DIRECTORY').and_return(
+        None
+    )
+
+    with pytest.raises(ValueError):
+        module.load_credential(hook_config={}, config={}, credential_name='mycredential')
+
+
+def test_load_credential_with_invalid_credential_name_raises():
+    flexmock(module.os.environ).should_receive('get').with_args('CREDENTIALS_DIRECTORY').and_return(
+        '/var'
+    )
+
+    with pytest.raises(ValueError):
+        module.load_credential(hook_config={}, config={}, credential_name='../../my!@#$credential')
+
+
+def test_load_credential_reads_named_credential_from_file():
+    flexmock(module.os.environ).should_receive('get').with_args('CREDENTIALS_DIRECTORY').and_return(
+        '/var'
+    )
+    credential_stream = io.StringIO('password')
+    credential_stream.name = '/var/mycredential'
+    builtins = flexmock(sys.modules['builtins'])
+    builtins.should_receive('open').with_args('/var/mycredential').and_return(credential_stream)
+
+    assert (
+        module.load_credential(hook_config={}, config={}, credential_name='mycredential')
+        == 'password'
+    )
+
+
+def test_load_credential_with_file_not_found_error_raises():
+    flexmock(module.os.environ).should_receive('get').with_args('CREDENTIALS_DIRECTORY').and_return(
+        '/var'
+    )
+    builtins = flexmock(sys.modules['builtins'])
+    builtins.should_receive('open').with_args('/var/mycredential').and_raise(FileNotFoundError)
+
+    with pytest.raises(ValueError):
+        module.load_credential(hook_config={}, config={}, credential_name='mycredential')

+ 51 - 0
tests/unit/hooks/credential/test_tag.py

@@ -0,0 +1,51 @@
+import pytest
+from flexmock import flexmock
+
+from borgmatic.hooks.credential import tag as module
+
+
+def test_resolve_credential_passes_through_string_without_credential_tag():
+    module.resolve_credential.cache_clear()
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').never()
+
+    assert module.resolve_credential('!no credentials here') == '!no credentials here'
+
+
+def test_resolve_credential_passes_through_none():
+    module.resolve_credential.cache_clear()
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').never()
+
+    assert module.resolve_credential(None) is None
+
+
+def test_resolve_credential_with_invalid_credential_tag_raises():
+    module.resolve_credential.cache_clear()
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').never()
+
+    with pytest.raises(ValueError):
+        module.resolve_credential('!credential systemd')
+
+
+def test_resolve_credential_with_valid_credential_tag_loads_credential():
+    module.resolve_credential.cache_clear()
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args(
+        'load_credential',
+        {},
+        'systemd',
+        'mycredential',
+    ).and_return('result').once()
+
+    assert module.resolve_credential('!credential systemd mycredential') == 'result'
+
+
+def test_resolve_credential_caches_credential_after_first_call():
+    module.resolve_credential.cache_clear()
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args(
+        'load_credential',
+        {},
+        'systemd',
+        'mycredential',
+    ).and_return('result').once()
+
+    assert module.resolve_credential('!credential systemd mycredential') == 'result'
+    assert module.resolve_credential('!credential systemd mycredential') == 'result'

+ 66 - 0
tests/unit/hooks/data_source/test_mariadb.py

@@ -25,6 +25,9 @@ def test_database_names_to_dump_bails_for_dry_run():
 
 
 def test_database_names_to_dump_queries_mariadb_for_database_names():
 def test_database_names_to_dump_queries_mariadb_for_database_names():
     extra_environment = flexmock()
     extra_environment = flexmock()
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_and_capture_output').with_args(
     flexmock(module).should_receive('execute_command_and_capture_output').with_args(
         ('mariadb', '--skip-column-names', '--batch', '--execute', 'show schemas'),
         ('mariadb', '--skip-column-names', '--batch', '--execute', 'show schemas'),
         extra_environment=extra_environment,
         extra_environment=extra_environment,
@@ -50,6 +53,9 @@ def test_dump_data_sources_dumps_each_database():
     databases = [{'name': 'foo'}, {'name': 'bar'}]
     databases = [{'name': 'foo'}, {'name': 'bar'}]
     processes = [flexmock(), flexmock()]
     processes = [flexmock(), flexmock()]
     flexmock(module).should_receive('make_dump_path').and_return('')
     flexmock(module).should_receive('make_dump_path').and_return('')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
     flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
         ('bar',)
         ('bar',)
     )
     )
@@ -81,6 +87,9 @@ def test_dump_data_sources_dumps_with_password():
     database = {'name': 'foo', 'username': 'root', 'password': 'trustsome1'}
     database = {'name': 'foo', 'username': 'root', 'password': 'trustsome1'}
     process = flexmock()
     process = flexmock()
     flexmock(module).should_receive('make_dump_path').and_return('')
     flexmock(module).should_receive('make_dump_path').and_return('')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
     flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
         ('bar',)
         ('bar',)
     )
     )
@@ -108,6 +117,9 @@ def test_dump_data_sources_dumps_all_databases_at_once():
     databases = [{'name': 'all'}]
     databases = [{'name': 'all'}]
     process = flexmock()
     process = flexmock()
     flexmock(module).should_receive('make_dump_path').and_return('')
     flexmock(module).should_receive('make_dump_path').and_return('')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('database_names_to_dump').and_return(('foo', 'bar'))
     flexmock(module).should_receive('database_names_to_dump').and_return(('foo', 'bar'))
     flexmock(module).should_receive('execute_dump_command').with_args(
     flexmock(module).should_receive('execute_dump_command').with_args(
         database={'name': 'all'},
         database={'name': 'all'},
@@ -132,6 +144,9 @@ def test_dump_data_sources_dumps_all_databases_separately_when_format_configured
     databases = [{'name': 'all', 'format': 'sql'}]
     databases = [{'name': 'all', 'format': 'sql'}]
     processes = [flexmock(), flexmock()]
     processes = [flexmock(), flexmock()]
     flexmock(module).should_receive('make_dump_path').and_return('')
     flexmock(module).should_receive('make_dump_path').and_return('')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('database_names_to_dump').and_return(('foo', 'bar'))
     flexmock(module).should_receive('database_names_to_dump').and_return(('foo', 'bar'))
 
 
     for name, process in zip(('foo', 'bar'), processes):
     for name, process in zip(('foo', 'bar'), processes):
@@ -199,6 +214,9 @@ def test_execute_dump_command_runs_mariadb_dump():
     process = flexmock()
     process = flexmock()
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -231,6 +249,9 @@ def test_execute_dump_command_runs_mariadb_dump_without_add_drop_database():
     process = flexmock()
     process = flexmock()
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -262,6 +283,9 @@ def test_execute_dump_command_runs_mariadb_dump_with_hostname_and_port():
     process = flexmock()
     process = flexmock()
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -300,6 +324,9 @@ def test_execute_dump_command_runs_mariadb_dump_with_username_and_password():
     process = flexmock()
     process = flexmock()
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -334,6 +361,9 @@ def test_execute_dump_command_runs_mariadb_dump_with_options():
     process = flexmock()
     process = flexmock()
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -367,6 +397,9 @@ def test_execute_dump_command_runs_non_default_mariadb_dump_with_options():
     process = flexmock()
     process = flexmock()
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -422,6 +455,9 @@ def test_execute_dump_command_with_duplicate_dump_skips_mariadb_dump():
 def test_execute_dump_command_with_dry_run_skips_mariadb_dump():
 def test_execute_dump_command_with_dry_run_skips_mariadb_dump():
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').never()
     flexmock(module).should_receive('execute_command').never()
@@ -442,6 +478,9 @@ def test_execute_dump_command_with_dry_run_skips_mariadb_dump():
 def test_dump_data_sources_errors_for_missing_all_databases():
 def test_dump_data_sources_errors_for_missing_all_databases():
     databases = [{'name': 'all'}]
     databases = [{'name': 'all'}]
     flexmock(module).should_receive('make_dump_path').and_return('')
     flexmock(module).should_receive('make_dump_path').and_return('')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
         'databases/localhost/all'
         'databases/localhost/all'
     )
     )
@@ -461,6 +500,9 @@ def test_dump_data_sources_errors_for_missing_all_databases():
 def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run():
 def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run():
     databases = [{'name': 'all'}]
     databases = [{'name': 'all'}]
     flexmock(module).should_receive('make_dump_path').and_return('')
     flexmock(module).should_receive('make_dump_path').and_return('')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
         'databases/localhost/all'
         'databases/localhost/all'
     )
     )
@@ -483,6 +525,9 @@ def test_restore_data_source_dump_runs_mariadb_to_restore():
     hook_config = [{'name': 'foo'}, {'name': 'bar'}]
     hook_config = [{'name': 'foo'}, {'name': 'bar'}]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         ('mariadb', '--batch'),
         ('mariadb', '--batch'),
         processes=[extract_process],
         processes=[extract_process],
@@ -511,6 +556,9 @@ def test_restore_data_source_dump_runs_mariadb_with_options():
     hook_config = [{'name': 'foo', 'restore_options': '--harder'}]
     hook_config = [{'name': 'foo', 'restore_options': '--harder'}]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         ('mariadb', '--batch', '--harder'),
         ('mariadb', '--batch', '--harder'),
         processes=[extract_process],
         processes=[extract_process],
@@ -541,6 +589,9 @@ def test_restore_data_source_dump_runs_non_default_mariadb_with_options():
     ]
     ]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         ('custom_mariadb', '--batch', '--harder'),
         ('custom_mariadb', '--batch', '--harder'),
         processes=[extract_process],
         processes=[extract_process],
@@ -569,6 +620,9 @@ def test_restore_data_source_dump_runs_mariadb_with_hostname_and_port():
     hook_config = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
     hook_config = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         (
         (
             'mariadb',
             'mariadb',
@@ -606,6 +660,9 @@ def test_restore_data_source_dump_runs_mariadb_with_username_and_password():
     hook_config = [{'name': 'foo', 'username': 'root', 'password': 'trustsome1'}]
     hook_config = [{'name': 'foo', 'username': 'root', 'password': 'trustsome1'}]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         ('mariadb', '--batch', '--user', 'root'),
         ('mariadb', '--batch', '--user', 'root'),
         processes=[extract_process],
         processes=[extract_process],
@@ -644,6 +701,9 @@ def test_restore_data_source_dump_with_connection_params_uses_connection_params_
     ]
     ]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         (
         (
             'mariadb',
             'mariadb',
@@ -695,6 +755,9 @@ def test_restore_data_source_dump_without_connection_params_uses_restore_params_
     ]
     ]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         (
         (
             'mariadb',
             'mariadb',
@@ -733,6 +796,9 @@ def test_restore_data_source_dump_without_connection_params_uses_restore_params_
 def test_restore_data_source_dump_with_dry_run_skips_restore():
 def test_restore_data_source_dump_with_dry_run_skips_restore():
     hook_config = [{'name': 'foo'}]
     hook_config = [{'name': 'foo'}]
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').never()
     flexmock(module).should_receive('execute_command_with_processes').never()
 
 
     module.restore_data_source_dump(
     module.restore_data_source_dump(

+ 36 - 0
tests/unit/hooks/data_source/test_mongodb.py

@@ -124,6 +124,9 @@ def test_dump_data_sources_runs_mongodump_with_username_and_password():
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
         'databases/localhost/foo'
         'databases/localhost/foo'
     )
     )
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -242,6 +245,9 @@ def test_dump_data_sources_runs_mongodumpall_for_all_databases():
 
 
 def test_build_dump_command_with_username_injection_attack_gets_escaped():
 def test_build_dump_command_with_username_injection_attack_gets_escaped():
     database = {'name': 'test', 'username': 'bob; naughty-command'}
     database = {'name': 'test', 'username': 'bob; naughty-command'}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
 
 
     command = module.build_dump_command(database, dump_filename='test', dump_format='archive')
     command = module.build_dump_command(database, dump_filename='test', dump_format='archive')
 
 
@@ -254,6 +260,9 @@ def test_restore_data_source_dump_runs_mongorestore():
 
 
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         ['mongorestore', '--archive', '--drop'],
         ['mongorestore', '--archive', '--drop'],
         processes=[extract_process],
         processes=[extract_process],
@@ -285,6 +294,9 @@ def test_restore_data_source_dump_runs_mongorestore_with_hostname_and_port():
 
 
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         [
         [
             'mongorestore',
             'mongorestore',
@@ -330,6 +342,9 @@ def test_restore_data_source_dump_runs_mongorestore_with_username_and_password()
 
 
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         [
         [
             'mongorestore',
             'mongorestore',
@@ -381,6 +396,9 @@ def test_restore_data_source_dump_with_connection_params_uses_connection_params_
 
 
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         [
         [
             'mongorestore',
             'mongorestore',
@@ -436,6 +454,9 @@ def test_restore_data_source_dump_without_connection_params_uses_restore_params_
 
 
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         [
         [
             'mongorestore',
             'mongorestore',
@@ -479,6 +500,9 @@ def test_restore_data_source_dump_runs_mongorestore_with_options():
 
 
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         ['mongorestore', '--archive', '--drop', '--harder'],
         ['mongorestore', '--archive', '--drop', '--harder'],
         processes=[extract_process],
         processes=[extract_process],
@@ -508,6 +532,9 @@ def test_restore_databases_dump_runs_mongorestore_with_schemas():
 
 
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         [
         [
             'mongorestore',
             'mongorestore',
@@ -545,6 +572,9 @@ def test_restore_data_source_dump_runs_psql_for_all_database_dump():
 
 
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         ['mongorestore', '--archive'],
         ['mongorestore', '--archive'],
         processes=[extract_process],
         processes=[extract_process],
@@ -573,6 +603,9 @@ def test_restore_data_source_dump_with_dry_run_skips_restore():
 
 
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').never()
     flexmock(module).should_receive('execute_command_with_processes').never()
 
 
     module.restore_data_source_dump(
     module.restore_data_source_dump(
@@ -596,6 +629,9 @@ def test_restore_data_source_dump_without_extract_process_restores_from_disk():
 
 
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('/dump/path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('/dump/path')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         ['mongorestore', '--dir', '/dump/path', '--drop'],
         ['mongorestore', '--dir', '/dump/path', '--drop'],
         processes=[],
         processes=[],

+ 60 - 0
tests/unit/hooks/data_source/test_mysql.py

@@ -16,6 +16,9 @@ def test_database_names_to_dump_passes_through_name():
 
 
 def test_database_names_to_dump_bails_for_dry_run():
 def test_database_names_to_dump_bails_for_dry_run():
     extra_environment = flexmock()
     extra_environment = flexmock()
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_and_capture_output').never()
     flexmock(module).should_receive('execute_command_and_capture_output').never()
 
 
     names = module.database_names_to_dump({'name': 'all'}, extra_environment, dry_run=True)
     names = module.database_names_to_dump({'name': 'all'}, extra_environment, dry_run=True)
@@ -25,6 +28,9 @@ def test_database_names_to_dump_bails_for_dry_run():
 
 
 def test_database_names_to_dump_queries_mysql_for_database_names():
 def test_database_names_to_dump_queries_mysql_for_database_names():
     extra_environment = flexmock()
     extra_environment = flexmock()
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_and_capture_output').with_args(
     flexmock(module).should_receive('execute_command_and_capture_output').with_args(
         ('mysql', '--skip-column-names', '--batch', '--execute', 'show schemas'),
         ('mysql', '--skip-column-names', '--batch', '--execute', 'show schemas'),
         extra_environment=extra_environment,
         extra_environment=extra_environment,
@@ -81,6 +87,9 @@ def test_dump_data_sources_dumps_with_password():
     database = {'name': 'foo', 'username': 'root', 'password': 'trustsome1'}
     database = {'name': 'foo', 'username': 'root', 'password': 'trustsome1'}
     process = flexmock()
     process = flexmock()
     flexmock(module).should_receive('make_dump_path').and_return('')
     flexmock(module).should_receive('make_dump_path').and_return('')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
     flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
         ('bar',)
         ('bar',)
     )
     )
@@ -199,6 +208,9 @@ def test_execute_dump_command_runs_mysqldump():
     process = flexmock()
     process = flexmock()
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -231,6 +243,9 @@ def test_execute_dump_command_runs_mysqldump_without_add_drop_database():
     process = flexmock()
     process = flexmock()
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -262,6 +277,9 @@ def test_execute_dump_command_runs_mysqldump_with_hostname_and_port():
     process = flexmock()
     process = flexmock()
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -300,6 +318,9 @@ def test_execute_dump_command_runs_mysqldump_with_username_and_password():
     process = flexmock()
     process = flexmock()
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -334,6 +355,9 @@ def test_execute_dump_command_runs_mysqldump_with_options():
     process = flexmock()
     process = flexmock()
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -367,6 +391,9 @@ def test_execute_dump_command_runs_non_default_mysqldump():
     process = flexmock()
     process = flexmock()
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -420,6 +447,9 @@ def test_execute_dump_command_with_duplicate_dump_skips_mysqldump():
 def test_execute_dump_command_with_dry_run_skips_mysqldump():
 def test_execute_dump_command_with_dry_run_skips_mysqldump():
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').never()
     flexmock(module).should_receive('execute_command').never()
@@ -440,6 +470,9 @@ def test_execute_dump_command_with_dry_run_skips_mysqldump():
 def test_dump_data_sources_errors_for_missing_all_databases():
 def test_dump_data_sources_errors_for_missing_all_databases():
     databases = [{'name': 'all'}]
     databases = [{'name': 'all'}]
     flexmock(module).should_receive('make_dump_path').and_return('')
     flexmock(module).should_receive('make_dump_path').and_return('')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
         'databases/localhost/all'
         'databases/localhost/all'
     )
     )
@@ -459,6 +492,9 @@ def test_dump_data_sources_errors_for_missing_all_databases():
 def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run():
 def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run():
     databases = [{'name': 'all'}]
     databases = [{'name': 'all'}]
     flexmock(module).should_receive('make_dump_path').and_return('')
     flexmock(module).should_receive('make_dump_path').and_return('')
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
         'databases/localhost/all'
         'databases/localhost/all'
     )
     )
@@ -481,6 +517,9 @@ def test_restore_data_source_dump_runs_mysql_to_restore():
     hook_config = [{'name': 'foo'}, {'name': 'bar'}]
     hook_config = [{'name': 'foo'}, {'name': 'bar'}]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         ('mysql', '--batch'),
         ('mysql', '--batch'),
         processes=[extract_process],
         processes=[extract_process],
@@ -509,6 +548,9 @@ def test_restore_data_source_dump_runs_mysql_with_options():
     hook_config = [{'name': 'foo', 'restore_options': '--harder'}]
     hook_config = [{'name': 'foo', 'restore_options': '--harder'}]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         ('mysql', '--batch', '--harder'),
         ('mysql', '--batch', '--harder'),
         processes=[extract_process],
         processes=[extract_process],
@@ -537,6 +579,9 @@ def test_restore_data_source_dump_runs_non_default_mysql_with_options():
     hook_config = [{'name': 'foo', 'mysql_command': 'custom_mysql', 'restore_options': '--harder'}]
     hook_config = [{'name': 'foo', 'mysql_command': 'custom_mysql', 'restore_options': '--harder'}]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         ('custom_mysql', '--batch', '--harder'),
         ('custom_mysql', '--batch', '--harder'),
         processes=[extract_process],
         processes=[extract_process],
@@ -565,6 +610,9 @@ def test_restore_data_source_dump_runs_mysql_with_hostname_and_port():
     hook_config = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
     hook_config = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         (
         (
             'mysql',
             'mysql',
@@ -602,6 +650,9 @@ def test_restore_data_source_dump_runs_mysql_with_username_and_password():
     hook_config = [{'name': 'foo', 'username': 'root', 'password': 'trustsome1'}]
     hook_config = [{'name': 'foo', 'username': 'root', 'password': 'trustsome1'}]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         ('mysql', '--batch', '--user', 'root'),
         ('mysql', '--batch', '--user', 'root'),
         processes=[extract_process],
         processes=[extract_process],
@@ -640,6 +691,9 @@ def test_restore_data_source_dump_with_connection_params_uses_connection_params_
     ]
     ]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         (
         (
             'mysql',
             'mysql',
@@ -691,6 +745,9 @@ def test_restore_data_source_dump_without_connection_params_uses_restore_params_
     ]
     ]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').with_args(
     flexmock(module).should_receive('execute_command_with_processes').with_args(
         (
         (
             'mysql',
             'mysql',
@@ -729,6 +786,9 @@ def test_restore_data_source_dump_without_connection_params_uses_restore_params_
 def test_restore_data_source_dump_with_dry_run_skips_restore():
 def test_restore_data_source_dump_with_dry_run_skips_restore():
     hook_config = [{'name': 'foo'}]
     hook_config = [{'name': 'foo'}]
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_with_processes').never()
     flexmock(module).should_receive('execute_command_with_processes').never()
 
 
     module.restore_data_source_dump(
     module.restore_data_source_dump(

+ 90 - 0
tests/unit/hooks/data_source/test_postgresql.py

@@ -24,6 +24,9 @@ def test_make_extra_environment_maps_options_to_environment():
         'PGSSLROOTCERT': 'root.crt',
         'PGSSLROOTCERT': 'root.crt',
         'PGSSLCRL': 'crl.crl',
         'PGSSLCRL': 'crl.crl',
     }
     }
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
 
 
     extra_env = module.make_extra_environment(database)
     extra_env = module.make_extra_environment(database)
 
 
@@ -32,6 +35,9 @@ def test_make_extra_environment_maps_options_to_environment():
 
 
 def test_make_extra_environment_with_cli_password_sets_correct_password():
 def test_make_extra_environment_with_cli_password_sets_correct_password():
     database = {'name': 'foo', 'restore_password': 'trustsome1', 'password': 'anotherpassword'}
     database = {'name': 'foo', 'restore_password': 'trustsome1', 'password': 'anotherpassword'}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
 
 
     extra = module.make_extra_environment(
     extra = module.make_extra_environment(
         database, restore_connection_params={'password': 'clipassword'}
         database, restore_connection_params={'password': 'clipassword'}
@@ -78,6 +84,9 @@ def test_database_names_to_dump_passes_through_all_without_format():
 
 
 def test_database_names_to_dump_with_all_and_format_and_dry_run_bails():
 def test_database_names_to_dump_with_all_and_format_and_dry_run_bails():
     database = {'name': 'all', 'format': 'custom'}
     database = {'name': 'all', 'format': 'custom'}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_and_capture_output').never()
     flexmock(module).should_receive('execute_command_and_capture_output').never()
 
 
     assert module.database_names_to_dump(database, flexmock(), dry_run=True) == ()
     assert module.database_names_to_dump(database, flexmock(), dry_run=True) == ()
@@ -85,6 +94,9 @@ def test_database_names_to_dump_with_all_and_format_and_dry_run_bails():
 
 
 def test_database_names_to_dump_with_all_and_format_lists_databases():
 def test_database_names_to_dump_with_all_and_format_lists_databases():
     database = {'name': 'all', 'format': 'custom'}
     database = {'name': 'all', 'format': 'custom'}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_and_capture_output').and_return(
     flexmock(module).should_receive('execute_command_and_capture_output').and_return(
         'foo,test,\nbar,test,"stuff and such"'
         'foo,test,\nbar,test,"stuff and such"'
     )
     )
@@ -97,6 +109,9 @@ def test_database_names_to_dump_with_all_and_format_lists_databases():
 
 
 def test_database_names_to_dump_with_all_and_format_lists_databases_with_hostname_and_port():
 def test_database_names_to_dump_with_all_and_format_lists_databases_with_hostname_and_port():
     database = {'name': 'all', 'format': 'custom', 'hostname': 'localhost', 'port': 1234}
     database = {'name': 'all', 'format': 'custom', 'hostname': 'localhost', 'port': 1234}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_and_capture_output').with_args(
     flexmock(module).should_receive('execute_command_and_capture_output').with_args(
         (
         (
             'psql',
             'psql',
@@ -121,6 +136,9 @@ def test_database_names_to_dump_with_all_and_format_lists_databases_with_hostnam
 
 
 def test_database_names_to_dump_with_all_and_format_lists_databases_with_username():
 def test_database_names_to_dump_with_all_and_format_lists_databases_with_username():
     database = {'name': 'all', 'format': 'custom', 'username': 'postgres'}
     database = {'name': 'all', 'format': 'custom', 'username': 'postgres'}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_and_capture_output').with_args(
     flexmock(module).should_receive('execute_command_and_capture_output').with_args(
         (
         (
             'psql',
             'psql',
@@ -143,6 +161,9 @@ def test_database_names_to_dump_with_all_and_format_lists_databases_with_usernam
 
 
 def test_database_names_to_dump_with_all_and_format_lists_databases_with_options():
 def test_database_names_to_dump_with_all_and_format_lists_databases_with_options():
     database = {'name': 'all', 'format': 'custom', 'list_options': '--harder'}
     database = {'name': 'all', 'format': 'custom', 'list_options': '--harder'}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_and_capture_output').with_args(
     flexmock(module).should_receive('execute_command_and_capture_output').with_args(
         ('psql', '--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only', '--harder'),
         ('psql', '--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only', '--harder'),
         extra_environment=object,
         extra_environment=object,
@@ -156,6 +177,9 @@ def test_database_names_to_dump_with_all_and_format_lists_databases_with_options
 
 
 def test_database_names_to_dump_with_all_and_format_excludes_particular_databases():
 def test_database_names_to_dump_with_all_and_format_excludes_particular_databases():
     database = {'name': 'all', 'format': 'custom'}
     database = {'name': 'all', 'format': 'custom'}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_and_capture_output').and_return(
     flexmock(module).should_receive('execute_command_and_capture_output').and_return(
         'foo,test,\ntemplate0,test,blah'
         'foo,test,\ntemplate0,test,blah'
     )
     )
@@ -169,6 +193,9 @@ def test_database_names_to_dump_with_all_and_psql_command_uses_custom_command():
         'format': 'custom',
         'format': 'custom',
         'psql_command': 'docker exec --workdir * mycontainer psql',
         'psql_command': 'docker exec --workdir * mycontainer psql',
     }
     }
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('execute_command_and_capture_output').with_args(
     flexmock(module).should_receive('execute_command_and_capture_output').with_args(
         (
         (
             'docker',
             'docker',
@@ -219,6 +246,9 @@ def test_dump_data_sources_runs_pg_dump_for_each_database():
         'databases/localhost/foo'
         'databases/localhost/foo'
     ).and_return('databases/localhost/bar')
     ).and_return('databases/localhost/bar')
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     for name, process in zip(('foo', 'bar'), processes):
     for name, process in zip(('foo', 'bar'), processes):
@@ -323,6 +353,9 @@ def test_dump_data_sources_with_dry_run_skips_pg_dump():
         'databases/localhost/foo'
         'databases/localhost/foo'
     ).and_return('databases/localhost/bar')
     ).and_return('databases/localhost/bar')
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
     flexmock(module).should_receive('execute_command').never()
     flexmock(module).should_receive('execute_command').never()
 
 
@@ -349,6 +382,9 @@ def test_dump_data_sources_runs_pg_dump_with_hostname_and_port():
         'databases/database.example.org/foo'
         'databases/database.example.org/foo'
     )
     )
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -394,6 +430,9 @@ def test_dump_data_sources_runs_pg_dump_with_username_and_password():
         'databases/localhost/foo'
         'databases/localhost/foo'
     )
     )
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -437,6 +476,9 @@ def test_dump_data_sources_with_username_injection_attack_gets_escaped():
         'databases/localhost/foo'
         'databases/localhost/foo'
     )
     )
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -477,6 +519,9 @@ def test_dump_data_sources_runs_pg_dump_with_directory_format():
         'databases/localhost/foo'
         'databases/localhost/foo'
     )
     )
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_parent_directory_for_dump')
     flexmock(module.dump).should_receive('create_parent_directory_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
 
 
@@ -519,6 +564,9 @@ def test_dump_data_sources_runs_pg_dump_with_options():
         'databases/localhost/foo'
         'databases/localhost/foo'
     )
     )
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -559,6 +607,9 @@ def test_dump_data_sources_runs_pg_dumpall_for_all_databases():
         'databases/localhost/all'
         'databases/localhost/all'
     )
     )
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -588,6 +639,9 @@ def test_dump_data_sources_runs_non_default_pg_dump():
         'databases/localhost/foo'
         'databases/localhost/foo'
     )
     )
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -623,6 +677,9 @@ def test_restore_data_source_dump_runs_pg_restore():
     hook_config = [{'name': 'foo', 'schemas': None}, {'name': 'bar'}]
     hook_config = [{'name': 'foo', 'schemas': None}, {'name': 'bar'}]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
@@ -677,6 +734,9 @@ def test_restore_data_source_dump_runs_pg_restore_with_hostname_and_port():
     ]
     ]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
@@ -739,6 +799,9 @@ def test_restore_data_source_dump_runs_pg_restore_with_username_and_password():
     ]
     ]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('make_extra_environment').and_return(
     flexmock(module).should_receive('make_extra_environment').and_return(
         {'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'}
         {'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'}
     )
     )
@@ -810,6 +873,9 @@ def test_restore_data_source_dump_with_connection_params_uses_connection_params_
     ]
     ]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('make_extra_environment').and_return(
     flexmock(module).should_receive('make_extra_environment').and_return(
         {'PGPASSWORD': 'clipassword', 'PGSSLMODE': 'disable'}
         {'PGPASSWORD': 'clipassword', 'PGSSLMODE': 'disable'}
     )
     )
@@ -889,6 +955,9 @@ def test_restore_data_source_dump_without_connection_params_uses_restore_params_
     ]
     ]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('make_extra_environment').and_return(
     flexmock(module).should_receive('make_extra_environment').and_return(
         {'PGPASSWORD': 'restorepassword', 'PGSSLMODE': 'disable'}
         {'PGPASSWORD': 'restorepassword', 'PGSSLMODE': 'disable'}
     )
     )
@@ -962,6 +1031,9 @@ def test_restore_data_source_dump_runs_pg_restore_with_options():
     ]
     ]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
@@ -1016,6 +1088,9 @@ def test_restore_data_source_dump_runs_psql_for_all_database_dump():
     hook_config = [{'name': 'all', 'schemas': None}]
     hook_config = [{'name': 'all', 'schemas': None}]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
@@ -1055,6 +1130,9 @@ def test_restore_data_source_dump_runs_psql_for_plain_database_dump():
     hook_config = [{'name': 'foo', 'format': 'plain', 'schemas': None}]
     hook_config = [{'name': 'foo', 'format': 'plain', 'schemas': None}]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
@@ -1106,6 +1184,9 @@ def test_restore_data_source_dump_runs_non_default_pg_restore_and_psql():
     ]
     ]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
@@ -1167,6 +1248,9 @@ def test_restore_data_source_dump_runs_non_default_pg_restore_and_psql():
 def test_restore_data_source_dump_with_dry_run_skips_restore():
 def test_restore_data_source_dump_with_dry_run_skips_restore():
     hook_config = [{'name': 'foo', 'schemas': None}]
     hook_config = [{'name': 'foo', 'schemas': None}]
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
     flexmock(module.dump).should_receive('make_data_source_dump_filename')
@@ -1191,6 +1275,9 @@ def test_restore_data_source_dump_with_dry_run_skips_restore():
 def test_restore_data_source_dump_without_extract_process_restores_from_disk():
 def test_restore_data_source_dump_without_extract_process_restores_from_disk():
     hook_config = [{'name': 'foo', 'schemas': None}]
     hook_config = [{'name': 'foo', 'schemas': None}]
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('/dump/path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('/dump/path')
@@ -1243,6 +1330,9 @@ def test_restore_data_source_dump_without_extract_process_restores_from_disk():
 def test_restore_data_source_dump_with_schemas_restores_schemas():
 def test_restore_data_source_dump_with_schemas_restores_schemas():
     hook_config = [{'name': 'foo', 'schemas': ['bar', 'baz']}]
     hook_config = [{'name': 'foo', 'schemas': ['bar', 'baz']}]
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_dump_path')
     flexmock(module).should_receive('make_dump_path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('/dump/path')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('/dump/path')

+ 60 - 0
tests/unit/hooks/monitoring/test_ntfy.py

@@ -36,6 +36,9 @@ def return_default_message_headers(state=Enum):
 
 
 def test_ping_monitor_minimal_config_hits_hosted_ntfy_on_fail():
 def test_ping_monitor_minimal_config_hits_hosted_ntfy_on_fail():
     hook_config = {'topic': topic}
     hook_config = {'topic': topic}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     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(borgmatic.hooks.monitoring.monitor.State.FAIL),
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
@@ -57,6 +60,9 @@ def test_ping_monitor_with_access_token_hits_hosted_ntfy_on_fail():
         'topic': topic,
         'topic': topic,
         'access_token': 'abc123',
         'access_token': 'abc123',
     }
     }
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     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(borgmatic.hooks.monitoring.monitor.State.FAIL),
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
@@ -80,6 +86,9 @@ def test_ping_monitor_with_username_password_and_access_token_ignores_username_p
         'password': 'fakepassword',
         'password': 'fakepassword',
         'access_token': 'abc123',
         'access_token': 'abc123',
     }
     }
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     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(borgmatic.hooks.monitoring.monitor.State.FAIL),
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
@@ -103,6 +112,9 @@ def test_ping_monitor_with_username_password_hits_hosted_ntfy_on_fail():
         'username': 'testuser',
         'username': 'testuser',
         'password': 'fakepassword',
         'password': 'fakepassword',
     }
     }
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     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(borgmatic.hooks.monitoring.monitor.State.FAIL),
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
@@ -121,6 +133,9 @@ def test_ping_monitor_with_username_password_hits_hosted_ntfy_on_fail():
 
 
 def test_ping_monitor_with_password_but_no_username_warns():
 def test_ping_monitor_with_password_but_no_username_warns():
     hook_config = {'topic': topic, 'password': 'fakepassword'}
     hook_config = {'topic': topic, 'password': 'fakepassword'}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     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(borgmatic.hooks.monitoring.monitor.State.FAIL),
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
@@ -140,6 +155,9 @@ def test_ping_monitor_with_password_but_no_username_warns():
 
 
 def test_ping_monitor_with_username_but_no_password_warns():
 def test_ping_monitor_with_username_but_no_password_warns():
     hook_config = {'topic': topic, 'username': 'testuser'}
     hook_config = {'topic': topic, 'username': 'testuser'}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     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(borgmatic.hooks.monitoring.monitor.State.FAIL),
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
@@ -159,6 +177,9 @@ def test_ping_monitor_with_username_but_no_password_warns():
 
 
 def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_start():
 def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_start():
     hook_config = {'topic': topic}
     hook_config = {'topic': topic}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
     module.ping_monitor(
     module.ping_monitor(
@@ -173,6 +194,9 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_start():
 
 
 def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_finish():
 def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_finish():
     hook_config = {'topic': topic}
     hook_config = {'topic': topic}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
     module.ping_monitor(
     module.ping_monitor(
@@ -187,6 +211,9 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_finish():
 
 
 def test_ping_monitor_minimal_config_hits_selfhosted_ntfy_on_fail():
 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.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     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(borgmatic.hooks.monitoring.monitor.State.FAIL),
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
@@ -205,6 +232,9 @@ def test_ping_monitor_minimal_config_hits_selfhosted_ntfy_on_fail():
 
 
 def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_fail_dry_run():
 def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_fail_dry_run():
     hook_config = {'topic': topic}
     hook_config = {'topic': topic}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
     module.ping_monitor(
     module.ping_monitor(
@@ -219,6 +249,9 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_fail_dry_run():
 
 
 def test_ping_monitor_custom_message_hits_hosted_ntfy_on_fail():
 def test_ping_monitor_custom_message_hits_hosted_ntfy_on_fail():
     hook_config = {'topic': topic, 'fail': custom_message_config}
     hook_config = {'topic': topic, 'fail': custom_message_config}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         f'{default_base_url}/{topic}', headers=custom_message_headers, auth=None
         f'{default_base_url}/{topic}', headers=custom_message_headers, auth=None
     ).and_return(flexmock(ok=True)).once()
     ).and_return(flexmock(ok=True)).once()
@@ -235,6 +268,9 @@ def test_ping_monitor_custom_message_hits_hosted_ntfy_on_fail():
 
 
 def test_ping_monitor_custom_state_hits_hosted_ntfy_on_start():
 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.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     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(borgmatic.hooks.monitoring.monitor.State.START),
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.START),
@@ -253,6 +289,9 @@ def test_ping_monitor_custom_state_hits_hosted_ntfy_on_start():
 
 
 def test_ping_monitor_with_connection_error_logs_warning():
 def test_ping_monitor_with_connection_error_logs_warning():
     hook_config = {'topic': topic}
     hook_config = {'topic': topic}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     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(borgmatic.hooks.monitoring.monitor.State.FAIL),
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
@@ -270,8 +309,29 @@ def test_ping_monitor_with_connection_error_logs_warning():
     )
     )
 
 
 
 
+def test_ping_monitor_with_credential_error_logs_warning():
+    hook_config = {'topic': topic}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive('resolve_credential').and_raise(
+        ValueError
+    )
+    flexmock(module.requests).should_receive('post').never()
+    flexmock(module.logger).should_receive('warning').once()
+
+    module.ping_monitor(
+        hook_config,
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitoring.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
+    )
+
+
 def test_ping_monitor_with_other_error_logs_warning():
 def test_ping_monitor_with_other_error_logs_warning():
     hook_config = {'topic': topic}
     hook_config = {'topic': topic}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     response = flexmock(ok=False)
     response = flexmock(ok=False)
     response.should_receive('raise_for_status').and_raise(
     response.should_receive('raise_for_status').and_raise(
         module.requests.exceptions.RequestException
         module.requests.exceptions.RequestException

+ 35 - 0
tests/unit/hooks/monitoring/test_pagerduty.py

@@ -4,6 +4,9 @@ from borgmatic.hooks.monitoring import pagerduty as module
 
 
 
 
 def test_ping_monitor_ignores_start_state():
 def test_ping_monitor_ignores_start_state():
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
     module.ping_monitor(
     module.ping_monitor(
@@ -17,6 +20,9 @@ def test_ping_monitor_ignores_start_state():
 
 
 
 
 def test_ping_monitor_ignores_finish_state():
 def test_ping_monitor_ignores_finish_state():
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
     module.ping_monitor(
     module.ping_monitor(
@@ -30,6 +36,9 @@ def test_ping_monitor_ignores_finish_state():
 
 
 
 
 def test_ping_monitor_calls_api_for_fail_state():
 def test_ping_monitor_calls_api_for_fail_state():
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.requests).should_receive('post').and_return(flexmock(ok=True))
     flexmock(module.requests).should_receive('post').and_return(flexmock(ok=True))
 
 
     module.ping_monitor(
     module.ping_monitor(
@@ -43,6 +52,9 @@ def test_ping_monitor_calls_api_for_fail_state():
 
 
 
 
 def test_ping_monitor_dry_run_does_not_call_api():
 def test_ping_monitor_dry_run_does_not_call_api():
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
     module.ping_monitor(
     module.ping_monitor(
@@ -56,6 +68,9 @@ def test_ping_monitor_dry_run_does_not_call_api():
 
 
 
 
 def test_ping_monitor_with_connection_error_logs_warning():
 def test_ping_monitor_with_connection_error_logs_warning():
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.requests).should_receive('post').and_raise(
     flexmock(module.requests).should_receive('post').and_raise(
         module.requests.exceptions.ConnectionError
         module.requests.exceptions.ConnectionError
     )
     )
@@ -71,8 +86,28 @@ def test_ping_monitor_with_connection_error_logs_warning():
     )
     )
 
 
 
 
+def test_ping_monitor_with_credential_error_logs_warning():
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive('resolve_credential').and_raise(
+        ValueError
+    )
+    flexmock(module.requests).should_receive('post').never()
+    flexmock(module.logger).should_receive('warning')
+
+    module.ping_monitor(
+        {'integration_key': 'abc123'},
+        {},
+        'config.yaml',
+        module.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
+    )
+
+
 def test_ping_monitor_with_other_error_logs_warning():
 def test_ping_monitor_with_other_error_logs_warning():
     response = flexmock(ok=False)
     response = flexmock(ok=False)
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     response.should_receive('raise_for_status').and_raise(
     response.should_receive('raise_for_status').and_raise(
         module.requests.exceptions.RequestException
         module.requests.exceptions.RequestException
     )
     )

+ 66 - 2
tests/unit/hooks/monitoring/test_pushover.py

@@ -11,6 +11,9 @@ def test_ping_monitor_config_with_minimum_config_fail_state_backup_successfully_
     should be auto populated with the default value which is the state name.
     should be auto populated with the default value which is the state name.
     '''
     '''
     hook_config = {'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui'}
     hook_config = {'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui'}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         'https://api.pushover.net/1/messages.json',
         'https://api.pushover.net/1/messages.json',
@@ -38,6 +41,9 @@ def test_ping_monitor_config_with_minimum_config_start_state_backup_not_send_to_
     'start' state. Only the 'fail' state is enabled by default.
     'start' state. Only the 'fail' state is enabled by default.
     '''
     '''
     hook_config = {'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui'}
     hook_config = {'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui'}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
@@ -63,6 +69,9 @@ def test_ping_monitor_start_state_backup_default_message_successfully_send_to_pu
         'user': '983hfe0of902lkjfa2amanfgui',
         'user': '983hfe0of902lkjfa2amanfgui',
         'states': {'start', 'fail', 'finish'},
         'states': {'start', 'fail', 'finish'},
     }
     }
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         'https://api.pushover.net/1/messages.json',
         'https://api.pushover.net/1/messages.json',
@@ -96,6 +105,9 @@ def test_ping_monitor_start_state_backup_custom_message_successfully_send_to_pus
         'states': {'start', 'fail', 'finish'},
         'states': {'start', 'fail', 'finish'},
         'start': {'message': 'custom start message'},
         'start': {'message': 'custom start message'},
     }
     }
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         'https://api.pushover.net/1/messages.json',
         'https://api.pushover.net/1/messages.json',
@@ -128,6 +140,9 @@ def test_ping_monitor_start_state_backup_default_message_with_priority_emergency
         'states': {'start', 'fail', 'finish'},
         'states': {'start', 'fail', 'finish'},
         'start': {'priority': 2},
         'start': {'priority': 2},
     }
     }
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         'https://api.pushover.net/1/messages.json',
         'https://api.pushover.net/1/messages.json',
@@ -163,6 +178,9 @@ def test_ping_monitor_start_state_backup_default_message_with_priority_emergency
         'states': {'start', 'fail', 'finish'},
         'states': {'start', 'fail', 'finish'},
         'start': {'priority': 2, 'expire': 600},
         'start': {'priority': 2, 'expire': 600},
     }
     }
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         'https://api.pushover.net/1/messages.json',
         'https://api.pushover.net/1/messages.json',
@@ -198,6 +216,9 @@ def test_ping_monitor_start_state_backup_default_message_with_priority_emergency
         'states': {'start', 'fail', 'finish'},
         'states': {'start', 'fail', 'finish'},
         'start': {'priority': 2, 'retry': 30},
         'start': {'priority': 2, 'retry': 30},
     }
     }
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         'https://api.pushover.net/1/messages.json',
         'https://api.pushover.net/1/messages.json',
@@ -236,6 +257,9 @@ def test_ping_monitor_start_state_backup_default_message_with_priority_high_decl
         'start': {'priority': 1, 'expire': 30, 'retry': 30},
         'start': {'priority': 1, 'expire': 30, 'retry': 30},
     }
     }
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
@@ -288,6 +312,9 @@ def test_ping_monitor_start_state_backup_based_on_documentation_advanced_example
             'url_title': 'Login to ticketing system',
             'url_title': 'Login to ticketing system',
         },
         },
     }
     }
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         'https://api.pushover.net/1/messages.json',
         'https://api.pushover.net/1/messages.json',
@@ -351,6 +378,9 @@ def test_ping_monitor_fail_state_backup_based_on_documentation_advanced_example_
             'url_title': 'Login to ticketing system',
             'url_title': 'Login to ticketing system',
         },
         },
     }
     }
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         'https://api.pushover.net/1/messages.json',
         'https://api.pushover.net/1/messages.json',
@@ -419,6 +449,9 @@ def test_ping_monitor_finish_state_backup_based_on_documentation_advanced_exampl
             'url_title': 'Login to ticketing system',
             'url_title': 'Login to ticketing system',
         },
         },
     }
     }
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         'https://api.pushover.net/1/messages.json',
         'https://api.pushover.net/1/messages.json',
@@ -446,12 +479,15 @@ def test_ping_monitor_finish_state_backup_based_on_documentation_advanced_exampl
     )
     )
 
 
 
 
-def test_ping_monitor_config_with_minimum_config_fail_state_backup_successfully_send_to_pushover_dryrun():
+def test_ping_monitor_config_with_minimum_config_fail_state_backup_successfully_send_to_pushover_dry_run():
     '''
     '''
     This test should be the minimum working configuration. The "message"
     This test should be the minimum working configuration. The "message"
     should be auto populated with the default value which is the state name.
     should be auto populated with the default value which is the state name.
     '''
     '''
     hook_config = {'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui'}
     hook_config = {'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui'}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.requests).should_receive('post').and_return(flexmock(ok=True)).never()
     flexmock(module.requests).should_receive('post').and_return(flexmock(ok=True)).never()
 
 
@@ -473,6 +509,9 @@ def test_ping_monitor_config_incorrect_state_exit_early():
         'token': 'ksdjfwoweijfvwoeifvjmwghagy92',
         'token': 'ksdjfwoweijfvwoeifvjmwghagy92',
         'user': '983hfe0of902lkjfa2amanfgui',
         'user': '983hfe0of902lkjfa2amanfgui',
     }
     }
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.logger).should_receive('warning').never()
     flexmock(module.requests).should_receive('post').and_return(flexmock(ok=True)).never()
     flexmock(module.requests).should_receive('post').and_return(flexmock(ok=True)).never()
 
 
@@ -486,7 +525,7 @@ def test_ping_monitor_config_incorrect_state_exit_early():
     )
     )
 
 
 
 
-def test_ping_monitor_push_post_error_exits_early():
+def test_ping_monitor_push_post_error_bails():
     '''
     '''
     This test simulates the Pushover servers not responding with a 200 OK. We
     This test simulates the Pushover servers not responding with a 200 OK. We
     should raise for status and warn then exit.
     should raise for status and warn then exit.
@@ -496,6 +535,9 @@ def test_ping_monitor_push_post_error_exits_early():
         'user': '983hfe0of902lkjfa2amanfgui',
         'user': '983hfe0of902lkjfa2amanfgui',
     }
     }
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     push_response = flexmock(ok=False)
     push_response = flexmock(ok=False)
     push_response.should_receive('raise_for_status').and_raise(
     push_response.should_receive('raise_for_status').and_raise(
         module.requests.ConnectionError
         module.requests.ConnectionError
@@ -520,3 +562,25 @@ def test_ping_monitor_push_post_error_exits_early():
         monitoring_log_level=1,
         monitoring_log_level=1,
         dry_run=False,
         dry_run=False,
     )
     )
+
+
+def test_ping_monitor_credential_error_bails():
+    hook_config = hook_config = {
+        'token': 'ksdjfwoweijfvwoeifvjmwghagy92',
+        'user': '983hfe0of902lkjfa2amanfgui',
+    }
+
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive('resolve_credential').and_raise(
+        ValueError
+    )
+    flexmock(module.requests).should_receive('post').never()
+    flexmock(module.logger).should_receive('warning').once()
+
+    module.ping_monitor(
+        hook_config,
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitoring.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
+    )

+ 88 - 15
tests/unit/hooks/monitoring/test_zabbix.py

@@ -57,7 +57,7 @@ AUTH_HEADERS_API_KEY = {
 AUTH_HEADERS_USERNAME_PASSWORD = {'Content-Type': 'application/json-rpc'}
 AUTH_HEADERS_USERNAME_PASSWORD = {'Content-Type': 'application/json-rpc'}
 
 
 
 
-def test_ping_monitor_with_non_matching_state_exits_early():
+def test_ping_monitor_with_non_matching_state_bails():
     hook_config = {'api_key': API_KEY}
     hook_config = {'api_key': API_KEY}
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
@@ -71,10 +71,13 @@ def test_ping_monitor_with_non_matching_state_exits_early():
     )
     )
 
 
 
 
-def test_ping_monitor_config_with_api_key_only_exit_early():
+def test_ping_monitor_config_with_api_key_only_bails():
     # This test should exit early since only providing an API KEY is not enough
     # This test should exit early since only providing an API KEY is not enough
     # for the hook to work
     # for the hook to work
     hook_config = {'api_key': API_KEY}
     hook_config = {'api_key': API_KEY}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
@@ -88,10 +91,13 @@ def test_ping_monitor_config_with_api_key_only_exit_early():
     )
     )
 
 
 
 
-def test_ping_monitor_config_with_host_only_exit_early():
+def test_ping_monitor_config_with_host_only_bails():
     # This test should exit early since only providing a HOST is not enough
     # This test should exit early since only providing a HOST is not enough
     # for the hook to work
     # for the hook to work
     hook_config = {'host': HOST}
     hook_config = {'host': HOST}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
@@ -105,10 +111,13 @@ def test_ping_monitor_config_with_host_only_exit_early():
     )
     )
 
 
 
 
-def test_ping_monitor_config_with_key_only_exit_early():
+def test_ping_monitor_config_with_key_only_bails():
     # This test should exit early since only providing a KEY is not enough
     # This test should exit early since only providing a KEY is not enough
     # for the hook to work
     # for the hook to work
     hook_config = {'key': KEY}
     hook_config = {'key': KEY}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
@@ -122,10 +131,13 @@ def test_ping_monitor_config_with_key_only_exit_early():
     )
     )
 
 
 
 
-def test_ping_monitor_config_with_server_only_exit_early():
+def test_ping_monitor_config_with_server_only_bails():
     # This test should exit early since only providing a SERVER is not enough
     # This test should exit early since only providing a SERVER is not enough
     # for the hook to work
     # for the hook to work
     hook_config = {'server': SERVER}
     hook_config = {'server': SERVER}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
@@ -139,9 +151,12 @@ def test_ping_monitor_config_with_server_only_exit_early():
     )
     )
 
 
 
 
-def test_ping_monitor_config_user_password_no_zabbix_data_exit_early():
+def test_ping_monitor_config_user_password_no_zabbix_data_bails():
     # This test should exit early since there are HOST/KEY or ITEMID provided to publish data to
     # This test should exit early since there are HOST/KEY or ITEMID provided to publish data to
     hook_config = {'server': SERVER, 'username': USERNAME, 'password': PASSWORD}
     hook_config = {'server': SERVER, 'username': USERNAME, 'password': PASSWORD}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
@@ -155,9 +170,12 @@ def test_ping_monitor_config_user_password_no_zabbix_data_exit_early():
     )
     )
 
 
 
 
-def test_ping_monitor_config_api_key_no_zabbix_data_exit_early():
+def test_ping_monitor_config_api_key_no_zabbix_data_bails():
     # This test should exit early since there are HOST/KEY or ITEMID provided to publish data to
     # This test should exit early since there are HOST/KEY or ITEMID provided to publish data to
     hook_config = {'server': SERVER, 'api_key': API_KEY}
     hook_config = {'server': SERVER, 'api_key': API_KEY}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
@@ -171,10 +189,13 @@ def test_ping_monitor_config_api_key_no_zabbix_data_exit_early():
     )
     )
 
 
 
 
-def test_ping_monitor_config_itemid_no_auth_data_exit_early():
+def test_ping_monitor_config_itemid_no_auth_data_bails():
     # This test should exit early since there is no authentication provided
     # This test should exit early since there is no authentication provided
     # and Zabbix requires authentication to use it's API
     # and Zabbix requires authentication to use it's API
     hook_config = {'server': SERVER, 'itemid': ITEMID}
     hook_config = {'server': SERVER, 'itemid': ITEMID}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
@@ -188,10 +209,13 @@ def test_ping_monitor_config_itemid_no_auth_data_exit_early():
     )
     )
 
 
 
 
-def test_ping_monitor_config_host_and_key_no_auth_data_exit_early():
+def test_ping_monitor_config_host_and_key_no_auth_data_bails():
     # This test should exit early since there is no authentication provided
     # This test should exit early since there is no authentication provided
     # and Zabbix requires authentication to use it's API
     # and Zabbix requires authentication to use it's API
     hook_config = {'server': SERVER, 'host': HOST, 'key': KEY}
     hook_config = {'server': SERVER, 'host': HOST, 'key': KEY}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
@@ -209,6 +233,9 @@ def test_ping_monitor_config_host_and_key_with_api_key_auth_data_successful():
     # This test should simulate a successful POST to a Zabbix server. This test uses API_KEY
     # This test should simulate a successful POST to a Zabbix server. This test uses API_KEY
     # to authenticate and HOST/KEY to know which item to populate in Zabbix.
     # to authenticate and HOST/KEY to know which item to populate in Zabbix.
     hook_config = {'server': SERVER, 'host': HOST, 'key': KEY, 'api_key': API_KEY}
     hook_config = {'server': SERVER, 'host': HOST, 'key': KEY, 'api_key': API_KEY}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         f'{SERVER}',
         f'{SERVER}',
         headers=AUTH_HEADERS_API_KEY,
         headers=AUTH_HEADERS_API_KEY,
@@ -226,8 +253,11 @@ def test_ping_monitor_config_host_and_key_with_api_key_auth_data_successful():
     )
     )
 
 
 
 
-def test_ping_monitor_config_host_and_missing_key_exits_early():
+def test_ping_monitor_config_host_and_missing_key_bails():
     hook_config = {'server': SERVER, 'host': HOST, 'api_key': API_KEY}
     hook_config = {'server': SERVER, 'host': HOST, 'api_key': API_KEY}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
@@ -241,8 +271,11 @@ def test_ping_monitor_config_host_and_missing_key_exits_early():
     )
     )
 
 
 
 
-def test_ping_monitor_config_key_and_missing_host_exits_early():
+def test_ping_monitor_config_key_and_missing_host_bails():
     hook_config = {'server': SERVER, 'key': KEY, 'api_key': API_KEY}
     hook_config = {'server': SERVER, 'key': KEY, 'api_key': API_KEY}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
@@ -267,6 +300,9 @@ def test_ping_monitor_config_host_and_key_with_username_password_auth_data_succe
         'password': PASSWORD,
         'password': PASSWORD,
     }
     }
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     auth_response = flexmock(ok=True)
     auth_response = flexmock(ok=True)
     auth_response.should_receive('json').and_return(
     auth_response.should_receive('json').and_return(
         {'jsonrpc': '2.0', 'result': '3fe6ed01a69ebd79907a120bcd04e494', 'id': 1}
         {'jsonrpc': '2.0', 'result': '3fe6ed01a69ebd79907a120bcd04e494', 'id': 1}
@@ -296,7 +332,7 @@ def test_ping_monitor_config_host_and_key_with_username_password_auth_data_succe
     )
     )
 
 
 
 
-def test_ping_monitor_config_host_and_key_with_username_password_auth_data_and_auth_post_error_exits_early():
+def test_ping_monitor_config_host_and_key_with_username_password_auth_data_and_auth_post_error_bails():
     hook_config = {
     hook_config = {
         'server': SERVER,
         'server': SERVER,
         'host': HOST,
         'host': HOST,
@@ -305,6 +341,9 @@ def test_ping_monitor_config_host_and_key_with_username_password_auth_data_and_a
         'password': PASSWORD,
         'password': PASSWORD,
     }
     }
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     auth_response = flexmock(ok=False)
     auth_response = flexmock(ok=False)
     auth_response.should_receive('json').and_return(
     auth_response.should_receive('json').and_return(
         {'jsonrpc': '2.0', 'result': '3fe6ed01a69ebd79907a120bcd04e494', 'id': 1}
         {'jsonrpc': '2.0', 'result': '3fe6ed01a69ebd79907a120bcd04e494', 'id': 1}
@@ -335,7 +374,7 @@ def test_ping_monitor_config_host_and_key_with_username_password_auth_data_and_a
     )
     )
 
 
 
 
-def test_ping_monitor_config_host_and_key_with_username_and_missing_password_exits_early():
+def test_ping_monitor_config_host_and_key_with_username_and_missing_password_bails():
     hook_config = {
     hook_config = {
         'server': SERVER,
         'server': SERVER,
         'host': HOST,
         'host': HOST,
@@ -343,6 +382,9 @@ def test_ping_monitor_config_host_and_key_with_username_and_missing_password_exi
         'username': USERNAME,
         'username': USERNAME,
     }
     }
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
@@ -356,7 +398,7 @@ def test_ping_monitor_config_host_and_key_with_username_and_missing_password_exi
     )
     )
 
 
 
 
-def test_ping_monitor_config_host_and_key_with_passing_and_missing_username_exits_early():
+def test_ping_monitor_config_host_and_key_with_password_and_missing_username_bails():
     hook_config = {
     hook_config = {
         'server': SERVER,
         'server': SERVER,
         'host': HOST,
         'host': HOST,
@@ -364,6 +406,9 @@ def test_ping_monitor_config_host_and_key_with_passing_and_missing_username_exit
         'password': PASSWORD,
         'password': PASSWORD,
     }
     }
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.logger).should_receive('warning').once()
     flexmock(module.requests).should_receive('post').never()
     flexmock(module.requests).should_receive('post').never()
 
 
@@ -381,6 +426,9 @@ def test_ping_monitor_config_itemid_with_api_key_auth_data_successful():
     # This test should simulate a successful POST to a Zabbix server. This test uses API_KEY
     # This test should simulate a successful POST to a Zabbix server. This test uses API_KEY
     # to authenticate and HOST/KEY to know which item to populate in Zabbix.
     # to authenticate and HOST/KEY to know which item to populate in Zabbix.
     hook_config = {'server': SERVER, 'itemid': ITEMID, 'api_key': API_KEY}
     hook_config = {'server': SERVER, 'itemid': ITEMID, 'api_key': API_KEY}
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     flexmock(module.requests).should_receive('post').with_args(
     flexmock(module.requests).should_receive('post').with_args(
         f'{SERVER}',
         f'{SERVER}',
         headers=AUTH_HEADERS_API_KEY,
         headers=AUTH_HEADERS_API_KEY,
@@ -403,6 +451,9 @@ def test_ping_monitor_config_itemid_with_username_password_auth_data_successful(
     # to authenticate and HOST/KEY to know which item to populate in Zabbix.
     # to authenticate and HOST/KEY to know which item to populate in Zabbix.
     hook_config = {'server': SERVER, 'itemid': ITEMID, 'username': USERNAME, 'password': PASSWORD}
     hook_config = {'server': SERVER, 'itemid': ITEMID, 'username': USERNAME, 'password': PASSWORD}
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     auth_response = flexmock(ok=True)
     auth_response = flexmock(ok=True)
     auth_response.should_receive('json').and_return(
     auth_response.should_receive('json').and_return(
         {'jsonrpc': '2.0', 'result': '3fe6ed01a69ebd79907a120bcd04e494', 'id': 1}
         {'jsonrpc': '2.0', 'result': '3fe6ed01a69ebd79907a120bcd04e494', 'id': 1}
@@ -432,9 +483,12 @@ def test_ping_monitor_config_itemid_with_username_password_auth_data_successful(
     )
     )
 
 
 
 
-def test_ping_monitor_config_itemid_with_username_password_auth_data_and_push_post_error_exits_early():
+def test_ping_monitor_config_itemid_with_username_password_auth_data_and_push_post_error_bails():
     hook_config = {'server': SERVER, 'itemid': ITEMID, 'username': USERNAME, 'password': PASSWORD}
     hook_config = {'server': SERVER, 'itemid': ITEMID, 'username': USERNAME, 'password': PASSWORD}
 
 
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive(
+        'resolve_credential'
+    ).replace_with(lambda value: value)
     auth_response = flexmock(ok=True)
     auth_response = flexmock(ok=True)
     auth_response.should_receive('json').and_return(
     auth_response.should_receive('json').and_return(
         {'jsonrpc': '2.0', 'result': '3fe6ed01a69ebd79907a120bcd04e494', 'id': 1}
         {'jsonrpc': '2.0', 'result': '3fe6ed01a69ebd79907a120bcd04e494', 'id': 1}
@@ -466,3 +520,22 @@ def test_ping_monitor_config_itemid_with_username_password_auth_data_and_push_po
         monitoring_log_level=1,
         monitoring_log_level=1,
         dry_run=False,
         dry_run=False,
     )
     )
+
+
+def test_ping_monitor_with_credential_error_bails():
+    hook_config = {'server': SERVER, 'itemid': ITEMID, 'username': USERNAME, 'password': PASSWORD}
+
+    flexmock(module.borgmatic.hooks.credential.tag).should_receive('resolve_credential').and_raise(
+        ValueError
+    )
+    flexmock(module.requests).should_receive('post').never()
+    flexmock(module.logger).should_receive('warning').once()
+
+    module.ping_monitor(
+        hook_config,
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitoring.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
+    )

+ 18 - 2
tests/unit/test_logger.py

@@ -382,8 +382,8 @@ def test_delayed_logging_handler_flush_forwards_each_record_to_each_target():
     handler = module.Delayed_logging_handler()
     handler = module.Delayed_logging_handler()
     flexmock(handler).should_receive('acquire')
     flexmock(handler).should_receive('acquire')
     flexmock(handler).should_receive('release')
     flexmock(handler).should_receive('release')
-    handler.targets = [flexmock(), flexmock()]
-    handler.buffer = [flexmock(), flexmock()]
+    handler.targets = [flexmock(level=logging.DEBUG), flexmock(level=logging.DEBUG)]
+    handler.buffer = [flexmock(levelno=logging.DEBUG), flexmock(levelno=logging.DEBUG)]
     handler.targets[0].should_receive('handle').with_args(handler.buffer[0]).once()
     handler.targets[0].should_receive('handle').with_args(handler.buffer[0]).once()
     handler.targets[1].should_receive('handle').with_args(handler.buffer[0]).once()
     handler.targets[1].should_receive('handle').with_args(handler.buffer[0]).once()
     handler.targets[0].should_receive('handle').with_args(handler.buffer[1]).once()
     handler.targets[0].should_receive('handle').with_args(handler.buffer[1]).once()
@@ -394,6 +394,22 @@ def test_delayed_logging_handler_flush_forwards_each_record_to_each_target():
     assert handler.buffer == []
     assert handler.buffer == []
 
 
 
 
+def test_delayed_logging_handler_flush_skips_forwarding_when_log_record_is_too_low_for_target():
+    handler = module.Delayed_logging_handler()
+    flexmock(handler).should_receive('acquire')
+    flexmock(handler).should_receive('release')
+    handler.targets = [flexmock(level=logging.INFO), flexmock(level=logging.DEBUG)]
+    handler.buffer = [flexmock(levelno=logging.DEBUG), flexmock(levelno=logging.INFO)]
+    handler.targets[0].should_receive('handle').with_args(handler.buffer[0]).never()
+    handler.targets[1].should_receive('handle').with_args(handler.buffer[0]).once()
+    handler.targets[0].should_receive('handle').with_args(handler.buffer[1]).once()
+    handler.targets[1].should_receive('handle').with_args(handler.buffer[1]).once()
+
+    handler.flush()
+
+    assert handler.buffer == []
+
+
 def test_flush_delayed_logging_without_handlers_does_not_raise():
 def test_flush_delayed_logging_without_handlers_does_not_raise():
     root_logger = flexmock(handlers=[])
     root_logger = flexmock(handlers=[])
     root_logger.should_receive('removeHandler')
     root_logger.should_receive('removeHandler')