Skip to content

Commit

Permalink
New aws_api_gateway_domain module for adding custom domains (ansible-…
Browse files Browse the repository at this point in the history
…collections#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 <None>
Reviewed-by: Jill R <None>
Reviewed-by: Sandra McCann <[email protected]>
Reviewed-by: Markus Bergholz <[email protected]>
Reviewed-by: Alina Buzachis <None>
  • Loading branch information
stefanhorning authored and abikouo committed Sep 18, 2023
1 parent 047516c commit af67561
Showing 1 changed file with 333 additions and 0 deletions.
333 changes: 333 additions & 0 deletions aws_api_gateway_domain.py
Original file line number Diff line number Diff line change
@@ -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()

0 comments on commit af67561

Please sign in to comment.