From 8d733dbdf0eb7bbda5f3a783e893d71a3a35a646 Mon Sep 17 00:00:00 2001 From: Will Thames Date: Thu, 18 Jan 2018 02:03:23 +1000 Subject: [PATCH] [cloud] New module cloudfront_distribution (#31284) * added cloudfont.py, modified cloudfront_facts.py class name and fixed a minor bug * Improvements to cloudfront_distribution * Reduce the scope of the cloudfront_distribution module * Remove presigning * Remove streaming distribution functionality * Add full test suite for cloudfront distribution * Meet Ansible AWS guidelines * Make requested changes Fix tests Use built-in waiter Update copyright --- CHANGELOG.md | 1 + .../testing_policies/cloudfront-policy.json | 28 + .../module_utils/aws/cloudfront_facts.py | 234 ++ .../cloud/amazon/cloudfront_distribution.py | 1972 +++++++++++++++++ .../modules/cloud/amazon/cloudfront_facts.py | 1 + .../targets/cloudfront_distribution/aliases | 1 + .../cloudfront_distribution/defaults/main.yml | 37 + .../cloudfront_distribution/meta/main.yml | 3 + .../cloudfront_distribution/tasks/main.yml | 386 ++++ 9 files changed, 2663 insertions(+) create mode 100644 hacking/aws_config/testing_policies/cloudfront-policy.json create mode 100644 lib/ansible/module_utils/aws/cloudfront_facts.py create mode 100644 lib/ansible/modules/cloud/amazon/cloudfront_distribution.py create mode 100644 test/integration/targets/cloudfront_distribution/aliases create mode 100644 test/integration/targets/cloudfront_distribution/defaults/main.yml create mode 100644 test/integration/targets/cloudfront_distribution/meta/main.yml create mode 100644 test/integration/targets/cloudfront_distribution/tasks/main.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b16b76eff7..f85f8e3844b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,7 @@ Ansible Changes By Release * aws_kms_facts * aws_s3_cors * aws_ssm_parameter_store + * cloudfront_distribution * ec2_ami_facts * ec2_asg_lifecycle_hook * ec2_placement_group diff --git a/hacking/aws_config/testing_policies/cloudfront-policy.json b/hacking/aws_config/testing_policies/cloudfront-policy.json new file mode 100644 index 00000000000..5bb22ffe5ae --- /dev/null +++ b/hacking/aws_config/testing_policies/cloudfront-policy.json @@ -0,0 +1,28 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowCloudfrontUsage", + "Effect": "Allow", + "Action": [ + "cloudfront:CreateDistribution", + "cloudfront:CreateDistributionWithTags", + "cloudfront:DeleteDistribution", + "cloudfront:GetDistribution", + "cloudfront:GetStreamingDistribution", + "cloudfront:GetDistributionConfig", + "cloudfront:GetStreamingDistributionConfig", + "cloudfront:GetInvalidation", + "cloudfront:ListDistributions", + "cloudfront:ListDistributionsByWebACLId", + "cloudfront:ListInvalidations", + "cloudfront:ListStreamingDistributions", + "cloudfront:ListTagsForResource", + "cloudfront:TagResource", + "cloudfront:UntagResource", + "cloudfront:UpdateDistribution" + ], + "Resource": "*" + } + ] +} diff --git a/lib/ansible/module_utils/aws/cloudfront_facts.py b/lib/ansible/module_utils/aws/cloudfront_facts.py new file mode 100644 index 00000000000..37b41de1599 --- /dev/null +++ b/lib/ansible/module_utils/aws/cloudfront_facts.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017 Willem van Ketwich +# +# This module is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This software is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this software. If not, see . +# +# Author: +# - Willem van Ketwich +# +# Common functionality to be used by the modules: +# - cloudfront_distribution +# - cloudfront_invalidation +# - cloudfront_origin_access_identity + + +""" +Common cloudfront facts shared between modules +""" + +from ansible.module_utils.ec2 import get_aws_connection_info, boto3_conn +from ansible.module_utils.ec2 import boto3_tag_list_to_ansible_dict, camel_dict_to_snake_dict + +try: + import botocore +except ImportError: + pass + + +class CloudFrontFactsServiceManager(object): + """Handles CloudFront Facts Services""" + + def __init__(self, module): + self.module = module + + region, ec2_url, aws_connect_kwargs = get_aws_connection_info(module, boto3=True) + self.client = boto3_conn(module, conn_type='client', + resource='cloudfront', region=region, + endpoint=ec2_url, **aws_connect_kwargs) + + def get_distribution(self, distribution_id): + try: + return self.client.get_distribution(Id=distribution_id) + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error describing distribution") + + def get_distribution_config(self, distribution_id): + try: + return self.client.get_distribution_config(Id=distribution_id) + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error describing distribution configuration") + + def get_origin_access_identity(self, origin_access_identity_id): + try: + return self.client.get_cloud_front_origin_access_identity(Id=origin_access_identity_id) + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error describing origin access identity") + + def get_origin_access_identity_config(self, origin_access_identity_id): + try: + return self.client.get_cloud_front_origin_access_identity_config(Id=origin_access_identity_id) + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error describing origin access identity configuration") + + def get_invalidation(self, distribution_id, invalidation_id): + try: + return self.client.get_invalidation(DistributionId=distribution_id, Id=invalidation_id) + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error describing invalidation") + + def get_streaming_distribution(self, distribution_id): + try: + return self.client.get_streaming_distribution(Id=distribution_id) + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error describing streaming distribution") + + def get_streaming_distribution_config(self, distribution_id): + try: + return self.client.get_streaming_distribution_config(Id=distribution_id) + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error describing streaming distribution") + + def list_origin_access_identities(self): + try: + paginator = self.client.get_paginator('list_cloud_front_origin_access_identities') + result = paginator.paginate().build_full_result()['CloudFrontOriginAccessIdentityList'] + return result.get('Items', []) + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error listing cloud front origin access identities") + + def list_distributions(self, keyed=True): + try: + paginator = self.client.get_paginator('list_distributions') + result = paginator.paginate().build_full_result().get('DistributionList', {}) + distribution_list = result.get('Items', []) + if not keyed: + return distribution_list + return self.keyed_list_helper(distribution_list) + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error listing distributions") + + def list_distributions_by_web_acl_id(self, web_acl_id): + try: + result = self.client.list_distributions_by_web_acl_id(WebAclId=web_acl_id) + distribution_list = result.get('DistributionList', {}).get('Items', []) + return self.keyed_list_helper(distribution_list) + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error listing distributions by web acl id") + + def list_invalidations(self, distribution_id): + try: + paginator = self.client.get_paginator('list_invalidations') + result = paginator.paginate(DistributionId=distribution_id).build_full_result() + return result.get('InvalidationList', {}).get('Items', []) + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error listing invalidations") + + def list_streaming_distributions(self, keyed=True): + try: + paginator = self.client.get_paginator('list_streaming_distributions') + result = paginator.paginate().build_full_result() + streaming_distribution_list = result.get('StreamingDistributionList', {}).get('Items', []) + if not keyed: + return streaming_distribution_list + return self.keyed_list_helper(streaming_distribution_list) + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error listing streaming distributions") + + def summary(self): + summary_dict = {} + summary_dict.update(self.summary_get_distribution_list(False)) + summary_dict.update(self.summary_get_distribution_list(True)) + summary_dict.update(self.summary_get_origin_access_identity_list()) + return summary_dict + + def summary_get_origin_access_identity_list(self): + try: + origin_access_identity_list = {'origin_access_identities': []} + origin_access_identities = self.list_origin_access_identities() + for origin_access_identity in origin_access_identities: + oai_id = origin_access_identity['Id'] + oai_full_response = self.get_origin_access_identity(oai_id) + oai_summary = {'Id': oai_id, 'ETag': oai_full_response['ETag']} + origin_access_identity_list['origin_access_identities'].append(oai_summary) + return origin_access_identity_list + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error generating summary of origin access identities") + + def summary_get_distribution_list(self, streaming=False): + try: + list_name = 'streaming_distributions' if streaming else 'distributions' + key_list = ['Id', 'ARN', 'Status', 'LastModifiedTime', 'DomainName', 'Comment', 'PriceClass', 'Enabled'] + distribution_list = {list_name: []} + distributions = self.list_streaming_distributions(False) if streaming else self.list_distributions(False) + for dist in distributions: + temp_distribution = {} + for key_name in key_list: + temp_distribution[key_name] = dist[key_name] + temp_distribution['Aliases'] = [alias for alias in dist['Aliases'].get('Items', [])] + temp_distribution['ETag'] = self.get_etag_from_distribution_id(dist['Id'], streaming) + if not streaming: + temp_distribution['WebACLId'] = dist['WebACLId'] + invalidation_ids = self.get_list_of_invalidation_ids_from_distribution_id(dist['Id']) + if invalidation_ids: + temp_distribution['Invalidations'] = invalidation_ids + resource_tags = self.client.list_tags_for_resource(Resource=dist['ARN']) + temp_distribution['Tags'] = boto3_tag_list_to_ansible_dict(resource_tags['Tags'].get('Items', [])) + distribution_list[list_name].append(temp_distribution) + return distribution_list + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error generating summary of distributions") + except Exception as e: + self.module.fail_json_aws(e, msg="Error generating summary of distributions") + + def get_etag_from_distribution_id(self, distribution_id, streaming): + distribution = {} + if not streaming: + distribution = self.get_distribution(distribution_id) + else: + distribution = self.get_streaming_distribution(distribution_id) + return distribution['ETag'] + + def get_list_of_invalidation_ids_from_distribution_id(self, distribution_id): + try: + invalidation_ids = [] + invalidations = self.list_invalidations(distribution_id) + for invalidation in invalidations: + invalidation_ids.append(invalidation['Id']) + return invalidation_ids + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error getting list of invalidation ids") + + def get_distribution_id_from_domain_name(self, domain_name): + try: + distribution_id = "" + distributions = self.list_distributions(False) + distributions += self.list_streaming_distributions(False) + for dist in distributions: + if 'Items' in dist['Aliases']: + for alias in dist['Aliases']['Items']: + if str(alias).lower() == domain_name.lower(): + distribution_id = dist['Id'] + break + return distribution_id + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error getting distribution id from domain name") + + def get_aliases_from_distribution_id(self, distribution_id): + try: + distribution = self.get_distribution(distribution_id) + return distribution['DistributionConfig']['Aliases'].get('Items', []) + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Error getting list of aliases from distribution_id") + + def keyed_list_helper(self, list_to_key): + keyed_list = dict() + for item in list_to_key: + distribution_id = item['Id'] + if 'Items' in item['Aliases']: + aliases = item['Aliases']['Items'] + for alias in aliases: + keyed_list.update({alias: item}) + keyed_list.update({distribution_id: item}) + return keyed_list diff --git a/lib/ansible/modules/cloud/amazon/cloudfront_distribution.py b/lib/ansible/modules/cloud/amazon/cloudfront_distribution.py new file mode 100644 index 00000000000..51e7d167618 --- /dev/null +++ b/lib/ansible/modules/cloud/amazon/cloudfront_distribution.py @@ -0,0 +1,1972 @@ +#!/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_distribution + +short_description: create, update and delete aws cloudfront distributions. + +description: + - Allows for easy creation, updating and deletion of CloudFront distributions. + +requirements: + - boto3 >= 1.0.0 + - python >= 2.6 + +version_added: "2.5" + +author: + - Willem van Ketwich (@wilvk) + - Will Thames (@willthames) + +options: + + state: + description: + - The desired state of the distribution + present - creates a new distribution or updates an existing distribution. + absent - deletes an existing distribution. + choices: ['present', 'absent'] + default: 'present' + + distribution_id: + description: + - The id of the cloudfront distribution. This parameter can be exchanged with I(alias) or I(caller_reference) and is used in conjunction with I(e_tag). + + e_tag: + description: + - A unique identifier of a modified or existing distribution. Used in conjunction with I(distribution_id). + Is determined automatically if not specified. + + caller_reference: + description: + - A unique identifier for creating and updating cloudfront distributions. Each caller reference must be unique across all distributions. e.g. a caller + reference used in a web distribution cannot be reused in a streaming distribution. This parameter can be used instead of I(distribution_id) + to reference an existing distribution. If not specified, this defaults to a datetime stamp of the format + 'YYYY-MM-DDTHH:MM:SS.ffffff'. + + tags: + description: + - Should be input as a dict() of key-value pairs. + Note that numeric keys or values must be wrapped in quotes. e.g. "Priority:" '1' + + purge_tags: + description: + - Specifies whether existing tags will be removed before adding new tags. When I(purge_tags=yes), existing tags are removed and I(tags) are added, if + specified. If no tags are specified, it removes all existing tags for the distribution. When I(purge_tags=no), existing tags are kept and I(tags) + are added, if specified. + default: 'no' + choices: ['yes', 'no'] + + alias: + description: + - The name of an alias (CNAME) that is used in a distribution. This is used to effectively reference a distribution by its alias as an alias can only + be used by one distribution per AWS account. This variable avoids having to provide the I(distribution_id) as well as + the I(e_tag), or I(caller_reference) of an existing distribution. + + aliases: + description: + - A I(list[]) of domain name aliases (CNAMEs) as strings to be used for the distribution. Each alias must be unique across all distribution for the AWS + account. + + default_root_object: + description: + - A config element that specifies the path to request when the user requests the origin. e.g. if specified as 'index.html', this maps to + www.example.com/index.html when www.example.com is called by the user. This prevents the entire distribution origin from being exposed at the root. + + default_origin_domain_name: + description: + - The domain name to use for an origin if no I(origins) have been specified. Should only be used on a first run of generating a distribution and not on + subsequent runs. Should not be used in conjunction with I(distribution_id), I(caller_reference) or I(alias). + + default_origin_path: + description: + - The default origin path to specify for an origin if no I(origins) have been specified. Defaults to empty if not specified. + + origins: + description: + - A config element that is a I(list[]) of complex origin objects to be specified for the distribution. Used for creating and updating distributions. + Each origin item comprises the attributes + I(id) + I(domain_name) (defaults to default_origin_domain_name if not specified) + I(origin_path) (defaults to default_origin_path if not specified) + I(custom_headers[]) + I(header_name) + I(header_value) + I(s3_origin_access_identity_enabled) + I(custom_origin_config) + I(http_port) + I(https_port) + I(origin_protocol_policy) + I(origin_ssl_protocols[]) + I(origin_read_timeout) + I(origin_keepalive_timeout) + + purge_origins: + description: Whether to remove any origins that aren't listed in I(origins) + default: false + + default_cache_behavior: + description: + - A config element that is a complex object specifying the default cache behavior of the distribution. If not specified, the I(target_origin_id) is + defined as the I(target_origin_id) of the first valid I(cache_behavior) in I(cache_behaviors) with defaults. + The default cache behavior comprises the attributes + I(target_origin_id) + I(forwarded_values) + I(query_string) + I(cookies) + I(forward) + I(whitelisted_names) + I(headers[]) + I(query_string_cache_keys[]) + I(trusted_signers) + I(enabled) + I(items[]) + I(viewer_protocol_policy) + I(min_ttl) + I(allowed_methods) + I(items[]) + I(cached_methods[]) + I(smooth_streaming) + I(default_ttl) + I(max_ttl) + I(compress) + I(lambda_function_associations[]) + I(lambda_function_arn) + I(event_type) + + cache_behaviors: + description: + - A config element that is a I(list[]) of complex cache behavior objects to be specified for the distribution. The order + of the list is preserved across runs unless C(purge_cache_behavior) is enabled. + Each cache behavior comprises the attributes + I(path_pattern) + I(target_origin_id) + I(forwarded_values) + I(query_string) + I(cookies) + I(forward) + I(whitelisted_names) + I(headers[]) + I(query_string_cache_keys[]) + I(trusted_signers) + I(enabled) + I(items[]) + I(viewer_protocol_policy) + I(min_ttl) + I(allowed_methods) + I(items[]) + I(cached_methods[]) + I(smooth_streaming) + I(default_ttl) + I(max_ttl) + I(compress) + I(lambda_function_associations[]) + + purge_cache_behaviors: + description: Whether to remove any cache behaviors that aren't listed in I(cache_behaviors). This switch + also allows the reordering of cache_behaviors. + default: false + + custom_error_responses: + description: + - A config element that is a I(list[]) of complex custom error responses to be specified for the distribution. This attribute configures custom http + error messages returned to the user. + Each custom error response object comprises the attributes + I(error_code) + I(reponse_page_path) + I(response_code) + I(error_caching_min_ttl) + + purge_custom_error_responses: + description: Whether to remove any custom error responses that aren't listed in I(custom_error_responses) + default: false + + comment: + description: + - A comment that describes the cloudfront distribution. If not specified, it defaults to a + generic message that it has been created with Ansible, and a datetime stamp. + + logging: + description: + - A config element that is a complex object that defines logging for the distribution. + The logging object comprises the attributes + I(enabled) + I(include_cookies) + I(bucket) + I(prefix) + + price_class: + description: + - A string that specifies the pricing class of the distribution. As per + U(https://aws.amazon.com/cloudfront/pricing/) + I(price_class=PriceClass_100) consists of the areas + United States + Canada + Europe + I(price_class=PriceClass_200) consists of the areas + United States + Canada + Europe + Hong Kong, Philippines, S. Korea, Singapore & Taiwan + Japan + India + I(price_class=PriceClass_All) consists of the areas + United States + Canada + Europe + Hong Kong, Philippines, S. Korea, Singapore & Taiwan + Japan + India + South America + Australia + choices: ['PriceClass_100', 'PriceClass_200', 'PriceClass_All'] + default: aws defaults this to 'PriceClass_All' + + enabled: + description: + - A boolean value that specifies whether the distribution is enabled or disabled. + default: 'yes' + choices: ['yes', 'no'] + + viewer_certificate: + description: + - A config element that is a complex object that specifies the encryption details of the distribution. + Comprises the following attributes + I(cloudfront_default_certificate) + I(iam_certificate_id) + I(acm_certificate_arn) + I(ssl_support_method) + I(minimum_protocol_version) + I(certificate) + I(certificate_source) + + restrictions: + description: + - A config element that is a complex object that describes how a distribution should restrict it's content. + The restriction object comprises the following attributes + I(geo_restriction) + I(restriction_type) + I(items[]) + + web_acl_id: + description: + - The id of a Web Application Firewall (WAF) Access Control List (ACL). + + http_version: + description: + - The version of the http protocol to use for the distribution. + choices: [ 'http1.1', 'http2' ] + default: aws defaults this to 'http2' + + ipv6_enabled: + description: + - Determines whether IPv6 support is enabled or not. + choices: ['yes', 'no'] + default: 'no' + + wait: + description: + - Specifies whether the module waits until the distribution has completed processing the creation or update. + choices: ['yes', 'no'] + default: 'no' + + wait_timeout: + description: + - Specifies the duration in seconds to wait for a timeout of a cloudfront create or update. Defaults to 1800 seconds (30 minutes). + default: 1800 + +''' + +EXAMPLES = ''' + +# create a basic distribution with defaults and tags + +- cloudfront_distribution: + state: present + default_origin_domain_name: www.my-cloudfront-origin.com + tags: + Name: example distribution + Project: example project + Priority: '1' + +# update a distribution comment by distribution_id + +- cloudfront_distribution: + state: present + distribution_id: E1RP5A2MJ8073O + comment: modified by ansible cloudfront.py + +# update a distribution comment by caller_reference + +- cloudfront_distribution: + state: present + caller_reference: my cloudfront distribution 001 + comment: modified by ansible cloudfront.py + +# update a distribution's aliases and comment using the distribution_id as a reference + +- cloudfront_distribution: + state: present + distribution_id: E1RP5A2MJ8073O + comment: modified by cloudfront.py again + aliases: [ 'www.my-distribution-source.com', 'zzz.aaa.io' ] + +# update a distribution's aliases and comment using an alias as a reference + +- cloudfront_distribution: + state: present + caller_reference: my test distribution + comment: modified by cloudfront.py again + aliases: + - www.my-distribution-source.com + - zzz.aaa.io + +# update a distribution's comment and aliases and tags and remove existing tags + +- cloudfront_distribution: + state: present + distribution_id: E15BU8SDCGSG57 + comment: modified by cloudfront.py again + aliases: + - tested.com + tags: + Project: distribution 1.2 + purge_tags: yes + +# create a distribution with an origin, logging and default cache behavior + +- cloudfront_distribution: + state: present + caller_reference: unique test distribution id + origins: + - id: 'my test origin-000111' + domain_name: www.example.com + origin_path: /production + custom_headers: + - header_name: MyCustomHeaderName + header_value: MyCustomHeaderValue + default_cache_behavior: + target_origin_id: 'my test origin-000111' + forwarded_values: + query_string: true + cookies: + forward: all + headers: + - '*' + viewer_protocol_policy: allow-all + smooth_streaming: true + compress: true + allowed_methods: + items: + - GET + - HEAD + cached_methods: + - GET + - HEAD + logging: + enabled: true + include_cookies: false + bucket: mylogbucket.s3.amazonaws.com + prefix: myprefix/ + enabled: false + comment: this is a cloudfront distribution with logging + +# delete a distribution + +- cloudfront_distribution: + state: absent + caller_reference: replaceable distribution +''' + +RETURN = ''' +active_trusted_signers: + description: Key pair IDs that CloudFront is aware of for each trusted signer + returned: always + type: complex + contains: + enabled: + description: Whether trusted signers are in use + returned: always + type: bool + sample: false + quantity: + description: Number of trusted signers + returned: always + type: int + sample: 1 + items: + description: Number of trusted signers + returned: when there are trusted signers + type: list + sample: + - key_pair_id +aliases: + description: Aliases that refer to the distribution + returned: always + type: complex + contains: + items: + description: List of aliases + returned: always + type: list + sample: + - test.example.com + quantity: + description: Number of aliases + returned: always + type: int + sample: 1 +arn: + description: Amazon Resource Name of the distribution + returned: always + type: string + sample: arn:aws:cloudfront::123456789012:distribution/E1234ABCDEFGHI +cache_behaviors: + description: Cloudfront cache behaviors + returned: always + type: complex + contains: + items: + description: List of cache behaviors + returned: always + type: complex + contains: + allowed_methods: + description: Methods allowed by the cache behavior + returned: always + type: complex + contains: + cached_methods: + description: Methods cached by the cache behavior + returned: always + type: complex + contains: + items: + description: List of cached methods + returned: always + type: list + sample: + - HEAD + - GET + quantity: + description: Count of cached methods + returned: always + type: int + sample: 2 + items: + description: List of methods allowed by the cache behavior + returned: always + type: list + sample: + - HEAD + - GET + quantity: + description: Count of methods allowed by the cache behavior + returned: always + type: int + sample: 2 + compress: + description: Whether compression is turned on for the cache behavior + returned: always + type: bool + sample: false + default_ttl: + description: Default Time to Live of the cache behavior + returned: always + type: int + sample: 86400 + forwarded_values: + description: Values forwarded to the origin for this cache behavior + returned: always + type: complex + contains: + cookies: + description: Cookies to forward to the origin + returned: always + type: complex + contains: + forward: + description: Which cookies to forward to the origin for this cache behavior + returned: always + type: string + sample: none + whitelisted_names: + description: The names of the cookies to forward to the origin for this cache behavior + returned: when I(forward) is C(whitelist) + type: complex + contains: + quantity: + description: Count of cookies to forward + returned: always + type: int + sample: 1 + items: + description: List of cookies to forward + returned: when list is not empty + type: list + sample: my_cookie + headers: + description: Which headers are used to vary on cache retrievals + returned: always + type: complex + contains: + quantity: + description: Count of headers to vary on + returned: always + type: int + sample: 1 + items: + description: List of headers to vary on + returned: when list is not empty + type: list + sample: + - Host + query_string: + description: Whether the query string is used in cache lookups + returned: always + type: bool + sample: false + query_string_cache_keys: + description: Which query string keys to use in cache lookups + returned: always + type: complex + contains: + quantity: + description: Count of query string cache keys to use in cache lookups + returned: always + type: int + sample: 1 + items: + description: List of query string cache keys to use in cache lookups + returned: when list is not empty + type: list + sample: + lambda_function_associations: + description: Lambda function associations for a cache behavior + returned: always + type: complex + contains: + quantity: + description: Count of lambda function associations + returned: always + type: int + sample: 1 + items: + description: List of lambda function associations + returned: when list is not empty + type: list + sample: + - lambda_function_arn: arn:aws:lambda:123456789012:us-east-1/lambda/lambda-function + event_type: viewer-response + max_ttl: + description: Maximum Time to Live + returned: always + type: int + sample: 31536000 + min_ttl: + description: Minimum Time to Live + returned: always + type: int + sample: 0 + path_pattern: + description: Path pattern that determines this cache behavior + returned: always + type: string + sample: /path/to/files/* + smooth_streaming: + description: Whether smooth streaming is enabled + returned: always + type: bool + sample: false + target_origin_id: + description: Id of origin reference by this cache behavior + returned: always + type: string + sample: origin_abcd + trusted_signers: + description: Trusted signers + returned: always + type: complex + contains: + enabled: + description: Whether trusted signers are enabled for this cache behavior + returned: always + type: bool + sample: false + quantity: + description: Count of trusted signers + returned: always + type: int + sample: 1 + viewer_protocol_policy: + description: Policy of how to handle http/https + returned: always + type: string + sample: redirect-to-https + quantity: + description: Count of cache behaviors + returned: always + type: int + sample: 1 + +caller_reference: + description: Idempotency reference given when creating cloudfront distribution + returned: always + type: string + sample: '1484796016700' +comment: + description: Any comments you want to include about the distribution + returned: always + type: string + sample: 'my first cloudfront distribution' +custom_error_responses: + description: Custom error responses to use for error handling + returned: always + type: complex + contains: + items: + description: List of custom error responses + returned: always + type: complex + contains: + error_caching_min_ttl: + description: Mininum time to cache this error response + returned: always + type: int + sample: 300 + error_code: + description: Origin response code that triggers this error response + returned: always + type: int + sample: 500 + response_code: + description: Response code to return to the requester + returned: always + type: string + sample: '500' + response_page_path: + description: Path that contains the error page to display + returned: always + type: string + sample: /errors/5xx.html + quantity: + description: Count of custom error response items + returned: always + type: int + sample: 1 +default_cache_behavior: + description: Default cache behavior + returned: always + type: complex + contains: + allowed_methods: + description: Methods allowed by the cache behavior + returned: always + type: complex + contains: + cached_methods: + description: Methods cached by the cache behavior + returned: always + type: complex + contains: + items: + description: List of cached methods + returned: always + type: list + sample: + - HEAD + - GET + quantity: + description: Count of cached methods + returned: always + type: int + sample: 2 + items: + description: List of methods allowed by the cache behavior + returned: always + type: list + sample: + - HEAD + - GET + quantity: + description: Count of methods allowed by the cache behavior + returned: always + type: int + sample: 2 + compress: + description: Whether compression is turned on for the cache behavior + returned: always + type: bool + sample: false + default_ttl: + description: Default Time to Live of the cache behavior + returned: always + type: int + sample: 86400 + forwarded_values: + description: Values forwarded to the origin for this cache behavior + returned: always + type: complex + contains: + cookies: + description: Cookies to forward to the origin + returned: always + type: complex + contains: + forward: + description: Which cookies to forward to the origin for this cache behavior + returned: always + type: string + sample: none + whitelisted_names: + description: The names of the cookies to forward to the origin for this cache behavior + returned: when I(forward) is C(whitelist) + type: complex + contains: + quantity: + description: Count of cookies to forward + returned: always + type: int + sample: 1 + items: + description: List of cookies to forward + returned: when list is not empty + type: list + sample: my_cookie + headers: + description: Which headers are used to vary on cache retrievals + returned: always + type: complex + contains: + quantity: + description: Count of headers to vary on + returned: always + type: int + sample: 1 + items: + description: List of headers to vary on + returned: when list is not empty + type: list + sample: + - Host + query_string: + description: Whether the query string is used in cache lookups + returned: always + type: bool + sample: false + query_string_cache_keys: + description: Which query string keys to use in cache lookups + returned: always + type: complex + contains: + quantity: + description: Count of query string cache keys to use in cache lookups + returned: always + type: int + sample: 1 + items: + description: List of query string cache keys to use in cache lookups + returned: when list is not empty + type: list + sample: + lambda_function_associations: + description: Lambda function associations for a cache behavior + returned: always + type: complex + contains: + quantity: + description: Count of lambda function associations + returned: always + type: int + sample: 1 + items: + description: List of lambda function associations + returned: when list is not empty + type: list + sample: + - lambda_function_arn: arn:aws:lambda:123456789012:us-east-1/lambda/lambda-function + event_type: viewer-response + max_ttl: + description: Maximum Time to Live + returned: always + type: int + sample: 31536000 + min_ttl: + description: Minimum Time to Live + returned: always + type: int + sample: 0 + path_pattern: + description: Path pattern that determines this cache behavior + returned: always + type: string + sample: /path/to/files/* + smooth_streaming: + description: Whether smooth streaming is enabled + returned: always + type: bool + sample: false + target_origin_id: + description: Id of origin reference by this cache behavior + returned: always + type: string + sample: origin_abcd + trusted_signers: + description: Trusted signers + returned: always + type: complex + contains: + enabled: + description: Whether trusted signers are enabled for this cache behavior + returned: always + type: bool + sample: false + quantity: + description: Count of trusted signers + returned: always + type: int + sample: 1 + viewer_protocol_policy: + description: Policy of how to handle http/https + returned: always + type: string + sample: redirect-to-https +default_root_object: + description: The object that you want CloudFront to request from your origin (for example, index.html) + when a viewer requests the root URL for your distribution + returned: always + type: string + sample: '' +diff: + description: Difference between previous configuration and new configuration + returned: always + type: dict + sample: {} +domain_name: + description: Domain name of cloudfront distribution + returned: always + type: string + sample: d1vz8pzgurxosf.cloudfront.net +enabled: + description: Whether the cloudfront distribution is enabled or not + returned: always + type: bool + sample: true +http_version: + description: Version of HTTP supported by the distribution + returned: always + type: string + sample: http2 +id: + description: Cloudfront distribution ID + returned: always + type: string + sample: E123456ABCDEFG +in_progress_invalidation_batches: + description: The number of invalidation batches currently in progress + returned: always + type: int + sample: 0 +is_ipv6_enabled: + description: Whether IPv6 is enabled + returned: always + type: bool + sample: true +last_modified_time: + description: Date and time distribution was last modified + returned: always + type: string + sample: '2017-10-13T01:51:12.656000+00:00' +logging: + description: Logging information + returned: always + type: complex + contains: + bucket: + description: S3 bucket logging destination + returned: always + type: string + sample: logs-example-com.s3.amazonaws.com + enabled: + description: Whether logging is enabled + returned: always + type: bool + sample: true + include_cookies: + description: Whether to log cookies + returned: always + type: bool + sample: false + prefix: + description: Prefix added to logging object names + returned: always + type: string + sample: cloudfront/test +origins: + description: Origins in the cloudfront distribution + returned: always + type: complex + contains: + items: + description: List of origins + returned: always + type: complex + contains: + custom_headers: + description: Custom headers passed to the origin + returned: always + type: complex + contains: + quantity: + description: Count of headers + returned: always + type: int + sample: 1 + custom_origin_config: + description: Configuration of the origin + returned: always + type: complex + contains: + http_port: + description: Port on which HTTP is listening + returned: always + type: int + sample: 80 + https_port: + description: Port on which HTTPS is listening + returned: always + type: int + sample: 443 + origin_keepalive_timeout: + description: Keep-alive timeout + returned: always + type: int + sample: 5 + origin_protocol_policy: + description: Policy of which protocols are supported + returned: always + type: string + sample: https-only + origin_read_timeout: + description: Timeout for reads to the origin + returned: always + type: int + sample: 30 + origin_ssl_protocols: + description: SSL protocols allowed by the origin + returned: always + type: complex + contains: + items: + description: List of SSL protocols + returned: always + type: list + sample: + - TLSv1 + - TLSv1.1 + - TLSv1.2 + quantity: + description: Count of SSL protocols + returned: always + type: int + sample: 3 + domain_name: + description: Domain name of the origin + returned: always + type: string + sample: test-origin.example.com + id: + description: ID of the origin + returned: always + type: string + sample: test-origin.example.com + origin_path: + description: Subdirectory to prefix the request from the S3 or HTTP origin + returned: always + type: string + sample: '' + quantity: + description: Count of origins + returned: always + type: int + sample: 1 +price_class: + description: Price class of cloudfront distribution + returned: always + type: string + sample: PriceClass_All +restrictions: + description: Restrictions in use by Cloudfront + returned: always + type: complex + contains: + geo_restriction: + description: Controls the countries in which your content is distributed. + returned: always + type: complex + contains: + quantity: + description: Count of restrictions + returned: always + type: int + sample: 1 + items: + description: List of country codes allowed or disallowed + returned: always + type: list + sample: xy + restriction_type: + description: Type of restriction + returned: always + type: string + sample: blacklist +status: + description: Status of the cloudfront distribution + returned: always + type: string + sample: InProgress +tags: + description: Distribution tags + returned: always + type: dict + sample: + Hello: World +viewer_certificate: + description: Certificate used by cloudfront distribution + returned: always + type: complex + contains: + acm_certificate_arn: + description: ARN of ACM certificate + returned: when certificate comes from ACM + type: string + sample: arn:aws:acm:us-east-1:123456789012:certificate/abcd1234-1234-1234-abcd-123456abcdef + certificate: + description: Reference to certificate + returned: always + type: string + sample: arn:aws:acm:us-east-1:123456789012:certificate/abcd1234-1234-1234-abcd-123456abcdef + certificate_source: + description: Where certificate comes from + returned: always + type: string + sample: acm + minimum_protocol_version: + description: Minimum SSL/TLS protocol supported by this distribution + returned: always + type: string + sample: TLSv1 + ssl_support_method: + description: Support for pre-SNI browsers or not + returned: always + type: string + sample: sni-only +web_acl_id: + description: ID of Web Access Control List (from WAF service) + returned: always + type: string + sample: abcd1234-1234-abcd-abcd-abcd12345678 +''' + +from ansible.module_utils._text import to_text, to_native +from ansible.module_utils.aws.core import AnsibleAWSModule +from ansible.module_utils.aws.cloudfront_facts import CloudFrontFactsServiceManager +from ansible.module_utils.ec2 import get_aws_connection_info +from ansible.module_utils.ec2 import ec2_argument_spec, boto3_conn, compare_aws_tags +from ansible.module_utils.ec2 import camel_dict_to_snake_dict, ansible_dict_to_boto3_tag_list +from ansible.module_utils.ec2 import snake_dict_to_camel_dict, boto3_tag_list_to_ansible_dict +import datetime + +try: + from collections import OrderedDict +except ImportError: + try: + from ordereddict import OrderedDict + except ImportError: + pass # caught by AnsibleAWSModule (as python 2.6 + boto3 => ordereddict is installed) + +try: + import botocore +except ImportError: + pass + + +def change_dict_key_name(dictionary, old_key, new_key): + if old_key in dictionary: + dictionary[new_key] = dictionary.get(old_key) + dictionary.pop(old_key, None) + return dictionary + + +def merge_validation_into_config(config, validated_node, node_name): + if validated_node is not None: + if isinstance(validated_node, dict): + config_node = config.get(node_name) + if config_node is not None: + config_node_items = list(config_node.items()) + else: + config_node_items = [] + config[node_name] = dict(config_node_items + list(validated_node.items())) + if isinstance(validated_node, list): + config[node_name] = list(set(config.get(node_name) + validated_node)) + return config + + +def ansible_list_to_cloudfront_list(list_items=None, include_quantity=True): + if list_items is None: + list_items = [] + if not isinstance(list_items, list): + raise ValueError('Expected a list, got a {0} with value {1}'.format(type(list_items).__name__, str(list_items))) + result = {} + if include_quantity: + result['quantity'] = len(list_items) + if len(list_items) > 0: + result['items'] = list_items + return result + + +def recursive_diff(dict1, dict2): + left = dict((k, v) for (k, v) in dict1.items() if k not in dict2) + right = dict((k, v) for (k, v) in dict2.items() if k not in dict1) + for k in (set(dict1.keys()) & set(dict2.keys())): + if isinstance(dict1[k], dict) and isinstance(dict2[k], dict): + result = recursive_diff(dict1[k], dict2[k]) + if result: + left[k] = result[0] + right[k] = result[1] + elif dict1[k] != dict2[k]: + left[k] = dict1[k] + right[k] = dict2[k] + if left or right: + return left, right + else: + return None + + +def create_distribution(client, module, config, tags): + try: + if not tags: + return client.create_distribution(DistributionConfig=config)['Distribution'] + else: + distribution_config_with_tags = { + 'DistributionConfig': config, + 'Tags': { + 'Items': tags + } + } + return client.create_distribution_with_tags(DistributionConfigWithTags=distribution_config_with_tags)['Distribution'] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Error creating distribution") + + +def delete_distribution(client, module, distribution): + try: + return client.delete_distribution(Id=distribution['Distribution']['Id'], IfMatch=distribution['ETag']) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Error deleting distribution %s" % to_native(distribution['Distribution'])) + + +def update_distribution(client, module, config, distribution_id, e_tag): + try: + return client.update_distribution(DistributionConfig=config, Id=distribution_id, IfMatch=e_tag)['Distribution'] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Error updating distribution to %s" % to_native(config)) + + +def tag_resource(client, module, arn, tags): + try: + return client.tag_resource(Resource=arn, Tags=dict(Items=tags)) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Error tagging resource") + + +def untag_resource(client, module, arn, tag_keys): + try: + return client.untag_resource(Resource=arn, TagKeys=dict(Items=tag_keys)) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Error untagging resource") + + +def list_tags_for_resource(client, module, arn): + try: + response = client.list_tags_for_resource(Resource=arn) + return boto3_tag_list_to_ansible_dict(response.get('Tags').get('Items')) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg="Error listing tags for resource") + + +def update_tags(client, module, existing_tags, valid_tags, purge_tags, arn): + changed = False + to_add, to_remove = compare_aws_tags(existing_tags, valid_tags, purge_tags) + if to_remove: + untag_resource(client, module, arn, to_remove) + changed = True + if to_add: + tag_resource(client, module, arn, ansible_dict_to_boto3_tag_list(to_add)) + changed = True + return changed + + +class CloudFrontValidationManager(object): + """ + Manages Cloudfront validations + """ + + def __init__(self, module): + self.__cloudfront_facts_mgr = CloudFrontFactsServiceManager(module) + self.module = module + self.__default_distribution_enabled = True + self.__default_http_port = 80 + self.__default_https_port = 443 + self.__default_ipv6_enabled = False + self.__default_origin_ssl_protocols = [ + 'TLSv1', + 'TLSv1.1', + 'TLSv1.2' + ] + self.__default_custom_origin_protocol_policy = 'match-viewer' + self.__default_custom_origin_read_timeout = 30 + self.__default_custom_origin_keepalive_timeout = 5 + self.__default_datetime_string = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S.%f') + self.__default_cache_behavior_min_ttl = 0 + self.__default_cache_behavior_max_ttl = 31536000 + self.__default_cache_behavior_default_ttl = 86400 + self.__default_cache_behavior_compress = False + self.__default_cache_behavior_viewer_protocol_policy = 'allow-all' + self.__default_cache_behavior_smooth_streaming = False + self.__default_cache_behavior_forwarded_values_forward_cookies = 'none' + self.__default_cache_behavior_forwarded_values_query_string = True + self.__default_trusted_signers_enabled = False + self.__valid_price_classes = set([ + 'PriceClass_100', + 'PriceClass_200', + 'PriceClass_All' + ]) + self.__valid_origin_protocol_policies = set([ + 'http-only', + 'match-viewer', + 'https-only' + ]) + self.__valid_origin_ssl_protocols = set([ + 'SSLv3', + 'TLSv1', + 'TLSv1.1', + 'TLSv1.2' + ]) + self.__valid_cookie_forwarding = set([ + 'none', + 'whitelist', + 'all' + ]) + self.__valid_viewer_protocol_policies = set([ + 'allow-all', + 'https-only', + 'redirect-to-https' + ]) + self.__valid_methods = set([ + 'GET', + 'HEAD', + 'POST', + 'PUT', + 'PATCH', + 'OPTIONS', + 'DELETE' + ]) + self.__valid_methods_cached_methods = [ + set([ + 'GET', + 'HEAD' + ]), + set([ + 'GET', + 'HEAD', + 'OPTIONS' + ]) + ] + self.__valid_methods_allowed_methods = [ + self.__valid_methods_cached_methods[0], + self.__valid_methods_cached_methods[1], + self.__valid_methods + ] + self.__valid_lambda_function_association_event_types = set([ + 'viewer-request', + 'viewer-response', + 'origin-request', + 'origin-response' + ]) + self.__valid_viewer_certificate_ssl_support_methods = set([ + 'sni-only', + 'vip' + ]) + self.__valid_viewer_certificate_minimum_protocol_versions = set([ + 'SSLv3', + 'TLSv1' + ]) + self.__valid_viewer_certificate_certificate_sources = set([ + 'cloudfront', + 'iam', + 'acm' + ]) + self.__valid_http_versions = set([ + 'http1.1', + 'http2' + ]) + self.__s3_bucket_domain_identifier = '.s3.amazonaws.com' + + def add_missing_key(self, dict_object, key_to_set, value_to_set): + if key_to_set not in dict_object and value_to_set is not None: + dict_object[key_to_set] = value_to_set + return dict_object + + def add_key_else_change_dict_key(self, dict_object, old_key, new_key, value_to_set): + if old_key not in dict_object and value_to_set is not None: + dict_object[new_key] = value_to_set + else: + dict_object = change_dict_key_name(dict_object, old_key, new_key) + return dict_object + + def add_key_else_validate(self, dict_object, key_name, attribute_name, value_to_set, valid_values, to_aws_list=False): + if key_name in dict_object: + self.validate_attribute_with_allowed_values(value_to_set, attribute_name, valid_values) + else: + if to_aws_list: + dict_object[key_name] = ansible_list_to_cloudfront_list(value_to_set) + elif value_to_set is not None: + dict_object[key_name] = value_to_set + return dict_object + + def validate_logging(self, logging): + try: + if logging is None: + return None + valid_logging = {} + if logging and not set(['enabled', 'include_cookies', 'bucket', 'prefix']).issubset(logging): + self.module.fail_json(msg="The logging parameters enabled, include_cookies, bucket and prefix must be specified.") + valid_logging['include_cookies'] = logging.get('include_cookies') + valid_logging['enabled'] = logging.get('enabled') + valid_logging['bucket'] = logging.get('bucket') + valid_logging['prefix'] = logging.get('prefix') + return valid_logging + except Exception as e: + self.module.fail_json_aws(e, msg="Error validating distribution logging") + + def validate_is_list(self, list_to_validate, list_name): + if not isinstance(list_to_validate, list): + self.module.fail_json(msg='%s is of type %s. Must be a list.' % (list_name, type(list_to_validate).__name__)) + + def validate_required_key(self, key_name, full_key_name, dict_object): + if key_name not in dict_object: + self.module.fail_json(msg="%s must be specified." % full_key_name) + + def validate_origins(self, client, config, origins, default_origin_domain_name, + default_origin_path, create_distribution, purge_origins=False): + try: + if origins is None: + if default_origin_domain_name is None and not create_distribution: + if purge_origins: + return None + else: + return ansible_list_to_cloudfront_list(config) + if default_origin_domain_name is not None: + origins = [{ + 'domain_name': default_origin_domain_name, + 'origin_path': default_origin_path or '' + }] + else: + origins = [] + self.validate_is_list(origins, 'origins') + if not origins and default_origin_domain_name is None and create_distribution: + self.module.fail_json(msg="Both origins[] and default_origin_domain_name have not been specified. Please specify at least one.") + all_origins = OrderedDict() + new_domains = list() + for origin in config: + all_origins[origin.get('domain_name')] = origin + for origin in origins: + origin = self.validate_origin(client, all_origins.get(origin.get('domain_name'), {}), origin, default_origin_path) + all_origins[origin['domain_name']] = origin + new_domains.append(origin['domain_name']) + if purge_origins: + for domain in all_origins: + if domain not in new_domains: + del(all_origins[domain]) + return ansible_list_to_cloudfront_list(all_origins.values()) + except Exception as e: + self.module.fail_json_aws(e, msg="Error validating distribution origins") + + def validate_s3_origin_configuration(self, client, existing_config, origin): + if origin['s3_origin_access_identity_enabled'] and existing_config.get('s3_origin_config', {}).get('origin_access_identity'): + return existing_config['s3_origin_config']['origin_access_identity'] + if not origin['s3_origin_access_identity_enabled']: + return None + try: + comment = "Origin Access Identity created by Ansible at %s" % self.__default_datetime_string + cfoai_config = dict(CloudFrontOriginAccessIdentityConfig=dict(CallerReference=self.__default_datetime_string, + Comment=comment)) + oai = client.create_cloud_front_origin_access_identity(**cfoai_config)['CloudFrontOriginAccessIdentity']['Id'] + except Exception as e: + self.module.fail_json_aws(e, msg="Couldn't create Origin Access Identity for id %s" % origin['id']) + return "origin-access-identity/cloudfront/%s" % oai + + def validate_origin(self, client, existing_config, origin, default_origin_path): + try: + origin = self.add_missing_key(origin, 'origin_path', existing_config.get('origin_path', default_origin_path or '')) + self.validate_required_key('origin_path', 'origins[].origin_path', origin) + origin = self.add_missing_key(origin, 'id', existing_config.get('id', self.__default_datetime_string)) + if 'custom_headers' in origin and len(origin.get('custom_headers')) > 0: + for custom_header in origin.get('custom_headers'): + if 'header_name' not in custom_header or 'header_value' not in custom_header: + self.module.fail_json(msg="Both origins[].custom_headers.header_name and origins[].custom_headers.header_value must be specified.") + origin['custom_headers'] = ansible_list_to_cloudfront_list(origin.get('custom_headers')) + else: + origin['custom_headers'] = ansible_list_to_cloudfront_list() + if self.__s3_bucket_domain_identifier in origin.get('domain_name').lower(): + if origin.get("s3_origin_access_identity_enabled") is not None: + s3_origin_config = self.validate_s3_origin_configuration(client, existing_config, origin) + if s3_origin_config: + oai = s3_origin_config + else: + oai = "" + origin["s3_origin_config"] = dict(origin_access_identity=oai) + del(origin["s3_origin_access_identity_enabled"]) + if 'custom_origin_config' in origin: + self.module.fail_json(msg="s3_origin_access_identity_enabled and custom_origin_config are mutually exclusive") + else: + origin = self.add_missing_key(origin, 'custom_origin_config', existing_config.get('custom_origin_config', {})) + custom_origin_config = origin.get('custom_origin_config') + custom_origin_config = self.add_key_else_validate(custom_origin_config, 'origin_protocol_policy', + 'origins[].custom_origin_config.origin_protocol_policy', + self.__default_custom_origin_protocol_policy, self.__valid_origin_protocol_policies) + custom_origin_config = self.add_missing_key(custom_origin_config, 'origin_read_timeout', self.__default_custom_origin_read_timeout) + custom_origin_config = self.add_missing_key(custom_origin_config, 'origin_keepalive_timeout', self.__default_custom_origin_keepalive_timeout) + custom_origin_config = self.add_key_else_change_dict_key(custom_origin_config, 'http_port', 'h_t_t_p_port', self.__default_http_port) + custom_origin_config = self.add_key_else_change_dict_key(custom_origin_config, 'https_port', 'h_t_t_p_s_port', self.__default_https_port) + if custom_origin_config.get('origin_ssl_protocols', {}).get('items'): + custom_origin_config['origin_ssl_protocols'] = custom_origin_config['origin_ssl_protocols']['items'] + if custom_origin_config.get('origin_ssl_protocols'): + self.validate_attribute_list_with_allowed_list(custom_origin_config['origin_ssl_protocols'], 'origins[].origin_ssl_protocols', + self.__valid_origin_ssl_protocols) + else: + custom_origin_config['origin_ssl_protocols'] = self.__default_origin_ssl_protocols + custom_origin_config['origin_ssl_protocols'] = ansible_list_to_cloudfront_list(custom_origin_config['origin_ssl_protocols']) + return origin + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + self.module.fail_json_aws(e, msg="Error validating distribution origin") + + def validate_cache_behaviors(self, config, cache_behaviors, valid_origins, purge_cache_behaviors=False): + try: + if cache_behaviors is None and valid_origins is not None and purge_cache_behaviors is False: + return ansible_list_to_cloudfront_list(config) + all_cache_behaviors = OrderedDict() + # cache behaviors are order dependent so we don't preserve the existing ordering when purge_cache_behaviors + # is true (if purge_cache_behaviors is not true, we can't really know the full new order) + if not purge_cache_behaviors: + for behavior in config: + all_cache_behaviors[behavior['path_pattern']] = behavior + for cache_behavior in cache_behaviors: + valid_cache_behavior = self.validate_cache_behavior(all_cache_behaviors.get(cache_behavior.get('path_pattern'), {}), + cache_behavior, valid_origins) + all_cache_behaviors[cache_behavior['path_pattern']] = valid_cache_behavior + if purge_cache_behaviors: + for target_origin_id in set(all_cache_behaviors.keys()) - set([cb['path_pattern'] for cb in cache_behaviors]): + del(all_cache_behaviors[target_origin_id]) + return ansible_list_to_cloudfront_list(all_cache_behaviors.values()) + except Exception as e: + self.module.fail_json_aws(e, msg="Error validating distribution cache behaviors") + + def validate_cache_behavior(self, config, cache_behavior, valid_origins, is_default_cache=False): + if is_default_cache and cache_behavior is None: + cache_behavior = {} + if cache_behavior is None and valid_origins is not None: + return config + cache_behavior = self.validate_cache_behavior_first_level_keys(config, cache_behavior, valid_origins, is_default_cache) + cache_behavior = self.validate_forwarded_values(config, cache_behavior.get('forwarded_values'), cache_behavior) + cache_behavior = self.validate_allowed_methods(config, cache_behavior.get('allowed_methods'), cache_behavior) + cache_behavior = self.validate_lambda_function_associations(config, cache_behavior.get('lambda_function_associations'), cache_behavior) + cache_behavior = self.validate_trusted_signers(config, cache_behavior.get('trusted_signers'), cache_behavior) + return cache_behavior + + def validate_cache_behavior_first_level_keys(self, config, cache_behavior, valid_origins, is_default_cache): + try: + cache_behavior = self.add_key_else_change_dict_key(cache_behavior, 'min_ttl', 'min_t_t_l', + config.get('min_t_t_l', self.__default_cache_behavior_min_ttl)) + cache_behavior = self.add_key_else_change_dict_key(cache_behavior, 'max_ttl', 'max_t_t_l', + config.get('max_t_t_l', self.__default_cache_behavior_max_ttl)) + cache_behavior = self.add_key_else_change_dict_key(cache_behavior, 'default_ttl', 'default_t_t_l', + config.get('default_t_t_l', self.__default_cache_behavior_default_ttl)) + cache_behavior = self.add_missing_key(cache_behavior, 'compress', config.get('compress', self.__default_cache_behavior_compress)) + target_origin_id = cache_behavior.get('target_origin_id', config.get('target_origin_id')) + if not target_origin_id: + target_origin_id = self.get_first_origin_id_for_default_cache_behavior(valid_origins) + if target_origin_id not in [origin['id'] for origin in valid_origins.get('items', [])]: + if is_default_cache: + cache_behavior_name = 'Default cache behavior' + else: + cache_behavior_name = 'Cache behavior for path %s' % cache_behavior['path_pattern'] + self.module.fail_json(msg="%s has target_origin_id pointing to an origin that does not exist." % + cache_behavior_name) + cache_behavior['target_origin_id'] = target_origin_id + cache_behavior = self.add_key_else_validate(cache_behavior, 'viewer_protocol_policy', 'cache_behavior.viewer_protocol_policy', + config.get('viewer_protocol_policy', + self.__default_cache_behavior_viewer_protocol_policy), + self.__valid_viewer_protocol_policies) + cache_behavior = self.add_missing_key(cache_behavior, 'smooth_streaming', + config.get('smooth_streaming', self.__default_cache_behavior_smooth_streaming)) + return cache_behavior + except Exception as e: + self.module.fail_json_aws(e, msg="Error validating distribution cache behavior first level keys") + + def validate_forwarded_values(self, config, forwarded_values, cache_behavior): + try: + if not forwarded_values: + forwarded_values = dict() + existing_config = config.get('forwarded_values', {}) + headers = forwarded_values.get('headers', existing_config.get('headers', {}).get('items')) + forwarded_values['headers'] = ansible_list_to_cloudfront_list(headers) + if 'cookies' not in forwarded_values: + forward = existing_config.get('cookies', {}).get('forward', self.__default_cache_behavior_forwarded_values_forward_cookies) + forwarded_values['cookies'] = {'forward': forward} + else: + existing_whitelist = existing_config.get('cookies', {}).get('whitelisted_names', {}).get('items') + whitelist = forwarded_values.get('cookies').get('whitelisted_names', existing_whitelist) + if whitelist: + self.validate_is_list(whitelist, 'forwarded_values.whitelisted_names') + forwarded_values['cookies']['whitelisted_names'] = ansible_list_to_cloudfront_list(whitelist) + cookie_forwarding = forwarded_values.get('cookies').get('forward', existing_config.get('cookies', {}).get('forward')) + self.validate_attribute_with_allowed_values(cookie_forwarding, 'cache_behavior.forwarded_values.cookies.forward', + self.__valid_cookie_forwarding) + forwarded_values['cookies']['forward'] = cookie_forwarding + query_string_cache_keys = forwarded_values.get('query_string_cache_keys', existing_config.get('query_string_cache_keys', {}).get('items', [])) + self.validate_is_list(query_string_cache_keys, 'forwarded_values.query_string_cache_keys') + forwarded_values['query_string_cache_keys'] = ansible_list_to_cloudfront_list(query_string_cache_keys) + forwarded_values = self.add_missing_key(forwarded_values, 'query_string', + existing_config.get('query_string', self.__default_cache_behavior_forwarded_values_query_string)) + cache_behavior['forwarded_values'] = forwarded_values + return cache_behavior + except Exception as e: + self.module.fail_json_aws(e, msg="Error validating forwarded values") + + def validate_lambda_function_associations(self, config, lambda_function_associations, cache_behavior): + try: + if lambda_function_associations is not None: + self.validate_is_list(lambda_function_associations, 'lambda_function_associations') + for association in lambda_function_associations: + association = change_dict_key_name(association, 'lambda_function_arn', 'lambda_function_a_r_n') + self.validate_attribute_with_allowed_values(association.get('event_type'), 'cache_behaviors[].lambda_function_associations.event_type', + self.__valid_lambda_function_association_event_types) + cache_behavior['lambda_function_associations'] = ansible_list_to_cloudfront_list(lambda_function_associations) + else: + if 'lambda_function_associations' in config: + cache_behavior['lambda_function_associations'] = config.get('lambda_function_associations') + else: + cache_behavior['lambda_function_associations'] = ansible_list_to_cloudfront_list([]) + return cache_behavior + except Exception as e: + self.module.fail_json_aws(e, msg="Error validating lambda function associations") + + def validate_allowed_methods(self, config, allowed_methods, cache_behavior): + try: + if allowed_methods is not None: + self.validate_required_key('items', 'cache_behavior.allowed_methods.items[]', allowed_methods) + temp_allowed_items = allowed_methods.get('items') + self.validate_is_list(temp_allowed_items, 'cache_behavior.allowed_methods.items') + self.validate_attribute_list_with_allowed_list(temp_allowed_items, 'cache_behavior.allowed_methods.items[]', + self.__valid_methods_allowed_methods) + cached_items = allowed_methods.get('cached_methods') + if 'cached_methods' in allowed_methods: + self.validate_is_list(cached_items, 'cache_behavior.allowed_methods.cached_methods') + self.validate_attribute_list_with_allowed_list(cached_items, 'cache_behavior.allowed_items.cached_methods[]', + self.__valid_methods_cached_methods) + # we don't care if the order of how cloudfront stores the methods differs - preserving existing + # order reduces likelihood of making unnecessary changes + if 'allowed_methods' in config and set(config['allowed_methods']['items']) == set(temp_allowed_items): + cache_behavior['allowed_methods'] = config['allowed_methods'] + else: + cache_behavior['allowed_methods'] = ansible_list_to_cloudfront_list(temp_allowed_items) + + if cached_items and set(cached_items) == set(config.get('allowed_methods', {}).get('cached_methods', {}).get('items', [])): + cache_behavior['allowed_methods']['cached_methods'] = config['allowed_methods']['cached_methods'] + else: + cache_behavior['allowed_methods']['cached_methods'] = ansible_list_to_cloudfront_list(cached_items) + else: + if 'allowed_methods' in config: + cache_behavior['allowed_methods'] = config.get('allowed_methods') + return cache_behavior + except Exception as e: + self.module.fail_json_aws(e, msg="Error validating allowed methods") + + def validate_trusted_signers(self, config, trusted_signers, cache_behavior): + try: + if trusted_signers is None: + trusted_signers = {} + if 'items' in trusted_signers: + valid_trusted_signers = ansible_list_to_cloudfront_list(trusted_signers.get('items')) + else: + valid_trusted_signers = dict(quantity=config.get('quantity', 0)) + if 'items' in config: + valid_trusted_signers = dict(items=config['items']) + valid_trusted_signers['enabled'] = trusted_signers.get('enabled', config.get('enabled', self.__default_trusted_signers_enabled)) + cache_behavior['trusted_signers'] = valid_trusted_signers + return cache_behavior + except Exception as e: + self.module.fail_json_aws(e, msg="Error validating trusted signers") + + def validate_viewer_certificate(self, viewer_certificate): + try: + if viewer_certificate is None: + return None + if viewer_certificate.get('cloudfront_default_certificate') and viewer_certificate.get('ssl_support_method') is not None: + self.module.fail_json(msg="viewer_certificate.ssl_support_method should not be specified with viewer_certificate_cloudfront_default" + + "_certificate set to true.") + self.validate_attribute_with_allowed_values(viewer_certificate.get('ssl_support_method'), 'viewer_certificate.ssl_support_method', + self.__valid_viewer_certificate_ssl_support_methods) + self.validate_attribute_with_allowed_values(viewer_certificate.get('minimum_protocol_version'), 'viewer_certificate.minimum_protocol_version', + self.__valid_viewer_certificate_minimum_protocol_versions) + self.validate_attribute_with_allowed_values(viewer_certificate.get('certificate_source'), 'viewer_certificate.certificate_source', + self.__valid_viewer_certificate_certificate_sources) + viewer_certificate = change_dict_key_name(viewer_certificate, 'cloudfront_default_certificate', 'cloud_front_default_certificate') + viewer_certificate = change_dict_key_name(viewer_certificate, 'ssl_support_method', 's_s_l_support_method') + viewer_certificate = change_dict_key_name(viewer_certificate, 'iam_certificate_id', 'i_a_m_certificate_id') + viewer_certificate = change_dict_key_name(viewer_certificate, 'acm_certificate_arn', 'a_c_m_certificate_arn') + return viewer_certificate + except Exception as e: + self.module.fail_json_aws(e, msg="Error validating viewer certificate") + + def validate_custom_error_responses(self, config, custom_error_responses, purge_custom_error_responses): + try: + if custom_error_responses is None and not purge_custom_error_responses: + return ansible_list_to_cloudfront_list(config) + self.validate_is_list(custom_error_responses, 'custom_error_responses') + result = list() + existing_responses = dict((response['error_code'], response) for response in custom_error_responses) + for custom_error_response in custom_error_responses: + self.validate_required_key('error_code', 'custom_error_responses[].error_code', custom_error_response) + custom_error_response = change_dict_key_name(custom_error_response, 'error_caching_min_ttl', 'error_caching_min_t_t_l') + if 'response_code' in custom_error_response: + custom_error_response['response_code'] = str(custom_error_response['response_code']) + if custom_error_response['error_code'] in existing_responses: + del(existing_responses[custom_error_response['error_code']]) + result.append(custom_error_response) + if not purge_custom_error_responses: + result.extend(existing_responses.values()) + + return ansible_list_to_cloudfront_list(result) + except Exception as e: + self.module.fail_json_aws(e, msg="Error validating custom error responses") + + def validate_restrictions(self, config, restrictions, purge_restrictions=False): + try: + if restrictions is None: + if purge_restrictions: + return None + else: + return config + self.validate_required_key('geo_restriction', 'restrictions.geo_restriction', restrictions) + geo_restriction = restrictions.get('geo_restriction') + self.validate_required_key('restriction_type', 'restrictions.geo_restriction.restriction_type', geo_restriction) + existing_restrictions = config.get('geo_restriction', {}).get(geo_restriction['restriction_type'], {}).get('items', []) + geo_restriction_items = geo_restriction.get('items') + if not purge_restrictions: + geo_restriction_items.extend([rest for rest in existing_restrictions if + rest not in geo_restriction_items]) + valid_restrictions = ansible_list_to_cloudfront_list(geo_restriction_items) + valid_restrictions['restriction_type'] = geo_restriction.get('restriction_type') + return valid_restrictions + except Exception as e: + self.module.fail_json_aws(e, msg="Error validating restrictions") + + def validate_distribution_config_parameters(self, config, default_root_object, ipv6_enabled, http_version, web_acl_id): + try: + config['default_root_object'] = default_root_object or config.get('default_root_object', '') + config['is_i_p_v_6_enabled'] = ipv6_enabled or config.get('i_p_v_6_enabled', self.__default_ipv6_enabled) + if http_version is not None or config.get('http_version'): + self.validate_attribute_with_allowed_values(http_version, 'http_version', self.__valid_http_versions) + config['http_version'] = http_version or config.get('http_version') + if web_acl_id or config.get('web_a_c_l_id'): + config['web_a_c_l_id'] = web_acl_id or config.get('web_a_c_l_id') + return config + except Exception as e: + self.module.fail_json_aws(e, msg="Error validating distribution config parameters") + + def validate_common_distribution_parameters(self, config, enabled, aliases, logging, price_class, purge_aliases=False): + try: + if config is None: + config = {} + if aliases is not None: + if not purge_aliases: + aliases.extend([alias for alias in config.get('aliases', {}).get('items', []) + if alias not in aliases]) + config['aliases'] = ansible_list_to_cloudfront_list(aliases) + if logging is not None: + config['logging'] = self.validate_logging(logging) + config['enabled'] = enabled or config.get('enabled', self.__default_distribution_enabled) + if price_class is not None: + self.validate_attribute_with_allowed_values(price_class, 'price_class', self.__valid_price_classes) + config['price_class'] = price_class + return config + except Exception as e: + self.module.fail_json_aws(e, msg="Error validating common distribution parameters") + + def validate_comment(self, config, comment): + config['comment'] = comment or config.get('comment', "Distribution created by Ansible with datetime stamp " + self.__default_datetime_string) + return config + + def validate_caller_reference(self, caller_reference): + return caller_reference or self.__default_datetime_string + + def get_first_origin_id_for_default_cache_behavior(self, valid_origins): + try: + if valid_origins is not None: + valid_origins_list = valid_origins.get('items') + if valid_origins_list is not None and isinstance(valid_origins_list, list) and len(valid_origins_list) > 0: + return str(valid_origins_list[0].get('id')) + self.module.fail_json(msg="There are no valid origins from which to specify a target_origin_id for the default_cache_behavior configuration.") + except Exception as e: + self.module.fail_json_aws(e, msg="Error getting first origin_id for default cache behavior") + + def validate_attribute_list_with_allowed_list(self, attribute_list, attribute_list_name, allowed_list): + try: + self.validate_is_list(attribute_list, attribute_list_name) + if (isinstance(allowed_list, list) and set(attribute_list) not in allowed_list or + isinstance(allowed_list, set) and not set(allowed_list).issuperset(attribute_list)): + self.module.fail_json(msg='The attribute list {0} must be one of [{1}]'.format(attribute_list_name, ' '.join(str(a) for a in allowed_list))) + except Exception as e: + self.module.fail_json_aws(e, msg="Error validating attribute list with allowed value list") + + def validate_attribute_with_allowed_values(self, attribute, attribute_name, allowed_list): + if attribute is not None and attribute not in allowed_list: + self.module.fail_json(msg='The attribute {0} must be one of [{1}]'.format(attribute_name, ' '.join(str(a) for a in allowed_list))) + + def validate_distribution_from_caller_reference(self, caller_reference): + try: + distributions = self.__cloudfront_facts_mgr.list_distributions(False) + distribution_name = 'Distribution' + distribution_config_name = 'DistributionConfig' + distribution_ids = [dist.get('Id') for dist in distributions] + for distribution_id in distribution_ids: + config = self.__cloudfront_facts_mgr.get_distribution(distribution_id) + distribution = config.get(distribution_name) + if distribution is not None: + distribution_config = distribution.get(distribution_config_name) + if distribution_config is not None and distribution_config.get('CallerReference') == caller_reference: + distribution['DistributionConfig'] = distribution_config + return distribution + + except Exception as e: + self.module.fail_json_aws(e, msg="Error validating distribution from caller reference") + + def validate_distribution_from_aliases_caller_reference(self, distribution_id, aliases, caller_reference): + try: + if caller_reference is not None: + return self.validate_distribution_from_caller_reference(caller_reference) + else: + if aliases: + distribution_id = self.validate_distribution_id_from_alias(aliases) + if distribution_id: + return self.__cloudfront_facts_mgr.get_distribution(distribution_id) + return None + except Exception as e: + self.module.fail_json_aws(e, msg="Error validating distribution_id from alias, aliases and caller reference") + + def validate_distribution_id_from_alias(self, aliases): + distributions = self.__cloudfront_facts_mgr.list_distributions(False) + if distributions: + for distribution in distributions: + distribution_aliases = distribution.get('Aliases', {}).get('Items', []) + if set(aliases) & set(distribution_aliases): + return distribution['Id'] + return None + + def wait_until_processed(self, client, wait_timeout, distribution_id, caller_reference): + if distribution_id is None: + distribution_id = self.validate_distribution_id_from_caller_reference(caller_reference=caller_reference) + + try: + waiter = client.get_waiter('distribution_deployed') + attempts = 1 + int(wait_timeout / 60) + waiter.wait(Id=distribution_id, WaiterConfig={'MaxAttempts': attempts}) + except botocore.exceptions.WaiterError as e: + self.module.fail_json(msg="Timeout waiting for cloudfront action. Waited for {0} seconds before timeout. " + "Error: {1}".format(to_text(wait_timeout), to_native(e))) + + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + self.module.fail_json_aws(e, msg="Error getting distribution {0}".format(distribution_id)) + + +def main(): + argument_spec = ec2_argument_spec() + + argument_spec.update(dict( + state=dict(choices=['present', 'absent'], default='present'), + caller_reference=dict(), + comment=dict(), + distribution_id=dict(), + e_tag=dict(), + tags=dict(type='dict', default={}), + purge_tags=dict(type='bool', default=False), + alias=dict(), + aliases=dict(type='list', default=[]), + purge_aliases=dict(type='bool', default=False), + default_root_object=dict(), + origins=dict(type='list'), + purge_origins=dict(type='bool', default=False), + default_cache_behavior=dict(type='dict'), + cache_behaviors=dict(type='list'), + purge_cache_behaviors=dict(type='bool', default=False), + custom_error_responses=dict(type='list'), + purge_custom_error_responses=dict(type='bool', default=False), + logging=dict(type='dict'), + price_class=dict(), + enabled=dict(type='bool'), + viewer_certificate=dict(type='dict'), + restrictions=dict(type='dict'), + web_acl_id=dict(), + http_version=dict(), + ipv6_enabled=dict(type='bool'), + default_origin_domain_name=dict(), + default_origin_path=dict(), + wait=dict(default=False, type='bool'), + wait_timeout=dict(default=1800, type='int') + )) + + result = {} + changed = True + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=False, + mutually_exclusive=[ + ['distribution_id', 'alias'], + ['default_origin_domain_name', 'distribution_id'], + ['default_origin_domain_name', 'alias'], + ] + ) + + region, ec2_url, aws_connect_kwargs = get_aws_connection_info(module, boto3=True) + client = boto3_conn(module, conn_type='client', resource='cloudfront', region=region, endpoint=ec2_url, **aws_connect_kwargs) + + validation_mgr = CloudFrontValidationManager(module) + + state = module.params.get('state') + caller_reference = module.params.get('caller_reference') + comment = module.params.get('comment') + e_tag = module.params.get('e_tag') + tags = module.params.get('tags') + purge_tags = module.params.get('purge_tags') + distribution_id = module.params.get('distribution_id') + alias = module.params.get('alias') + aliases = module.params.get('aliases') + purge_aliases = module.params.get('purge_aliases') + default_root_object = module.params.get('default_root_object') + origins = module.params.get('origins') + purge_origins = module.params.get('purge_origins') + default_cache_behavior = module.params.get('default_cache_behavior') + cache_behaviors = module.params.get('cache_behaviors') + purge_cache_behaviors = module.params.get('purge_cache_behaviors') + custom_error_responses = module.params.get('custom_error_responses') + purge_custom_error_responses = module.params.get('purge_custom_error_responses') + logging = module.params.get('logging') + price_class = module.params.get('price_class') + enabled = module.params.get('enabled') + viewer_certificate = module.params.get('viewer_certificate') + restrictions = module.params.get('restrictions') + purge_restrictions = module.params.get('purge_restrictions') + web_acl_id = module.params.get('web_acl_id') + http_version = module.params.get('http_version') + ipv6_enabled = module.params.get('ipv6_enabled') + default_origin_domain_name = module.params.get('default_origin_domain_name') + default_origin_path = module.params.get('default_origin_path') + wait = module.params.get('wait') + wait_timeout = module.params.get('wait_timeout') + + if alias and alias not in aliases: + aliases.append(alias) + + distribution = validation_mgr.validate_distribution_from_aliases_caller_reference(distribution_id, aliases, caller_reference) + + update = state == 'present' and distribution + create = state == 'present' and not distribution + delete = state == 'absent' and distribution + + if not (update or create or delete): + module.exit_json(changed=False) + + if update or delete: + config = distribution['Distribution']['DistributionConfig'] + e_tag = distribution['ETag'] + distribution_id = distribution['Distribution']['Id'] + else: + config = dict() + if update: + config = camel_dict_to_snake_dict(config, reversible=True) + + if create or update: + config = validation_mgr.validate_common_distribution_parameters(config, enabled, aliases, logging, price_class, purge_aliases) + config = validation_mgr.validate_distribution_config_parameters(config, default_root_object, ipv6_enabled, http_version, web_acl_id) + config['origins'] = validation_mgr.validate_origins(client, config.get('origins', {}).get('items', []), origins, default_origin_domain_name, + default_origin_path, create, purge_origins) + config['cache_behaviors'] = validation_mgr.validate_cache_behaviors(config.get('cache_behaviors', {}).get('items', []), + cache_behaviors, config['origins'], purge_cache_behaviors) + config['default_cache_behavior'] = validation_mgr.validate_cache_behavior(config.get('default_cache_behavior', {}), + default_cache_behavior, config['origins'], True) + config['custom_error_responses'] = validation_mgr.validate_custom_error_responses(config.get('custom_error_responses', {}).get('items', []), + custom_error_responses, purge_custom_error_responses) + valid_restrictions = validation_mgr.validate_restrictions(config.get('restrictions', {}), restrictions, purge_restrictions) + if valid_restrictions: + config['restrictions'] = valid_restrictions + valid_viewer_certificate = validation_mgr.validate_viewer_certificate(viewer_certificate) + config = merge_validation_into_config(config, valid_viewer_certificate, 'viewer_certificate') + config = validation_mgr.validate_comment(config, comment) + config = snake_dict_to_camel_dict(config, capitalize_first=True) + + if create: + config['CallerReference'] = validation_mgr.validate_caller_reference(caller_reference) + result = create_distribution(client, module, config, ansible_dict_to_boto3_tag_list(tags)) + result = camel_dict_to_snake_dict(result) + result['tags'] = list_tags_for_resource(client, module, result['arn']) + + if delete: + if config['Enabled']: + config['Enabled'] = False + result = update_distribution(client, module, config, distribution_id, e_tag) + validation_mgr.wait_until_processed(client, wait_timeout, distribution_id, config.get('CallerReference')) + distribution = validation_mgr.validate_distribution_from_aliases_caller_reference(distribution_id, aliases, caller_reference) + # e_tag = distribution['ETag'] + result = delete_distribution(client, module, distribution) + + if update: + changed = config != distribution['Distribution']['DistributionConfig'] + if changed: + result = update_distribution(client, module, config, distribution_id, e_tag) + else: + result = distribution['Distribution'] + existing_tags = list_tags_for_resource(client, module, result['ARN']) + distribution['Distribution']['DistributionConfig']['tags'] = existing_tags + changed |= update_tags(client, module, existing_tags, tags, purge_tags, result['ARN']) + result = camel_dict_to_snake_dict(result) + result['distribution_config']['tags'] = config['tags'] = list_tags_for_resource(client, module, result['arn']) + result['diff'] = dict() + diff = recursive_diff(distribution['Distribution']['DistributionConfig'], config) + if diff: + result['diff']['before'] = diff[0] + result['diff']['after'] = diff[1] + + if wait and (create or update): + validation_mgr.wait_until_processed(client, wait_timeout, distribution_id, config.get('CallerReference')) + + if 'distribution_config' in result: + result.update(result['distribution_config']) + del(result['distribution_config']) + + module.exit_json(changed=changed, **result) + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/modules/cloud/amazon/cloudfront_facts.py b/lib/ansible/modules/cloud/amazon/cloudfront_facts.py index da12f3530ae..43756edb33f 100644 --- a/lib/ansible/modules/cloud/amazon/cloudfront_facts.py +++ b/lib/ansible/modules/cloud/amazon/cloudfront_facts.py @@ -670,5 +670,6 @@ def main(): result['cloudfront'].update(facts) module.exit_json(msg="Retrieved cloudfront facts.", ansible_facts=result) + if __name__ == '__main__': main() diff --git a/test/integration/targets/cloudfront_distribution/aliases b/test/integration/targets/cloudfront_distribution/aliases new file mode 100644 index 00000000000..4ef4b2067d0 --- /dev/null +++ b/test/integration/targets/cloudfront_distribution/aliases @@ -0,0 +1 @@ +cloud/aws diff --git a/test/integration/targets/cloudfront_distribution/defaults/main.yml b/test/integration/targets/cloudfront_distribution/defaults/main.yml new file mode 100644 index 00000000000..ce7d1ebc417 --- /dev/null +++ b/test/integration/targets/cloudfront_distribution/defaults/main.yml @@ -0,0 +1,37 @@ +cloudfront_hostname: "{{ resource_prefix | lower }}01" +# Use a domain that has a wildcard DNS +cloudfront_alias: "{{ cloudfront_hostname | lower }}.github.io" + +cloudfront_test_cache_behaviors: + - path_pattern: /test/path + forwarded_values: + headers: + - Host + allowed_methods: + items: + - GET + - HEAD + - POST + - PATCH + - PUT + - OPTIONS + - DELETE + cached_methods: + - GET + - HEAD + - path_pattern: /another/path + forwarded_values: + cookies: + forward: whitelist + whitelisted_names: + - my_header + query_string: yes + query_string_cache_keys: + - whatever + allowed_methods: + items: + - GET + - HEAD + cached_methods: + - GET + - HEAD diff --git a/test/integration/targets/cloudfront_distribution/meta/main.yml b/test/integration/targets/cloudfront_distribution/meta/main.yml new file mode 100644 index 00000000000..1f64f1169a9 --- /dev/null +++ b/test/integration/targets/cloudfront_distribution/meta/main.yml @@ -0,0 +1,3 @@ +dependencies: + - prepare_tests + - setup_ec2 diff --git a/test/integration/targets/cloudfront_distribution/tasks/main.yml b/test/integration/targets/cloudfront_distribution/tasks/main.yml new file mode 100644 index 00000000000..5a26adb7232 --- /dev/null +++ b/test/integration/targets/cloudfront_distribution/tasks/main.yml @@ -0,0 +1,386 @@ +- block: + - name: make sure resource prefix is lowercase + set_fact: + test_identifier: "{{ resource_prefix | lower }}" + + - name: set yaml anchor + set_fact: + aws_connection_info: &aws_connection_info + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + no_log: yes + + - name: create cloudfront distribution using defaults + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ cloudfront_hostname }}-origin.example.com" + id: "{{ cloudfront_hostname }}-origin.example.com" + default_cache_behavior: + target_origin_id: "{{ cloudfront_hostname }}-origin.example.com" + state: present + purge_origins: yes + <<: *aws_connection_info + + - name: re-run cloudfront distribution with same defaults + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ cloudfront_hostname }}-origin.example.com" + state: present + <<: *aws_connection_info + register: cf_dist_no_update + + - name: ensure distribution was not updated + assert: + that: + - not cf_dist_no_update.changed + + - name: update origin http port + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ cloudfront_hostname }}-origin.example.com" + custom_origin_config: + http_port: 8080 + state: present + <<: *aws_connection_info + register: update_origin_http_port + + - name: ensure http port was updated + assert: + that: + - update_origin_http_port.changed + + - name: set a random comment + set_fact: + comment: "{{'ABCDEFabcdef123456'|shuffle|join }}" + + - name: update comment + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + comment: "{{ comment }}" + state: present + <<: *aws_connection_info + register: cf_comment + + - name: ensure comment was updated + assert: + that: + - cf_comment.changed + - 'cf_comment.comment == comment' + + - name: create second origin + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ test_identifier }}2.example.com" + id: "{{ test_identifier }}2.example.com" + state: present + wait: yes + <<: *aws_connection_info + register: cf_add_origin + + - name: ensure origin was added + assert: + that: + - cf_add_origin.origins.quantity == 2 + - cf_add_origin.changed + + - name: re-run second origin + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ cloudfront_hostname }}-origin.example.com" + custom_origin_config: + http_port: 8080 + - domain_name: "{{ test_identifier }}2.example.com" + wait: yes + state: present + <<: *aws_connection_info + register: cf_rerun_second_origin + + - name: ensure nothing changed after re-run + assert: + that: + - cf_rerun_second_origin.origins.quantity == 2 + - not cf_rerun_second_origin.changed + + - name: run with origins in reverse order + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ test_identifier }}2.example.com" + - domain_name: "{{ cloudfront_hostname }}-origin.example.com" + custom_origin_config: + http_port: 8080 + state: present + <<: *aws_connection_info + register: cf_rerun_second_origin_reversed + + - name: ensure nothing changed after reversed re-run + assert: + that: + - cf_rerun_second_origin_reversed.origins.quantity == 2 + - not cf_rerun_second_origin_reversed.changed + + + - name: purge first origin + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ test_identifier }}2.example.com" + default_cache_behavior: + target_origin_id: "{{ test_identifier }}2.example.com" + purge_origins: yes + state: present + <<: *aws_connection_info + register: cf_purge_origin + + - name: ensure origin was removed + assert: + that: + - cf_purge_origin.origins.quantity == 1 + - cf_purge_origin.changed + + - name: add tags to existing distribution + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ test_identifier }}2.example.com" + tags: + Name: "{{ cloudfront_alias }}" + Another: tag + state: present + <<: *aws_connection_info + register: cf_add_tags + + - name: ensure tags were added + assert: + that: + - cf_add_tags.changed + - cf_add_tags.tags|length == 2 + + - name: delete distribution + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + enabled: no + wait: yes + state: absent + <<: *aws_connection_info + + - name: create distribution with tags + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ test_identifier }}2.example.com" + id: "{{ test_identifier }}2.example.com" + tags: + Name: "{{ cloudfront_alias }}" + Another: tag + state: present + <<: *aws_connection_info + register: cf_second_distribution + + - name: ensure tags were set on creation + assert: + that: + - cf_second_distribution.changed + - cf_second_distribution.tags|length == 2 + - "'Name' in cf_second_distribution.tags" + + - name: re-run create distribution with same tags and purge_tags + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ test_identifier }}2.example.com" + id: "{{ test_identifier }}2.example.com" + tags: + Name: "{{ cloudfront_alias }}" + Another: tag + purge_tags: yes + state: present + <<: *aws_connection_info + register: rerun_with_purge_tags + + - name: ensure that re-running didn't change + assert: + that: + - not rerun_with_purge_tags.changed + + - name: add new tag to distribution + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ test_identifier }}2.example.com" + tags: + Third: thing + purge_tags: no + state: present + <<: *aws_connection_info + register: update_with_new_tag + + - name: ensure tags are correct + assert: + that: + - update_with_new_tag.changed + - "'Third' in update_with_new_tag.tags" + - "'Another' in update_with_new_tag.tags" + + - name: create some cache behaviors + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ test_identifier }}2.example.com" + cache_behaviors: "{{ cloudfront_test_cache_behaviors }}" + state: present + <<: *aws_connection_info + register: add_cache_behaviors + + - name: reverse some cache behaviors + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ test_identifier }}2.example.com" + cache_behaviors: "{{ cloudfront_test_cache_behaviors|reverse|list }}" + state: present + <<: *aws_connection_info + register: reverse_cache_behaviors + + - name: check that reversing cache behaviors changes nothing when purge_cache_behaviors unset + assert: + that: + - not reverse_cache_behaviors.changed + - reverse_cache_behaviors.cache_behaviors|length == 2 + + - name: reverse some cache behaviors properly + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ test_identifier }}2.example.com" + cache_behaviors: "{{ cloudfront_test_cache_behaviors|reverse|list }}" + purge_cache_behaviors: yes + state: present + <<: *aws_connection_info + register: reverse_cache_behaviors_with_purge + + - name: check that reversing cache behaviors changes nothing when purge_cache_behaviors unset + assert: + that: + - reverse_cache_behaviors_with_purge.changed + - reverse_cache_behaviors_with_purge.cache_behaviors|length == 2 + + - name: update origin that changes target id (failure expected) + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ test_identifier }}3.example.com" + id: "{{ test_identifier }}3.example.com" + purge_origins: yes + state: present + <<: *aws_connection_info + register: remove_origin_in_use + ignore_errors: yes + + - name: check that removing in use origin fails + assert: + that: + - remove_origin_in_use.failed + + # FIXME: This currently fails due to AWS side problems + # not clear whether to hope they fix or prevent this issue from happening + #- name: update origin and update cache behavior to point to new origin + # cloudfront_distribution: + # alias: "{{ cloudfront_alias }}" + # origins: + # - domain_name: "{{ test_identifier }}3.example.com" + # id: "{{ test_identifier }}3.example.com" + # cache_behaviors: + # - path_pattern: /test/path + # target_origin_id: "{{ test_identifier }}3.example.com" + # - path_pattern: /another/path + # target_origin_id: "{{ test_identifier }}3.example.com" + # state: present + # aws_access_key: "{{ aws_access_key|default(omit) }}" + # aws_secret_key: "{{ aws_secret_key|default(omit) }}" + # security_token: "{{ security_token|default(omit) }}" + # profile: "{{ profile|default(omit) }}" + # register: update_cache_behaviors in use + + - name: create an s3 bucket for next test + aws_s3: + bucket: "{{ test_identifier }}-bucket" + mode: create + <<: *aws_connection_info + + - name: update origin to point to the s3 bucket + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ test_identifier }}-bucket.{{ aws_region }}.s3.amazonaws.com" + id: "{{ test_identifier }}3.example.com" + s3_origin_access_identity_enabled: yes + state: present + <<: *aws_connection_info + register: update_origin_to_s3 + + - name: check that s3 origin access is in result + assert: + that: + - item.s3_origin_config.origin_access_identity.startswith('origin-access-identity/cloudfront/') + when: "'s3_origin_config' in item" + loop: "{{ update_origin_to_s3.origins['items'] }}" + + - name: update origin to remove s3 origin access identity + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ test_identifier }}-bucket.{{ aws_region }}.s3.amazonaws.com" + id: "{{ test_identifier }}3.example.com" + s3_origin_access_identity_enabled: no + state: present + <<: *aws_connection_info + register: update_origin_to_s3_without_origin_access + + - name: check that s3 origin access is not in result + assert: + that: + - not item.s3_origin_config.origin_access_identity + when: "'s3_origin_config' in item" + loop: "{{ update_origin_to_s3_without_origin_access.origins['items'] }}" + + - name: delete the s3 bucket + aws_s3: + bucket: "{{ test_identifier }}-bucket" + mode: delete + <<: *aws_connection_info + + - name: update origin to remove s3 origin access identity + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + origins: + - domain_name: "{{ test_identifier }}-bucket.{{ aws_region }}.s3.amazonaws.com" + id: "{{ test_identifier }}3.example.com" + s3_origin_access_identity_enabled: yes + custom_origin_config: + origin_protocol_policy: 'http-only' + state: present + <<: *aws_connection_info + register: update_origin_to_s3_with_origin_access_and_with_custom_origin_config + ignore_errors: True + + - name: check that custom origin with origin access identity fails + assert: + that: + - update_origin_to_s3_with_origin_access_and_with_custom_origin_config.failed + + always: + # TEARDOWN STARTS HERE + - name: clean up cloudfront distribution + cloudfront_distribution: + alias: "{{ cloudfront_alias }}" + enabled: no + wait: yes + state: absent + <<: *aws_connection_info