From af675610e8c2ab53286c1c3194b88d0aa62006af Mon Sep 17 00:00:00 2001 From: Stefan Horning Date: Thu, 14 Apr 2022 10:28:44 +0200 Subject: [PATCH] New aws_api_gateway_domain module for adding custom domains (#44) New aws_api_gateway_domain module for adding custom domains SUMMARY New module to setup a custom domain for AWS API Gateway services. ISSUE TYPE New Module Pull Request COMPONENT NAME aws_api_gateway_domain ADDITIONAL INFORMATION Complements already existing aws_api_gateway module to also allow custom domain setup. Opened here as suggested in ansible PR ansible/ansible#68709 Reviewed-by: Stefan Horning Reviewed-by: Jill R Reviewed-by: Sandra McCann Reviewed-by: Markus Bergholz Reviewed-by: Alina Buzachis --- aws_api_gateway_domain.py | 333 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 aws_api_gateway_domain.py diff --git a/aws_api_gateway_domain.py b/aws_api_gateway_domain.py new file mode 100644 index 00000000000..a8a04295dae --- /dev/null +++ b/aws_api_gateway_domain.py @@ -0,0 +1,333 @@ +#!/usr/bin/python +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: aws_api_gateway_domain +short_description: Manage AWS API Gateway custom domains +description: + - Manages API Gateway custom domains for API GW Rest APIs. + - AWS API Gateway custom domain setups use CloudFront behind the scenes. + So you will get a CloudFront distribution as a result, configured to be aliased with your domain. +version_added: '3.3.0' +author: + - 'Stefan Horning (@stefanhorning)' +options: + domain_name: + description: + - Domain name you want to use for your API GW deployment. + required: true + type: str + certificate_arn: + description: + - AWS Certificate Manger (ACM) TLS certificate ARN. + type: str + required: true + security_policy: + description: + - Set allowed TLS versions through AWS defined policies. Currently only C(TLS_1_0) and C(TLS_1_2) are available. + default: TLS_1_2 + choices: ['TLS_1_0', 'TLS_1_2'] + type: str + endpoint_type: + description: + - API endpoint configuration for domain. Use EDGE for edge-optimized endpoint, or use C(REGIONAL) or C(PRIVATE). + default: EDGE + choices: ['EDGE', 'REGIONAL', 'PRIVATE'] + type: str + domain_mappings: + description: + - Map your domain base paths to your API GW REST APIs, that you previously created. Use provided ID of the API setup and the release stage. + - "domain_mappings should be a list of dictionaries containing three keys: base_path, rest_api_id and stage." + - "Example: I([{ base_path: v1, rest_api_id: abc123, stage: production }])" + - if you want base path to be just I(/) omit the param completely or set it to empty string. + required: true + type: list + elements: dict + state: + description: + - Create or delete custom domain setup. + default: present + choices: [ 'present', 'absent' ] + type: str +extends_documentation_fragment: + - amazon.aws.aws + - amazon.aws.ec2 +notes: + - Does not create a DNS entry on Route53, for that use the route53 module. + - Only supports TLS certificates from AWS ACM that can just be referenced by the ARN, while the AWS API still offers (deprecated) + options to add own Certificates. +''' + +EXAMPLES = ''' +- name: Setup endpoint for a custom domain for your API Gateway HTTP API + community.aws.aws_api_gateway_domain: + domain_name: myapi.foobar.com + certificate_arn: 'arn:aws:acm:us-east-1:1231123123:certificate/8bd89412-abc123-xxxxx' + security_policy: TLS_1_2 + endpoint_type: EDGE + domain_mappings: + - { rest_api_id: abc123, stage: production } + state: present + register: api_gw_domain_result + +- name: Create a DNS record for your custom domain on route 53 (using route53 module) + community.aws.route53: + record: myapi.foobar.com + value: "{{ api_gw_domain_result.response.domain.distribution_domain_name }}" + type: A + alias: true + zone: foobar.com + alias_hosted_zone_id: "{{ api_gw_domain_result.response.domain.distribution_hosted_zone_id }}" + command: create +''' + +RETURN = ''' +response: + description: The data returned by create_domain_name (or update and delete) and create_base_path_mapping methods by boto3. + returned: success + type: dict + sample: + domain: + { + domain_name: mydomain.com, + certificate_arn: 'arn:aws:acm:xxxxxx', + distribution_domain_name: xxxx.cloudfront.net, + distribution_hosted_zone_id: ABC123123, + endpoint_configuration: { types: ['EDGE'] }, + domain_name_status: 'AVAILABLE', + security_policy: TLS_1_2, + tags: {} + } + path_mappings: [ + { base_path: '(empty)', rest_api_id: 'abcd123', stage: 'production' } + ] +''' + +try: + from botocore.exceptions import ClientError, BotoCoreError, EndpointConnectionError +except ImportError: + pass # caught by imported AnsibleAWSModule + +import copy + +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule, is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict, snake_dict_to_camel_dict + + +def get_domain(module, client): + domain_name = module.params.get('domain_name') + result = {} + try: + result['domain'] = get_domain_name(client, domain_name) + result['path_mappings'] = get_domain_mappings(client, domain_name) + except is_boto3_error_code('NotFoundException'): + return None + except (ClientError, BotoCoreError, EndpointConnectionError) as e: # pylint: disable=duplicate-except + module.fail_json_aws(e, msg="getting API GW domain") + return camel_dict_to_snake_dict(result) + + +def create_domain(module, client): + path_mappings = module.params.get('domain_mappings', []) + domain_name = module.params.get('domain_name') + result = {'domain': {}, 'path_mappings': []} + + try: + result['domain'] = create_domain_name( + module, + client, + domain_name, + module.params.get('certificate_arn'), + module.params.get('endpoint_type'), + module.params.get('security_policy') + ) + + for mapping in path_mappings: + base_path = mapping.get('base_path', '') + rest_api_id = mapping.get('rest_api_id') + stage = mapping.get('stage') + if rest_api_id is None or stage is None: + module.fail_json('Every domain mapping needs a rest_api_id and stage name') + + result['path_mappings'].append(add_domain_mapping(client, domain_name, base_path, rest_api_id, stage)) + + except (ClientError, BotoCoreError, EndpointConnectionError) as e: + module.fail_json_aws(e, msg="creating API GW domain") + return camel_dict_to_snake_dict(result) + + +def update_domain(module, client, existing_domain): + domain_name = module.params.get('domain_name') + result = existing_domain + result['updated'] = False + + domain = existing_domain.get('domain') + # Compare only relevant set of domain arguments. + # As get_domain_name gathers all kind of state information that can't be set anyways. + # Also this module doesn't support custom TLS cert setup params as they are kind of deprecated already and would increase complexity. + existing_domain_settings = { + 'certificate_arn': domain.get('certificate_arn'), + 'security_policy': domain.get('security_policy'), + 'endpoint_type': domain.get('endpoint_configuration').get('types')[0] + } + specified_domain_settings = { + 'certificate_arn': module.params.get('certificate_arn'), + 'security_policy': module.params.get('security_policy'), + 'endpoint_type': module.params.get('endpoint_type') + } + + if specified_domain_settings != existing_domain_settings: + try: + result['domain'] = update_domain_name(client, domain_name, **snake_dict_to_camel_dict(specified_domain_settings)) + result['updated'] = True + except (ClientError, BotoCoreError, EndpointConnectionError) as e: + module.fail_json_aws(e, msg="updating API GW domain") + + existing_mappings = copy.deepcopy(existing_domain.get('path_mappings', [])) + # Cleanout `base_path: "(none)"` elements from dicts as those won't match with specified mappings + for mapping in existing_mappings: + if mapping.get('base_path', 'missing') == '(none)': + mapping.pop('base_path') + + specified_mappings = copy.deepcopy(module.params.get('domain_mappings', [])) + # Cleanout `base_path: ""` elements from dicts as those won't match with existing mappings + for mapping in specified_mappings: + if mapping.get('base_path', 'missing') == '': + mapping.pop('base_path') + + if specified_mappings != existing_mappings: + try: + # When lists missmatch delete all existing mappings before adding new ones as specified + for mapping in existing_domain.get('path_mappings', []): + delete_domain_mapping(client, domain_name, mapping['base_path']) + for mapping in module.params.get('domain_mappings', []): + result['path_mappings'] = add_domain_mapping( + client, domain_name, mapping.get('base_path', ''), mapping.get('rest_api_id'), mapping.get('stage') + ) + result['updated'] = True + except (ClientError, BotoCoreError, EndpointConnectionError) as e: + module.fail_json_aws(e, msg="updating API GW domain mapping") + + return camel_dict_to_snake_dict(result) + + +def delete_domain(module, client): + domain_name = module.params.get('domain_name') + try: + result = delete_domain_name(client, domain_name) + except (ClientError, BotoCoreError, EndpointConnectionError) as e: + module.fail_json_aws(e, msg="deleting API GW domain") + return camel_dict_to_snake_dict(result) + + +retry_params = {"tries": 10, "delay": 5, "backoff": 1.2} + + +@AWSRetry.backoff(**retry_params) +def get_domain_name(client, domain_name): + return client.get_domain_name(domainName=domain_name) + + +@AWSRetry.backoff(**retry_params) +def get_domain_mappings(client, domain_name): + return client.get_base_path_mappings(domainName=domain_name, limit=200).get('items', []) + + +@AWSRetry.backoff(**retry_params) +def create_domain_name(module, client, domain_name, certificate_arn, endpoint_type, security_policy): + endpoint_configuration = {'types': [endpoint_type]} + + if endpoint_type == 'EDGE': + return client.create_domain_name( + domainName=domain_name, + certificateArn=certificate_arn, + endpointConfiguration=endpoint_configuration, + securityPolicy=security_policy + ) + else: + # Use regionalCertificateArn for regional domain deploys + return client.create_domain_name( + domainName=domain_name, + regionalCertificateArn=certificate_arn, + endpointConfiguration=endpoint_configuration, + securityPolicy=security_policy + ) + + +@AWSRetry.backoff(**retry_params) +def add_domain_mapping(client, domain_name, base_path, rest_api_id, stage): + return client.create_base_path_mapping(domainName=domain_name, basePath=base_path, restApiId=rest_api_id, stage=stage) + + +@AWSRetry.backoff(**retry_params) +def update_domain_name(client, domain_name, **kwargs): + patch_operations = [] + + for key, value in kwargs.items(): + path = "/" + key + if key == "endpointType": + continue + patch_operations.append({"op": "replace", "path": path, "value": value}) + + return client.update_domain_name(domainName=domain_name, patchOperations=patch_operations) + + +@AWSRetry.backoff(**retry_params) +def delete_domain_name(client, domain_name): + return client.delete_domain_name(domainName=domain_name) + + +@AWSRetry.backoff(**retry_params) +def delete_domain_mapping(client, domain_name, base_path): + return client.delete_base_path_mapping(domainName=domain_name, basePath=base_path) + + +def main(): + argument_spec = dict( + domain_name=dict(type='str', required=True), + certificate_arn=dict(type='str', required=True), + security_policy=dict(type='str', default='TLS_1_2', choices=['TLS_1_0', 'TLS_1_2']), + endpoint_type=dict(type='str', default='EDGE', choices=['EDGE', 'REGIONAL', 'PRIVATE']), + domain_mappings=dict(type='list', required=True, elements='dict'), + state=dict(type='str', default='present', choices=['present', 'absent']) + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=False + ) + + client = module.client('apigateway') + + state = module.params.get('state') + changed = False + + if state == "present": + existing_domain = get_domain(module, client) + if existing_domain is not None: + result = update_domain(module, client, existing_domain) + changed = result['updated'] + else: + result = create_domain(module, client) + changed = True + if state == "absent": + result = delete_domain(module, client) + changed = True + + exit_args = {"changed": changed} + + if result is not None: + exit_args['response'] = result + + module.exit_json(**exit_args) + + +if __name__ == '__main__': + main()