Add support for rotating docker secrets (#293)

* Add parameters for rolling updates to `docker_secret`

* Extract `remove_secrets` to its own function in `docker_secret`

* Store existing secrets in a list instead of a single secret

With this change `docker_secret` now supports the case where we store
multiple versions of a secret with the `_v123` postfix.

`absent` state implicitly handles removing these this way.

* When using `rolling_versions` don't automatically remove current secret

To make rolling updates actually work instead of failing on trying to
remove a secret that is attached to a service, use the
`versions_to_keep` parameter to remove old versions of the secret after
creating the new one. This way the secret with the new data is created
with a different name and can be attached to the service by its ID
without having to delete the previous one first which would fail if it
is already attached to a service.

* Add version numbers to newly created secrets

Attach the incremental version number to the secret name as a `_v123`
postfix where `123` is replaced with an incremental counter starting
from 1.
A label with the numeric version is also attached to the secret to ease
calculating the new version number upon change with the name
`ansible_version`.

* Return `secret_name` for docker secrets as well

* Add integration test for rolling secrets

* Update `docker_secret` documentation as per review comments

* Correctly return `docker_secret` version number as int

* Use template string for naming `docker_secrets` instead of concatenation

* Return the correct secret name on deletion failure

* Simplify `docker_secret` creation

* Add missing comma for `docker_secret` schema

* Only remove old docker secrets if `rolling_versions` is set

* Add check in `docker_secret` version parsing to handle NaNs

* Add newly created `docker_secret` to internal secret list to avoid additional deletions

* Add changelog fragment for `docker_secret` `rolling_versions` feature

* Update changelogs/fragments/270-rolling-secrets.yml

Co-authored-by: Felix Fontein <felix@fontein.de>

Co-authored-by: Felix Fontein <felix@fontein.de>
This commit is contained in:
András Maróy
2022-02-12 07:29:49 +00:00
committed by GitHub
parent 9cd46a7d41
commit b481fa4801
4 changed files with 208 additions and 16 deletions
+83 -16
View File
@@ -51,6 +51,21 @@ options:
- If C(true), an existing secret will be replaced, even if it has not changed.
type: bool
default: no
rolling_versions:
description:
- If set to C(true), secrets are created with an increasing version number appended to their name.
- Adds a label containing the version number to the managed secrets with the name C(ansible_version).
type: bool
default: false
version_added: 2.2.0
versions_to_keep:
description:
- When using I(rolling_versions), the number of old versions of the secret to keep.
- Extraneous old secrets are deleted after the new one is created.
- Set to C(-1) to keep everything or to C(0) or C(1) to keep only the current one.
type: int
default: 5
version_added: 2.2.0
name:
description:
- The name of the secret.
@@ -155,6 +170,13 @@ secret_id:
returned: success and I(state) is C(present)
type: str
sample: 'hzehrmyjigmcp2gb6nlhmjqcv'
secret_name:
description:
- The name of the created secret object.
returned: success and I(state) is C(present)
type: str
sample: 'awesome_secret'
version_added: 2.2.0
'''
import base64
@@ -204,15 +226,36 @@ class SecretManager(DockerBaseClass):
self.client.fail('Error while reading {src}: {error}'.format(src=data_src, error=to_native(exc)))
self.labels = parameters.get('labels')
self.force = parameters.get('force')
self.rolling_versions = parameters.get('rolling_versions')
self.versions_to_keep = parameters.get('versions_to_keep')
if self.rolling_versions:
self.version = 0
self.data_key = None
self.secrets = []
def __call__(self):
self.get_secret()
if self.state == 'present':
self.data_key = hashlib.sha224(self.data).hexdigest()
self.present()
self.remove_old_versions()
elif self.state == 'absent':
self.absent()
def get_version(self, secret):
try:
return int(secret.get('Spec', {}).get('Labels', {}).get('ansible_version', 0))
except ValueError:
return 0
def remove_old_versions(self):
if not self.rolling_versions or self.versions_to_keep < 0:
return
if not self.check_mode:
while len(self.secrets) > max(self.versions_to_keep, 1):
self.remove_secret(self.secrets.pop(0))
def get_secret(self):
''' Find an existing secret. '''
try:
@@ -220,10 +263,17 @@ class SecretManager(DockerBaseClass):
except APIError as exc:
self.client.fail("Error accessing secret %s: %s" % (self.name, to_native(exc)))
for secret in secrets:
if secret['Spec']['Name'] == self.name:
return secret
return None
if self.rolling_versions:
self.secrets = [
secret
for secret in secrets
if secret['Spec']['Name'].startswith('{name}_v'.format(name=self.name))
]
self.secrets.sort(key=self.get_version)
else:
self.secrets = [
secret for secret in secrets if secret['Spec']['Name'] == self.name
]
def create_secret(self):
''' Create a new secret '''
@@ -232,12 +282,17 @@ class SecretManager(DockerBaseClass):
labels = {
'ansible_key': self.data_key
}
if self.rolling_versions:
self.version += 1
labels['ansible_version'] = str(self.version)
self.name = '{name}_v{version}'.format(name=self.name, version=self.version)
if self.labels:
labels.update(self.labels)
try:
if not self.check_mode:
secret_id = self.client.create_secret(self.name, self.data, labels=labels)
self.secrets += self.client.secrets(filters={'id': secret_id})
except APIError as exc:
self.client.fail("Error creating secret: %s" % to_native(exc))
@@ -246,11 +301,19 @@ class SecretManager(DockerBaseClass):
return secret_id
def remove_secret(self, secret):
try:
if not self.check_mode:
self.client.remove_secret(secret['ID'])
except APIError as exc:
self.client.fail("Error removing secret %s: %s" % (secret['Spec']['Name'], to_native(exc)))
def present(self):
''' Handles state == 'present', creating or updating the secret '''
secret = self.get_secret()
if secret:
if self.secrets:
secret = self.secrets[-1]
self.results['secret_id'] = secret['ID']
self.results['secret_name'] = secret['Spec']['Name']
data_changed = False
attrs = secret.get('Spec', {})
if attrs.get('Labels', {}).get('ansible_key'):
@@ -260,25 +323,26 @@ class SecretManager(DockerBaseClass):
if not self.force:
self.client.module.warn("'ansible_key' label not found. Secret will not be changed unless the force parameter is set to 'yes'")
labels_changed = not compare_generic(self.labels, attrs.get('Labels'), 'allow_more_present', 'dict')
if self.rolling_versions:
self.version = self.get_version(secret)
if data_changed or labels_changed or self.force:
# if something changed or force, delete and re-create the secret
self.absent()
if not self.rolling_versions:
self.absent()
secret_id = self.create_secret()
self.results['changed'] = True
self.results['secret_id'] = secret_id
self.results['secret_name'] = self.name
else:
self.results['changed'] = True
self.results['secret_id'] = self.create_secret()
self.results['secret_name'] = self.name
def absent(self):
''' Handles state == 'absent', removing the secret '''
secret = self.get_secret()
if secret:
try:
if not self.check_mode:
self.client.remove_secret(secret['ID'])
except APIError as exc:
self.client.fail("Error removing secret %s: %s" % (self.name, to_native(exc)))
if self.secrets:
for secret in self.secrets:
self.remove_secret(secret)
self.results['changed'] = True
@@ -290,7 +354,9 @@ def main():
data_is_b64=dict(type='bool', default=False),
data_src=dict(type='path'),
labels=dict(type='dict'),
force=dict(type='bool', default=False)
force=dict(type='bool', default=False),
rolling_versions=dict(type='bool', default=False),
versions_to_keep=dict(type='int', default=5),
)
required_if = [
@@ -313,7 +379,8 @@ def main():
try:
results = dict(
changed=False,
secret_id=''
secret_id='',
secret_name=''
)
SecretManager(client, results)()