Source code for loom.core.config.secrets
"""AWS Secrets Manager resolver for loom configuration.
Resolves ``${secrets:/path/to/secret}`` placeholders in OmegaConf configs
by fetching values from AWS Secrets Manager at parse time.
Example::
from loom.core.config import load_config
from loom.core.config.secrets import SecretsManagerResolver
cfg = load_config("config/prod.yaml", resolvers=[SecretsManagerResolver("eu-west-1")])
"""
from __future__ import annotations
import logging
from typing import Any
try:
import boto3 as _boto3_module # type: ignore[import-untyped]
except ImportError:
_boto3_module = None
from loom.core.config._resolver_utils import (
_expand_env_vars,
_navigate_json,
_split_resolver_key,
)
from loom.core.config.errors import ConfigError
logger = logging.getLogger(__name__)
def _fetch_secret(client: Any, name: str) -> str:
"""Fetch a string secret value from AWS Secrets Manager.
Args:
client: Boto3 secretsmanager client.
name: Secret name or short ARN. ARN-style names are supported for
plain fetches but not for dot-notation JSON navigation.
Returns:
The secret value as a string.
Raises:
ConfigError: When the secret is binary, or on any API error.
"""
try:
result = client.get_secret_value(SecretId=name)
except Exception as exc:
raise ConfigError(f"Failed to fetch Secrets Manager secret {name!r}: {exc}") from exc
if "SecretString" not in result:
raise ConfigError(
f"Secrets Manager secret {name!r} is binary — only string secrets are supported"
)
return str(result["SecretString"])
[docs]
class SecretsManagerResolver:
"""Resolves AWS Secrets Manager paths for use with :func:`~loom.core.config.load_config`.
Fetches secret values from AWS Secrets Manager. The boto3 client is
created lazily on first use and reused across calls.
Env-var tokens in the form ``%VAR_NAME%`` (uppercase letters, digits,
and underscores only) are expanded from ``os.environ`` before the
request is made.
Args:
region: AWS region name. Passed directly to ``boto3.client``.
Defaults to ``None``, which lets boto3 use its own resolution
chain (env vars, instance metadata, etc.).
Example::
resolver = SecretsManagerResolver("eu-west-1")
value = resolver.resolve("/myapp/%ENV%/db_password")
"""
def __init__(self, region: str | None = None) -> None:
self._region = region
self._client: Any = None
@property
def name(self) -> str:
"""OmegaConf resolver prefix.
Returns:
The string ``"secrets"``.
"""
return "secrets"
def _get_client(self) -> Any:
"""Return the boto3 secretsmanager client, creating it on first call.
Returns:
A boto3 secretsmanager client instance.
Raises:
ConfigError: When boto3 is not installed.
"""
if self._client is None:
if _boto3_module is None:
raise ConfigError(
"boto3 is required for SecretsManagerResolver. "
"Install it with: pip install loom[config-ssm]"
)
self._client = _boto3_module.client("secretsmanager", region_name=self._region)
return self._client
[docs]
def resolve(self, key: str) -> object:
"""Resolve an AWS Secrets Manager path to its stored value.
Expands ``%VAR_NAME%`` tokens in *key* from the environment, then
fetches the secret from AWS Secrets Manager.
Args:
key: Secret path, optionally containing ``%VAR_NAME%``
placeholders that are replaced with environment variable values.
Supports dot-notation for JSON key navigation: ``/path/secret.key``
fetches ``/path/secret`` and returns ``secret["key"]``.
Returns:
Resolved value. A plain string for secrets without dot-notation;
a structured value (string, int, dict, etc.) when dot-notation
navigates into a JSON secret.
Raises:
ConfigError: When *key* is empty, an env-var placeholder is
missing, boto3 is not installed, the secret is binary,
or the API call fails.
"""
if not key:
raise ConfigError("Secrets Manager key must not be empty")
expanded = _expand_env_vars(key)
path, json_keys = _split_resolver_key(expanded)
logger.info("secrets_resolver: fetching %s", path)
raw = _fetch_secret(self._get_client(), path)
if not json_keys:
return raw
return _navigate_json(raw, json_keys, path)
__all__ = ["SecretsManagerResolver"]