config_ninja.contrib.secretsmanager

Integrate with the AWS SecretsManager service.

Example

The following config-ninja settings file configures the SecretsManagerBackend to install ~/.docker/config.json from the latest version of the secret:

---
# the following top-level key is required
CONFIG_NINJA_OBJECTS:
  # each second-level key identifies a config-ninja object
  example-0:
    # set the location that the object is written to
    dest:
      format: json
      path: /tmp/secret.json

    # specify where the object is stored / retrieved from
    source:
      backend: secretsmanager
      format: json

      new:
        kwargs:
          secret_id: example-secret
  1"""Integrate with the AWS SecretsManager service.
  2
  3## Example
  4
  5The following `config-ninja`_ settings file configures the `SecretsManagerBackend` to install
  6`~/.docker/config.json` from the latest version of the secret:
  7
  8```yaml
  9.. include:: ../../../examples/secretsmanager-backend.yaml
 10```
 11
 12.. _config-ninja: https://config-ninja.readthedocs.io/home.html
 13"""
 14
 15from __future__ import annotations
 16
 17import asyncio
 18import logging
 19import typing
 20
 21import boto3
 22
 23from config_ninja.backend import Backend
 24from config_ninja.contrib.appconfig import MINIMUM_POLL_INTERVAL_SECONDS
 25
 26if typing.TYPE_CHECKING:  # pragma: no cover
 27    from mypy_boto3_secretsmanager import SecretsManagerClient
 28
 29
 30__all__ = ['SecretsManagerBackend']
 31
 32logger = logging.getLogger(__name__)
 33
 34
 35class SecretsManagerBackend(Backend):
 36    """Retrieve config data from the AWS SecretsManager service.
 37
 38    ## Usage
 39
 40    >>> backend = SecretsManagerBackend(secretsmanager_client, 'secret-id')
 41    >>> print(backend.get())
 42    {"username": "admin", "password": 1234}
 43    """
 44
 45    client: SecretsManagerClient
 46    """The `boto3` client used to communicate with the AWS Secrets Manager service."""
 47
 48    secret_id: str
 49    """The ID of the secret to retrieve"""
 50
 51    version_id: str | None = None
 52
 53    def __init__(self, client: SecretsManagerClient, secret_id: str) -> None:
 54        """Initialize the backend."""
 55        self.client = client
 56        self.secret_id = secret_id
 57        logger.debug('Initialize: %s', repr(self))
 58
 59    def __str__(self) -> str:
 60        """Return the secret ID.
 61
 62        >>> print(str(SecretsManagerBackend(secretsmanager_client, 'secret-id')))
 63        secret-id
 64        """
 65        return self.secret_id if not self.version_id else f'{self.secret_id} (version: {self.version_id})'
 66
 67    @classmethod
 68    def new(cls, secret_id: str, session: boto3.Session | None = None) -> SecretsManagerBackend:  # pylint: disable=arguments-differ
 69        """Instantiate a new `boto3` client and `SecretsManagerBackend` object.
 70
 71        >>> backend = SecretsManagerBackend.new('secret-id')
 72        >>> print(backend)
 73        secret-id
 74        """
 75        logger.info('Create new instance: %s(secret_id="%s")', cls.__name__, secret_id)
 76        session = session or boto3.Session()
 77        client: SecretsManagerClient = session.client('secretsmanager')  # pyright: ignore[reportUnknownMemberType]
 78        return cls(client, secret_id)
 79
 80    def get(self) -> str:
 81        """Retrieve the secret data."""
 82        response = self.client.get_secret_value(SecretId=self.secret_id)
 83        self.version_id = response.get('VersionId')
 84        return response['SecretString']
 85
 86    def _retrieve_current_version(self) -> str:
 87        """Retrieve the version ID of the current value of the secret.
 88
 89        A value error is raised if no current version was found:
 90
 91        >>> backend = SecretsManagerBackend(secretsmanager_client_no_current, 'secret-id')
 92        >>> with pytest.raises(ValueError):
 93        ...     backend._retrieve_current_version()
 94        """
 95        response = self.client.list_secret_version_ids(SecretId=self.secret_id)
 96        for version in response['Versions']:
 97            if 'AWSCURRENT' in version.get('VersionStages', []) and (version_id := version.get('VersionId')):
 98                return version_id
 99
100        raise ValueError(f"No current version found for secret '{self}'")
101
102    async def poll(self, interval: int = MINIMUM_POLL_INTERVAL_SECONDS) -> typing.AsyncIterator[str]:
103        """Poll for changes to the secret."""
104        while True:
105            logger.debug('Poll for configuration changes')
106            try:
107                version_id = self._retrieve_current_version()
108            except ValueError as exc:
109                logger.warning('%s', exc)
110            else:
111                if version_id and version_id != self.version_id:
112                    yield self.get()
113
114            await asyncio.sleep(interval)
115
116    def _async_doctests(self) -> None:
117        """Define `async` `doctest` tests in this method to improve documentation.
118
119        Verify that an empty response to the `boto3` client is handled and the polling continues:
120        >>> backend = SecretsManagerBackend(secretsmanager_client, 'secret-id')
121        >>> content = asyncio.run(anext(backend.poll(interval=0.01)))
122        >>> print(content)
123        {"username": "admin", "password": 1234}
124
125        >>> backend = SecretsManagerBackend(secretsmanager_client_no_current_initially, 'secret-id')
126        >>> _ = backend.get(); backend.version_id   # fetch the initial value from the mock fixture
127        'v6'
128
129        >>> content = asyncio.run(anext(backend.poll(interval=0.01)))
130        >>> print(content)
131        {"username": "admin", "password": 1234}
132        """
class SecretsManagerBackend(config_ninja.backend.Backend):
 36class SecretsManagerBackend(Backend):
 37    """Retrieve config data from the AWS SecretsManager service.
 38
 39    ## Usage
 40
 41    >>> backend = SecretsManagerBackend(secretsmanager_client, 'secret-id')
 42    >>> print(backend.get())
 43    {"username": "admin", "password": 1234}
 44    """
 45
 46    client: SecretsManagerClient
 47    """The `boto3` client used to communicate with the AWS Secrets Manager service."""
 48
 49    secret_id: str
 50    """The ID of the secret to retrieve"""
 51
 52    version_id: str | None = None
 53
 54    def __init__(self, client: SecretsManagerClient, secret_id: str) -> None:
 55        """Initialize the backend."""
 56        self.client = client
 57        self.secret_id = secret_id
 58        logger.debug('Initialize: %s', repr(self))
 59
 60    def __str__(self) -> str:
 61        """Return the secret ID.
 62
 63        >>> print(str(SecretsManagerBackend(secretsmanager_client, 'secret-id')))
 64        secret-id
 65        """
 66        return self.secret_id if not self.version_id else f'{self.secret_id} (version: {self.version_id})'
 67
 68    @classmethod
 69    def new(cls, secret_id: str, session: boto3.Session | None = None) -> SecretsManagerBackend:  # pylint: disable=arguments-differ
 70        """Instantiate a new `boto3` client and `SecretsManagerBackend` object.
 71
 72        >>> backend = SecretsManagerBackend.new('secret-id')
 73        >>> print(backend)
 74        secret-id
 75        """
 76        logger.info('Create new instance: %s(secret_id="%s")', cls.__name__, secret_id)
 77        session = session or boto3.Session()
 78        client: SecretsManagerClient = session.client('secretsmanager')  # pyright: ignore[reportUnknownMemberType]
 79        return cls(client, secret_id)
 80
 81    def get(self) -> str:
 82        """Retrieve the secret data."""
 83        response = self.client.get_secret_value(SecretId=self.secret_id)
 84        self.version_id = response.get('VersionId')
 85        return response['SecretString']
 86
 87    def _retrieve_current_version(self) -> str:
 88        """Retrieve the version ID of the current value of the secret.
 89
 90        A value error is raised if no current version was found:
 91
 92        >>> backend = SecretsManagerBackend(secretsmanager_client_no_current, 'secret-id')
 93        >>> with pytest.raises(ValueError):
 94        ...     backend._retrieve_current_version()
 95        """
 96        response = self.client.list_secret_version_ids(SecretId=self.secret_id)
 97        for version in response['Versions']:
 98            if 'AWSCURRENT' in version.get('VersionStages', []) and (version_id := version.get('VersionId')):
 99                return version_id
100
101        raise ValueError(f"No current version found for secret '{self}'")
102
103    async def poll(self, interval: int = MINIMUM_POLL_INTERVAL_SECONDS) -> typing.AsyncIterator[str]:
104        """Poll for changes to the secret."""
105        while True:
106            logger.debug('Poll for configuration changes')
107            try:
108                version_id = self._retrieve_current_version()
109            except ValueError as exc:
110                logger.warning('%s', exc)
111            else:
112                if version_id and version_id != self.version_id:
113                    yield self.get()
114
115            await asyncio.sleep(interval)
116
117    def _async_doctests(self) -> None:
118        """Define `async` `doctest` tests in this method to improve documentation.
119
120        Verify that an empty response to the `boto3` client is handled and the polling continues:
121        >>> backend = SecretsManagerBackend(secretsmanager_client, 'secret-id')
122        >>> content = asyncio.run(anext(backend.poll(interval=0.01)))
123        >>> print(content)
124        {"username": "admin", "password": 1234}
125
126        >>> backend = SecretsManagerBackend(secretsmanager_client_no_current_initially, 'secret-id')
127        >>> _ = backend.get(); backend.version_id   # fetch the initial value from the mock fixture
128        'v6'
129
130        >>> content = asyncio.run(anext(backend.poll(interval=0.01)))
131        >>> print(content)
132        {"username": "admin", "password": 1234}
133        """

Retrieve config data from the AWS SecretsManager service.

Usage

>>> backend = SecretsManagerBackend(secretsmanager_client, 'secret-id')
>>> print(backend.get())
{"username": "admin", "password": 1234}
SecretsManagerBackend( client: mypy_boto3_secretsmanager.client.SecretsManagerClient, secret_id: str)
54    def __init__(self, client: SecretsManagerClient, secret_id: str) -> None:
55        """Initialize the backend."""
56        self.client = client
57        self.secret_id = secret_id
58        logger.debug('Initialize: %s', repr(self))

Initialize the backend.

client: mypy_boto3_secretsmanager.client.SecretsManagerClient

The boto3 client used to communicate with the AWS Secrets Manager service.

secret_id: str

The ID of the secret to retrieve

version_id: str | None = None
@classmethod
def new( cls, secret_id: str, session: boto3.session.Session | None = None) -> SecretsManagerBackend:
68    @classmethod
69    def new(cls, secret_id: str, session: boto3.Session | None = None) -> SecretsManagerBackend:  # pylint: disable=arguments-differ
70        """Instantiate a new `boto3` client and `SecretsManagerBackend` object.
71
72        >>> backend = SecretsManagerBackend.new('secret-id')
73        >>> print(backend)
74        secret-id
75        """
76        logger.info('Create new instance: %s(secret_id="%s")', cls.__name__, secret_id)
77        session = session or boto3.Session()
78        client: SecretsManagerClient = session.client('secretsmanager')  # pyright: ignore[reportUnknownMemberType]
79        return cls(client, secret_id)

Instantiate a new boto3 client and SecretsManagerBackend object.

>>> backend = SecretsManagerBackend.new('secret-id')
>>> print(backend)
secret-id
def get(self) -> str:
81    def get(self) -> str:
82        """Retrieve the secret data."""
83        response = self.client.get_secret_value(SecretId=self.secret_id)
84        self.version_id = response.get('VersionId')
85        return response['SecretString']

Retrieve the secret data.

async def poll(self, interval: int = 60) -> AsyncIterator[str]:
103    async def poll(self, interval: int = MINIMUM_POLL_INTERVAL_SECONDS) -> typing.AsyncIterator[str]:
104        """Poll for changes to the secret."""
105        while True:
106            logger.debug('Poll for configuration changes')
107            try:
108                version_id = self._retrieve_current_version()
109            except ValueError as exc:
110                logger.warning('%s', exc)
111            else:
112                if version_id and version_id != self.version_id:
113                    yield self.get()
114
115            await asyncio.sleep(interval)

Poll for changes to the secret.