diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d1fcc6b88e..1b23e6b939b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,7 @@ See [Porting Guide](http://docs.ansible.com/ansible/devel/porting_guides.html) f * aws_ssm_parameter_store * aws_waf_condition * cloudfront_distribution + * cloudfront_invalidation * cloudfront_origin_access_identity * ec2_ami_facts * ec2_asg_lifecycle_hook diff --git a/lib/ansible/module_utils/aws/core.py b/lib/ansible/module_utils/aws/core.py index 3d34d3e4052..eccd8be441a 100644 --- a/lib/ansible/module_utils/aws/core.py +++ b/lib/ansible/module_utils/aws/core.py @@ -142,3 +142,6 @@ class AnsibleAWSModule(object): else: self._module.fail_json(msg=message, exception=last_traceback, **camel_dict_to_snake_dict(response)) + + def warn(self, msg): + self._module.warn(msg) diff --git a/lib/ansible/modules/cloud/amazon/cloudfront_invalidation.py b/lib/ansible/modules/cloud/amazon/cloudfront_invalidation.py new file mode 100644 index 00000000000..d55d1229393 --- /dev/null +++ b/lib/ansible/modules/cloud/amazon/cloudfront_invalidation.py @@ -0,0 +1,275 @@ +#!/usr/bin/python +# Copyright (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' +--- + +module: cloudfront_invalidation + +short_description: create invalidations for aws cloudfront distributions +description: + - Allows for invalidation of a batch of paths for a CloudFront distribution. + +requirements: + - boto3 >= 1.0.0 + - python >= 2.6 + +version_added: "2.5" + +author: Willem van Ketwich (@wilvk) + +extends_documentation_fragment: + - aws + - ec2 + +options: + distribution_id: + description: + - The id of the cloudfront distribution to invalidate paths for. Can be specified insted of the alias. + required: false + alias: + description: + - The alias of the cloudfront distribution to invalidate paths for. Can be specified instead of distribution_id. + required: false + caller_reference: + description: + - A unique reference identifier for the invalidation paths. + required: false + default: current datetime stamp + target_paths: + description: + - A list of paths on the distribution to invalidate. Each path should begin with '/'. Wildcards are allowed. eg. '/foo/bar/*' + required: true + +notes: + - does not support check mode + +''' + +EXAMPLES = ''' + +- name: create a batch of invalidations using a distribution_id for a reference + cloudfront_invalidation: + distribution_id: E15BU8SDCGSG57 + caller_reference: testing 123 + target_paths: + - /testpathone/test1.css + - /testpathtwo/test2.js + - /testpaththree/test3.ss + +- name: create a batch of invalidations using an alias as a reference and one path using a wildcard match + cloudfront_invalidation: + alias: alias.test.com + caller_reference: testing 123 + target_paths: + - /testpathone/test4.css + - /testpathtwo/test5.js + - /testpaththree/* + +''' + +RETURN = ''' +invalidation: + description: The invalidation's information. + returned: always + type: complex + contains: + create_time: + description: The date and time the invalidation request was first made. + returned: always + type: string + sample: '2018-02-01T15:50:41.159000+00:00' + id: + description: The identifier for the invalidation request. + returned: always + type: string + sample: I2G9MOWJZFV612 + invalidation_batch: + description: The current invalidation information for the batch request. + returned: always + type: complex + contains: + caller_reference: + description: The value used to uniquely identify an invalidation request. + returned: always + type: string + sample: testing 123 + paths: + description: A dict that contains information about the objects that you want to invalidate. + returned: always + type: complex + contains: + items: + description: A list of the paths that you want to invalidate. + returned: always + type: list + sample: + - /testpathtwo/test2.js + - /testpathone/test1.css + - /testpaththree/test3.ss + quantity: + description: The number of objects that you want to invalidate. + returned: always + type: int + sample: 3 + status: + description: The status of the invalidation request. + returned: always + type: string + sample: Completed +location: + description: The fully qualified URI of the distribution and invalidation batch request. + returned: always + type: string + sample: https://cloudfront.amazonaws.com/2017-03-25/distribution/E1ZID6KZJECZY7/invalidation/I2G9MOWJZFV622 +''' + +from ansible.module_utils.ec2 import get_aws_connection_info +from ansible.module_utils.ec2 import ec2_argument_spec, boto3_conn +from ansible.module_utils.ec2 import snake_dict_to_camel_dict +from ansible.module_utils.ec2 import camel_dict_to_snake_dict +from ansible.module_utils.aws.core import AnsibleAWSModule +from ansible.module_utils.aws.cloudfront_facts import CloudFrontFactsServiceManager +import datetime + +try: + from botocore.exceptions import ClientError, BotoCoreError +except ImportError: + pass # caught by imported AnsibleAWSModule + + +class CloudFrontInvalidationServiceManager(object): + """ + Handles CloudFront service calls to AWS for invalidations + """ + + def __init__(self, module): + self.module = module + self.create_client('cloudfront') + + def create_client(self, resource): + region, ec2_url, aws_connect_kwargs = get_aws_connection_info(self.module, boto3=True) + self.client = boto3_conn(self.module, conn_type='client', resource=resource, region=region, endpoint=ec2_url, **aws_connect_kwargs) + + def create_invalidation(self, distribution_id, invalidation_batch): + current_invalidation_response = self.get_invalidation(distribution_id, invalidation_batch['CallerReference']) + try: + response = self.client.create_invalidation(DistributionId=distribution_id, InvalidationBatch=invalidation_batch) + response.pop('ResponseMetadata', None) + if current_invalidation_response: + return response, False + else: + return response, True + except BotoCoreError as e: + self.module.fail_json_aws(e, msg="Error creating CloudFront invalidations.") + except ClientError as e: + if ('Your request contains a caller reference that was used for a previous invalidation batch ' + 'for the same distribution.' in e.response['Error']['Message']): + self.module.warn("InvalidationBatch target paths are not modifiable. " + "To make a new invalidation please update caller_reference.") + return current_invalidation_response, False + else: + self.module.fail_json_aws(e, msg="Error creating CloudFront invalidations.") + + def get_invalidation(self, distribution_id, caller_reference): + current_invalidation = {} + # find all invalidations for the distribution + try: + paginator = self.client.get_paginator('list_invalidations') + invalidations = paginator.paginate(DistributionId=distribution_id).build_full_result()['InvalidationList'].get('Items', []) + invalidation_ids = [inv['Id'] for inv in invalidations] + except (BotoCoreError, ClientError) as e: + self.module.fail_json_aws(e, msg="Error listing CloudFront invalidations.") + + # check if there is an invalidation with the same caller reference + for inv_id in invalidation_ids: + try: + invalidation = self.client.get_invalidation(DistributionId=distribution_id, Id=inv_id)['Invalidation'] + caller_ref = invalidation.get('InvalidationBatch', {}).get('CallerReference') + except (BotoCoreError, ClientError) as e: + self.module.fail_json_aws(e, msg="Error getting Cloudfront invalidation {0}".format(inv_id)) + if caller_ref == caller_reference: + current_invalidation = invalidation + break + + current_invalidation.pop('ResponseMetadata', None) + return current_invalidation + + +class CloudFrontInvalidationValidationManager(object): + """ + Manages Cloudfront validations for invalidation batches + """ + + def __init__(self, module): + self.module = module + self.__cloudfront_facts_mgr = CloudFrontFactsServiceManager(module) + + def validate_distribution_id(self, distribution_id, alias): + try: + if distribution_id is None and alias is None: + self.module.fail_json(msg="distribution_id or alias must be specified") + if distribution_id is None: + distribution_id = self.__cloudfront_facts_mgr.get_distribution_id_from_domain_name(alias) + return distribution_id + except (ClientError, BotoCoreError) as e: + self.module.fail_json_aws(e, msg="Error validating parameters.") + + def create_aws_list(self, invalidation_batch): + aws_list = {} + aws_list["Quantity"] = len(invalidation_batch) + aws_list["Items"] = invalidation_batch + return aws_list + + def validate_invalidation_batch(self, invalidation_batch, caller_reference): + try: + if caller_reference is not None: + valid_caller_reference = caller_reference + else: + valid_caller_reference = datetime.datetime.now().isoformat() + valid_invalidation_batch = { + 'paths': self.create_aws_list(invalidation_batch), + 'caller_reference': valid_caller_reference + } + return valid_invalidation_batch + except (ClientError, BotoCoreError) as e: + self.module.fail_json_aws(e, msg="Error validating invalidation batch.") + + +def main(): + argument_spec = ec2_argument_spec() + + argument_spec.update(dict( + caller_reference=dict(), + distribution_id=dict(), + alias=dict(), + target_paths=dict(required=True, type='list') + )) + + module = AnsibleAWSModule(argument_spec=argument_spec, supports_check_mode=False, mutually_exclusive=[['distribution_id', 'alias']]) + + validation_mgr = CloudFrontInvalidationValidationManager(module) + service_mgr = CloudFrontInvalidationServiceManager(module) + + caller_reference = module.params.get('caller_reference') + distribution_id = module.params.get('distribution_id') + alias = module.params.get('alias') + target_paths = module.params.get('target_paths') + + result = {} + + distribution_id = validation_mgr.validate_distribution_id(distribution_id, alias) + valid_target_paths = validation_mgr.validate_invalidation_batch(target_paths, caller_reference) + valid_pascal_target_paths = snake_dict_to_camel_dict(valid_target_paths, True) + result, changed = service_mgr.create_invalidation(distribution_id, valid_pascal_target_paths) + + module.exit_json(changed=changed, **camel_dict_to_snake_dict(result)) + + +if __name__ == '__main__': + main()