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 """
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.
@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.