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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 208 additions and 16 deletions

View File

@ -0,0 +1,2 @@
minor_changes:
- docker_secret - add support for rolling update, set ``rolling_versions`` to ``true`` to enable (https://github.com/ansible-collections/community.docker/pull/293, https://github.com/ansible-collections/community.docker/issues/21).

View File

@ -51,6 +51,21 @@ options:
- If C(true), an existing secret will be replaced, even if it has not changed. - If C(true), an existing secret will be replaced, even if it has not changed.
type: bool type: bool
default: no 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: name:
description: description:
- The name of the secret. - The name of the secret.
@ -155,6 +170,13 @@ secret_id:
returned: success and I(state) is C(present) returned: success and I(state) is C(present)
type: str type: str
sample: 'hzehrmyjigmcp2gb6nlhmjqcv' 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 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.client.fail('Error while reading {src}: {error}'.format(src=data_src, error=to_native(exc)))
self.labels = parameters.get('labels') self.labels = parameters.get('labels')
self.force = parameters.get('force') 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.data_key = None
self.secrets = []
def __call__(self): def __call__(self):
self.get_secret()
if self.state == 'present': if self.state == 'present':
self.data_key = hashlib.sha224(self.data).hexdigest() self.data_key = hashlib.sha224(self.data).hexdigest()
self.present() self.present()
self.remove_old_versions()
elif self.state == 'absent': elif self.state == 'absent':
self.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): def get_secret(self):
''' Find an existing secret. ''' ''' Find an existing secret. '''
try: try:
@ -220,10 +263,17 @@ class SecretManager(DockerBaseClass):
except APIError as exc: except APIError as exc:
self.client.fail("Error accessing secret %s: %s" % (self.name, to_native(exc))) self.client.fail("Error accessing secret %s: %s" % (self.name, to_native(exc)))
for secret in secrets: if self.rolling_versions:
if secret['Spec']['Name'] == self.name: self.secrets = [
return secret secret
return None 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): def create_secret(self):
''' Create a new secret ''' ''' Create a new secret '''
@ -232,12 +282,17 @@ class SecretManager(DockerBaseClass):
labels = { labels = {
'ansible_key': self.data_key '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: if self.labels:
labels.update(self.labels) labels.update(self.labels)
try: try:
if not self.check_mode: if not self.check_mode:
secret_id = self.client.create_secret(self.name, self.data, labels=labels) 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: except APIError as exc:
self.client.fail("Error creating secret: %s" % to_native(exc)) self.client.fail("Error creating secret: %s" % to_native(exc))
@ -246,11 +301,19 @@ class SecretManager(DockerBaseClass):
return secret_id 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): def present(self):
''' Handles state == 'present', creating or updating the secret ''' ''' Handles state == 'present', creating or updating the secret '''
secret = self.get_secret() if self.secrets:
if secret: secret = self.secrets[-1]
self.results['secret_id'] = secret['ID'] self.results['secret_id'] = secret['ID']
self.results['secret_name'] = secret['Spec']['Name']
data_changed = False data_changed = False
attrs = secret.get('Spec', {}) attrs = secret.get('Spec', {})
if attrs.get('Labels', {}).get('ansible_key'): if attrs.get('Labels', {}).get('ansible_key'):
@ -260,25 +323,26 @@ class SecretManager(DockerBaseClass):
if not self.force: 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'") 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') 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 data_changed or labels_changed or self.force:
# if something changed or force, delete and re-create the secret # if something changed or force, delete and re-create the secret
if not self.rolling_versions:
self.absent() self.absent()
secret_id = self.create_secret() secret_id = self.create_secret()
self.results['changed'] = True self.results['changed'] = True
self.results['secret_id'] = secret_id self.results['secret_id'] = secret_id
self.results['secret_name'] = self.name
else: else:
self.results['changed'] = True self.results['changed'] = True
self.results['secret_id'] = self.create_secret() self.results['secret_id'] = self.create_secret()
self.results['secret_name'] = self.name
def absent(self): def absent(self):
''' Handles state == 'absent', removing the secret ''' ''' Handles state == 'absent', removing the secret '''
secret = self.get_secret() if self.secrets:
if secret: for secret in self.secrets:
try: self.remove_secret(secret)
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)))
self.results['changed'] = True self.results['changed'] = True
@ -290,7 +354,9 @@ def main():
data_is_b64=dict(type='bool', default=False), data_is_b64=dict(type='bool', default=False),
data_src=dict(type='path'), data_src=dict(type='path'),
labels=dict(type='dict'), 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 = [ required_if = [
@ -313,7 +379,8 @@ def main():
try: try:
results = dict( results = dict(
changed=False, changed=False,
secret_id='' secret_id='',
secret_name=''
) )
SecretManager(client, results)() SecretManager(client, results)()

View File

@ -135,6 +135,82 @@
that: that:
- output.failed - output.failed
# Rolling update
- name: Create rolling secret
docker_secret:
name: rolling_password
data: opensesame!
rolling_versions: true
state: present
register: original_output
- name: Create variable secret_id
set_fact:
secret_id: "{{ original_output.secret_id }}"
- name: Inspect secret
command: "docker secret inspect {{ secret_id }}"
register: inspect
ignore_errors: yes
- debug: var=inspect
- name: assert secret creation succeeded
assert:
that:
- "'rolling_password' in inspect.stdout"
- "'ansible_key' in inspect.stdout"
- "'ansible_version' in inspect.stdout"
- original_output.secret_name == 'rolling_password_v1'
when: inspect is not failed
- assert:
that:
- "'is too new. Maximum supported API version is' in inspect.stderr"
when: inspect is failed
- name: Create secret again
docker_secret:
name: rolling_password
data: newpassword!
rolling_versions: true
state: present
register: new_output
- name: assert that new version is created
assert:
that:
- new_output.changed
- new_output.secret_id != original_output.secret_id
- new_output.secret_name != original_output.secret_name
- new_output.secret_name == 'rolling_password_v2'
- name: Remove rolling secrets
docker_secret:
name: rolling_password
rolling_versions: true
state: absent
- name: Check that secret is removed
command: "docker secret inspect {{ original_output.secret_id }}"
register: output
ignore_errors: yes
- name: assert secret was removed
assert:
that:
- output.failed
- name: Check that secret is removed
command: "docker secret inspect {{ new_output.secret_id }}"
register: output
ignore_errors: yes
- name: assert secret was removed
assert:
that:
- output.failed
always: always:
- name: Remove Swarm cluster - name: Remove Swarm cluster
docker_swarm: docker_swarm:

View File

@ -5,6 +5,7 @@
service_name: "{{ name_prefix ~ '-secrets' }}" service_name: "{{ name_prefix ~ '-secrets' }}"
secret_name_1: "{{ name_prefix ~ '-secret-1' }}" secret_name_1: "{{ name_prefix ~ '-secret-1' }}"
secret_name_2: "{{ name_prefix ~ '-secret-2' }}" secret_name_2: "{{ name_prefix ~ '-secret-2' }}"
secret_name_3: "{{ name_prefix ~ '-secret-3' }}"
- name: Registering container name - name: Registering container name
set_fact: set_fact:
@ -24,6 +25,14 @@
register: "secret_result_2" register: "secret_result_2"
when: docker_api_version is version('1.25', '>=') and docker_py_version is version('2.1.0', '>=') when: docker_api_version is version('1.25', '>=') and docker_py_version is version('2.1.0', '>=')
- docker_secret:
name: "{{ secret_name_3 }}"
data: "secret3"
state: "present"
rolling_versions: true
register: "secret_result_3"
when: docker_api_version is version('1.25', '>=') and docker_py_version is version('2.1.0', '>=')
#################################################################### ####################################################################
## secrets ######################################################### ## secrets #########################################################
#################################################################### ####################################################################
@ -131,6 +140,40 @@
register: secrets_8 register: secrets_8
ignore_errors: yes ignore_errors: yes
- name: rolling secrets
docker_swarm_service:
name: "{{ service_name }}"
image: "{{ docker_test_image_alpine }}"
resolve_image: no
command: '/bin/sh -v -c "sleep 10m"'
secrets:
- secret_name: "{{ secret_name_3 }}_v1"
filename: "/run/secrets/{{ secret_name_3 }}.txt"
register: secrets_9
ignore_errors: yes
- name: update rolling secret
docker_secret:
name: "{{ secret_name_3 }}"
data: "newsecret3"
state: "present"
rolling_versions: true
register: secrets_10
when: docker_api_version is version('1.25', '>=') and docker_py_version is version('2.1.0', '>=')
ignore_errors: yes
- name: rolling secrets service update
docker_swarm_service:
name: "{{ service_name }}"
image: "{{ docker_test_image_alpine }}"
resolve_image: no
command: '/bin/sh -v -c "sleep 10m"'
secrets:
- secret_name: "{{ secret_name_3 }}_v2"
filename: "/run/secrets/{{ secret_name_3 }}.txt"
register: secrets_11
ignore_errors: yes
- name: cleanup - name: cleanup
docker_swarm_service: docker_swarm_service:
name: "{{ service_name }}" name: "{{ service_name }}"
@ -147,6 +190,9 @@
- secrets_6 is not changed - secrets_6 is not changed
- secrets_7 is changed - secrets_7 is changed
- secrets_8 is not changed - secrets_8 is not changed
- secrets_9 is changed
- secrets_10 is not failed
- secrets_11 is changed
when: docker_api_version is version('1.25', '>=') and docker_py_version is version('2.4.0', '>=') when: docker_api_version is version('1.25', '>=') and docker_py_version is version('2.4.0', '>=')
- assert: - assert:
that: that:
@ -405,6 +451,7 @@
loop: loop:
- "{{ secret_name_1 }}" - "{{ secret_name_1 }}"
- "{{ secret_name_2 }}" - "{{ secret_name_2 }}"
- "{{ secret_name_3 }}"
loop_control: loop_control:
loop_var: secret_name loop_var: secret_name
ignore_errors: yes ignore_errors: yes