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: