From 29ab90ebd567bbc8fab1d907304b4b2d79bbe5c8 Mon Sep 17 00:00:00 2001 From: Mark Chappell Date: Tue, 5 Jul 2022 14:38:13 +0200 Subject: [PATCH] Add support for check mode to SSM Parameter store (#1309) Add support for check mode to SSM Parameter store SUMMARY Adds support for check_mode Adds basic waiters for create/delete Fixes bug where module wasn't consistently idempotent ISSUE TYPE Bugfix Pull Request Feature Pull Request COMPONENT NAME plugins/modules/aws_ssm_parameter_store.py ADDITIONAL INFORMATION Module was using a deprecated parameter when calling describe_parameters (Filters). This deprecated parameter appears to have some form of caching applied to it and would sometimes return old values. By switching to the ParameterFilters replacement things seem to be more consistent. Reviewed-by: Alina Buzachis --- .../1308-ssm_parameter-check_mode.yml | 4 + plugins/modules/aws_ssm_parameter_store.py | 130 ++++++++++++++++-- .../aws_ssm_parameter_store/tasks/main.yml | 130 +++++++++++++++--- 3 files changed, 240 insertions(+), 24 deletions(-) create mode 100644 changelogs/fragments/1308-ssm_parameter-check_mode.yml diff --git a/changelogs/fragments/1308-ssm_parameter-check_mode.yml b/changelogs/fragments/1308-ssm_parameter-check_mode.yml new file mode 100644 index 00000000000..c728cf98ad6 --- /dev/null +++ b/changelogs/fragments/1308-ssm_parameter-check_mode.yml @@ -0,0 +1,4 @@ +minor_changes: +- aws_ssm_parameter_store - added support for check_mode (https://github.com/ansible-collections/community.aws/pull/1309). +bugfixes: +- aws_ssm_parameter_store - fixed bug where module wasn't consistently idempotent (https://github.com/ansible-collections/community.aws/pull/1309). diff --git a/plugins/modules/aws_ssm_parameter_store.py b/plugins/modules/aws_ssm_parameter_store.py index b46214cd263..b3c13015c26 100644 --- a/plugins/modules/aws_ssm_parameter_store.py +++ b/plugins/modules/aws_ssm_parameter_store.py @@ -207,6 +207,8 @@ returned: success ''' +import time + try: import botocore except ImportError: @@ -216,14 +218,94 @@ from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule from ansible_collections.amazon.aws.plugins.module_utils.core import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry +from ansible_collections.community.aws.plugins.module_utils.base import BaseWaiterFactory + + +class ParameterWaiterFactory(BaseWaiterFactory): + def __init__(self, module): + client = module.client('ssm') + super(ParameterWaiterFactory, self).__init__(module, client) + + @property + def _waiter_model_data(self): + data = super(ParameterWaiterFactory, self)._waiter_model_data + ssm_data = dict( + parameter_exists=dict( + operation='DescribeParameters', + delay=1, maxAttempts=20, + acceptors=[ + dict(state='retry', matcher='error', expected='ParameterNotFound'), + dict(state='retry', matcher='path', expected=True, argument='length(Parameters[].Name) == `0`'), + dict(state='success', matcher='path', expected=True, argument='length(Parameters[].Name) > `0`'), + ] + ), + parameter_deleted=dict( + operation='DescribeParameters', + delay=1, maxAttempts=20, + acceptors=[ + dict(state='retry', matcher='path', expected=True, argument='length(Parameters[].Name) > `0`'), + dict(state='success', matcher='path', expected=True, argument='length(Parameters[]) == `0`'), + dict(state='success', matcher='error', expected='ParameterNotFound'), + ] + ), + ) + data.update(ssm_data) + return data + + +def _wait_exists(client, module, name): + if module.check_mode: + return + wf = ParameterWaiterFactory(module) + waiter = wf.get_waiter('parameter_exists') + try: + waiter.wait( + ParameterFilters=[{'Key': 'Name', "Values": [name]}], + ) + except botocore.exceptions.WaiterError: + module.warn("Timeout waiting for parameter to exist") + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to describe parameter while waiting for creation") + + +def _wait_updated(client, module, name, version): + # Unfortunately we can't filter on the Version, as such we need something custom. + if module.check_mode: + return + for x in range(1, 10): + try: + parameter = describe_parameter(client, module, ParameterFilters=[{"Key": "Name", "Values": [name]}]) + if parameter.get('Version', 0) > version: + return + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to describe parameter while waiting for update") + time.sleep(1) + + +def _wait_deleted(client, module, name): + if module.check_mode: + return + wf = ParameterWaiterFactory(module) + waiter = wf.get_waiter('parameter_deleted') + try: + waiter.wait( + ParameterFilters=[{'Key': 'Name', "Values": [name]}], + ) + except botocore.exceptions.WaiterError: + module.warn("Timeout waiting for parameter to exist") + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Failed to describe parameter while waiting for deletion") def update_parameter(client, module, **args): changed = False response = {} + if module.check_mode: + return True, response try: - response = client.put_parameter(**args) + response = client.put_parameter(aws_retry=True, **args) changed = True except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: module.fail_json_aws(e, msg="setting parameter") @@ -231,6 +313,7 @@ def update_parameter(client, module, **args): return changed, response +@AWSRetry.jittered_backoff() def describe_parameter(client, module, **args): paginator = client.get_paginator('describe_parameters') existing_parameter = paginator.paginate(**args).build_full_result() @@ -267,11 +350,14 @@ def create_update_parameter(client, module): args.update(KeyId=module.params.get('key_id')) try: - existing_parameter = client.get_parameter(Name=args['Name'], WithDecryption=True) - except Exception: + existing_parameter = client.get_parameter(aws_retry=True, Name=args['Name'], WithDecryption=True) + except botocore.exceptions.ClientError: pass + except botocore.exceptions.BotoCoreError as e: + module.fail_json_aws(e, msg="fetching parameter") if existing_parameter: + original_version = existing_parameter['Parameter']['Version'] if 'Value' not in args: args['Value'] = existing_parameter['Parameter']['Value'] @@ -290,14 +376,17 @@ def create_update_parameter(client, module): try: describe_existing_parameter = describe_parameter( client, module, - Filters=[{"Key": "Name", "Values": [args['Name']]}]) + ParameterFilters=[{"Key": "Name", "Values": [args['Name']]}]) except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: module.fail_json_aws(e, msg="getting description value") if describe_existing_parameter['Description'] != args['Description']: (changed, response) = update_parameter(client, module, **args) + if changed: + _wait_updated(client, module, module.params.get('name'), original_version) else: (changed, response) = update_parameter(client, module, **args) + _wait_exists(client, module, module.params.get('name')) return changed, response @@ -305,8 +394,24 @@ def create_update_parameter(client, module): def delete_parameter(client, module): response = {} + try: + existing_parameter = client.get_parameter(aws_retry=True, Name=module.params.get('name'), WithDecryption=True) + except is_boto3_error_code('ParameterNotFound'): + return False, {} + except botocore.exceptions.ClientError: + # If we can't describe the parameter we may still be able to delete it + existing_parameter = True + except botocore.exceptions.BotoCoreError as e: + module.fail_json_aws(e, msg="setting parameter") + + if not existing_parameter: + return False, {} + if module.check_mode: + return True, {} + try: response = client.delete_parameter( + aws_retry=True, Name=module.params.get('name') ) except is_boto3_error_code('ParameterNotFound'): @@ -314,11 +419,14 @@ def delete_parameter(client, module): except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: # pylint: disable=duplicate-except module.fail_json_aws(e, msg="deleting parameter") + _wait_deleted(client, module, module.params.get('name')) + return True, response def setup_client(module): - connection = module.client('ssm') + retry_decorator = AWSRetry.jittered_backoff() + connection = module.client('ssm', retry_decorator=retry_decorator) return connection @@ -337,6 +445,7 @@ def setup_module_object(): return AnsibleAWSModule( argument_spec=argument_spec, + supports_check_mode=True, ) @@ -353,9 +462,14 @@ def main(): result = {"response": response} - parameter_metadata = describe_parameter( - client, module, - Filters=[{"Key": "Name", "Values": [module.params.get('name')]}]) + try: + parameter_metadata = describe_parameter( + client, module, + ParameterFilters=[{"Key": "Name", "Values": [module.params.get('name')]}]) + except is_boto3_error_code('ParameterNotFound'): + return False, {} + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="to describe parameter") if parameter_metadata: result['parameter_metadata'] = camel_dict_to_snake_dict(parameter_metadata) diff --git a/tests/integration/targets/aws_ssm_parameter_store/tasks/main.yml b/tests/integration/targets/aws_ssm_parameter_store/tasks/main.yml index a92a7e8f97a..7f4e4f6e2d4 100644 --- a/tests/integration/targets/aws_ssm_parameter_store/tasks/main.yml +++ b/tests/integration/targets/aws_ssm_parameter_store/tasks/main.yml @@ -28,6 +28,17 @@ # ============================================================ # Create + - name: Create key/value pair in aws parameter store (CHECK) + aws_ssm_parameter_store: + name: '{{ simple_name }}' + description: '{{ simple_description }}' + value: '{{ simple_value }}' + register: result + check_mode: True + - assert: + that: + - result is changed + - name: Create key/value pair in aws parameter store aws_ssm_parameter_store: name: '{{ simple_name }}' @@ -39,7 +50,7 @@ set_fact: lookup_value: "{{ lookup('amazon.aws.aws_ssm', simple_name, **connection_args) }}" - assert: - that: + that: - result is changed - lookup_value == simple_value - '"parameter_metadata" in result' @@ -59,6 +70,17 @@ - result.parameter_metadata.tier == 'Standard' - result.parameter_metadata.type == 'String' + - name: Create key/value pair in aws parameter store - idempotency (CHECK) + aws_ssm_parameter_store: + name: '{{ simple_name }}' + description: '{{ simple_description }}' + value: '{{ simple_value }}' + register: result + check_mode: True + - assert: + that: + - result is not changed + - name: Create key/value pair in aws parameter store - idempotency aws_ssm_parameter_store: name: '{{ simple_name }}' @@ -70,7 +92,7 @@ set_fact: lookup_value: "{{ lookup('amazon.aws.aws_ssm', simple_name, **connection_args) }}" - assert: - that: + that: - result is not changed - lookup_value == simple_value - '"parameter_metadata" in result' @@ -93,6 +115,16 @@ # ============================================================ # Update description + - name: Update description (CHECK) + aws_ssm_parameter_store: + name: '{{ simple_name }}' + description: '{{ updated_description }}' + register: result + check_mode: True + - assert: + that: + - result is changed + - name: Update description aws_ssm_parameter_store: name: '{{ simple_name }}' @@ -103,7 +135,7 @@ set_fact: lookup_value: "{{ lookup('amazon.aws.aws_ssm', simple_name, **connection_args) }}" - assert: - that: + that: - result is changed - lookup_value == simple_value - '"parameter_metadata" in result' @@ -123,6 +155,16 @@ - result.parameter_metadata.tier == 'Standard' - result.parameter_metadata.type == 'String' + - name: Update description - idempotency (CHECK) + aws_ssm_parameter_store: + name: '{{ simple_name }}' + description: '{{ updated_description }}' + register: result + check_mode: True + - assert: + that: + - result is not changed + - name: Update description - idempotency aws_ssm_parameter_store: name: '{{ simple_name }}' @@ -133,7 +175,7 @@ set_fact: lookup_value: "{{ lookup('amazon.aws.aws_ssm', simple_name, **connection_args) }}" - assert: - that: + that: - result is not changed - lookup_value == simple_value - lookup_value == simple_value @@ -153,16 +195,20 @@ - result.parameter_metadata.policies | length == 0 - result.parameter_metadata.tier == 'Standard' - result.parameter_metadata.type == 'String' - # 2022-06-20: - # There's a quirk in the AWS API that it doesn't seem to return updates to - # the metadata until there's a change in the value of the parameter. The - # change has been made (as we'll see below) but we can't properly determine - # idempotency - ignore_errors: True # ============================================================ # Update value + - name: Update key/value pair in aws parameter store (CHECK) + aws_ssm_parameter_store: + name: '{{ simple_name }}' + value: '{{ updated_value }}' + register: result + check_mode: True + - assert: + that: + - result is changed + - name: Update key/value pair in aws parameter store aws_ssm_parameter_store: name: '{{ simple_name }}' @@ -173,7 +219,7 @@ set_fact: lookup_value: "{{ lookup('amazon.aws.aws_ssm', simple_name, **connection_args) }}" - assert: - that: + that: - result is changed - lookup_value == updated_value - '"parameter_metadata" in result' @@ -193,6 +239,16 @@ - result.parameter_metadata.tier == 'Standard' - result.parameter_metadata.type == 'String' + - name: Update key/value pair in aws parameter store - idempotency (CHECK) + aws_ssm_parameter_store: + name: '{{ simple_name }}' + value: '{{ updated_value }}' + register: result + check_mode: True + - assert: + that: + - result is not changed + - name: Update key/value pair in aws parameter store - idempotency aws_ssm_parameter_store: name: '{{ simple_name }}' @@ -203,7 +259,7 @@ set_fact: lookup_value: "{{ lookup('amazon.aws.aws_ssm', simple_name, **connection_args) }}" - assert: - that: + that: - result is not changed - lookup_value == updated_value - '"parameter_metadata" in result' @@ -226,6 +282,17 @@ # ============================================================ # Complex update + - name: Complex update to key/value pair in aws parameter store (CHECK) + aws_ssm_parameter_store: + name: '{{ simple_name }}' + value: '{{ simple_value }}' + description: '{{ simple_description }}' + register: result + check_mode: True + - assert: + that: + - result is changed + - name: Complex update to key/value pair in aws parameter store aws_ssm_parameter_store: name: '{{ simple_name }}' @@ -237,7 +304,7 @@ set_fact: lookup_value: "{{ lookup('amazon.aws.aws_ssm', simple_name, **connection_args) }}" - assert: - that: + that: - result is changed - lookup_value == simple_value - '"parameter_metadata" in result' @@ -257,6 +324,17 @@ - result.parameter_metadata.tier == 'Standard' - result.parameter_metadata.type == 'String' + - name: Complex update to key/value pair in aws parameter store - idempotency (CHECK) + aws_ssm_parameter_store: + name: '{{ simple_name }}' + value: '{{ simple_value }}' + description: '{{ simple_description }}' + register: result + check_mode: True + - assert: + that: + - result is not changed + - name: Complex update to key/value pair in aws parameter store - idempotency aws_ssm_parameter_store: name: '{{ simple_name }}' @@ -268,7 +346,7 @@ set_fact: lookup_value: "{{ lookup('amazon.aws.aws_ssm', simple_name, **connection_args) }}" - assert: - that: + that: - result is not changed - lookup_value == simple_value - '"parameter_metadata" in result' @@ -291,6 +369,16 @@ # ============================================================ # Delete + - name: Delete key/value pair in aws parameter store (CHECK) + aws_ssm_parameter_store: + name: '{{ simple_name }}' + state: absent + register: result + check_mode: True + - assert: + that: + - result is changed + - name: Delete key/value pair in aws parameter store aws_ssm_parameter_store: name: '{{ simple_name }}' @@ -303,10 +391,20 @@ register: info_result ignore_errors: true - assert: - that: + that: - result is changed - info_result is failed + - name: Delete key/value pair in aws parameter store - idempotency (CHECK) + aws_ssm_parameter_store: + name: '{{ simple_name }}' + state: absent + register: result + check_mode: True + - assert: + that: + - result is not changed + - name: Delete key/value pair in aws parameter store - idempotency aws_ssm_parameter_store: name: '{{ simple_name }}' @@ -314,7 +412,7 @@ register: result - assert: - that: + that: - result is not changed always: