瀏覽代碼

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

Dan Helfman 3 月之前
父節點
當前提交
9a9a8fd1c6
共有 6 個文件被更改,包括 126 次插入12 次删除
  1. 3 0
      NEWS
  2. 21 8
      borgmatic/commands/borgmatic.py
  3. 33 0
      borgmatic/config/credential.py
  4. 19 1
      borgmatic/config/load.py
  5. 11 3
      borgmatic/config/validate.py
  6. 39 0
      borgmatic/hooks/credential/systemd.py

+ 3 - 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.

+ 21 - 8
borgmatic/commands/borgmatic.py

@@ -533,15 +533,20 @@ def run_actions(
     )
     )
 
 
 
 
-def load_configurations(config_filenames, overrides=None, resolve_env=True):
+def load_configurations(
+    config_filenames, overrides=None, resolve_env=True, resolve_credentials=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", whether to resolve environment variables, and whether
+    to resolve credentials, 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 +568,11 @@ 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,
+                resolve_credentials,
             )
             )
             config_paths.update(paths)
             config_paths.update(paths)
             logs.extend(parse_logs)
             logs.extend(parse_logs)
@@ -907,9 +916,13 @@ 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,
+        resolve_credentials=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

+ 33 - 0
borgmatic/config/credential.py

@@ -0,0 +1,33 @@
+import borgmatic.hooks.dispatch
+
+
+def resolve_credentials(config, item=None):
+    '''
+    Resolves values like "!credential hookname credentialname" from the given configuration by
+    calling relevant hooks to get the actual credential values.
+
+    Raise ValueError if the config could not be parsed or the credential could not be loaded.
+    '''
+    if not item:
+        item = config
+
+    if isinstance(item, str):
+        if item.startswith('!credential '):
+            try:
+                (tag_name, hook_name, credential_name) = item.split(' ', 2)
+            except ValueError:
+                raise ValueError(f'Cannot load credential with invalid syntax "{item}"')
+
+            return borgmatic.hooks.dispatch.call_hook(
+                'load_credential', config, hook_name, credential_name
+            )
+
+    if isinstance(item, list):
+        for index, subitem in enumerate(item):
+            item[index] = resolve_credentials(config, subitem)
+
+    if isinstance(item, dict):
+        for key, value in item.items():
+            item[key] = resolve_credentials(config, value)
+
+    return item

+ 19 - 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 not supported; use a single filename or a list of filenames instead'
     )
     )
 
 
 
 
@@ -104,6 +104,23 @@ 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 not supported; 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 +139,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.

+ 11 - 3
borgmatic/config/validate.py

@@ -5,6 +5,7 @@ import jsonschema
 import ruamel.yaml
 import ruamel.yaml
 
 
 import borgmatic.config
 import borgmatic.config
+import borgmatic.config.credential
 from borgmatic.config import constants, environment, load, normalize, override
 from borgmatic.config import constants, environment, load, normalize, override
 
 
 
 
@@ -84,12 +85,15 @@ def apply_logical_validation(config_filename, parsed_configuration):
             )
             )
 
 
 
 
-def parse_configuration(config_filename, schema_filename, overrides=None, resolve_env=True):
+def parse_configuration(
+    config_filename, schema_filename, overrides=None, resolve_env=True, resolve_credentials=True
+):
     '''
     '''
     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", whether to resolve environment variables, and whether to resolve
+    credentials, 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'],
@@ -118,12 +122,16 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
     if resolve_env:
     if resolve_env:
         environment.resolve_env_variables(config)
         environment.resolve_env_variables(config)
 
 
+    if resolve_credentials:
+        borgmatic.config.credential.resolve_credentials(config)
+
     logs = normalize.normalize(config_filename, config)
     logs = normalize.normalize(config_filename, config)
 
 
     try:
     try:
         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:

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

@@ -0,0 +1,39 @@
+import functools
+import logging
+import os
+import re
+
+import borgmatic.config.paths
+import borgmatic.execute
+
+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 corresonding 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.error(error)
+
+        raise ValueError(f'Cannot load credential "{credential_name}" from file: {error.filename}')