From a51eca364f4e539f674654589ca4a819959f4b6b Mon Sep 17 00:00:00 2001 From: Ryan Brown Date: Thu, 29 Nov 2018 13:59:10 -0500 Subject: [PATCH] New module: AWS EC2 Launch Template (#46972) * Add launch template integration tests --- lib/ansible/config/module_defaults.yml | 2 + .../cloud/amazon/ec2_launch_template.py | 649 ++++++++++++++++++ .../targets/ec2_launch_template/aliases | 2 + .../playbooks/full_test.yml | 4 + .../ec2_launch_template/defaults/main.yml | 18 + .../files/assume-role-policy.json | 13 + .../roles/ec2_launch_template/meta/main.yml | 3 + .../ec2_launch_template/tasks/cpu_options.yml | 38 + .../tasks/iam_instance_role.yml | 104 +++ .../roles/ec2_launch_template/tasks/main.yml | 23 + .../tasks/tags_and_vpc_settings.yml | 216 ++++++ .../playbooks/version_fail.yml | 35 + .../targets/ec2_launch_template/runme.sh | 26 + 13 files changed, 1133 insertions(+) create mode 100644 lib/ansible/modules/cloud/amazon/ec2_launch_template.py create mode 100644 test/integration/targets/ec2_launch_template/aliases create mode 100644 test/integration/targets/ec2_launch_template/playbooks/full_test.yml create mode 100644 test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/defaults/main.yml create mode 100644 test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/files/assume-role-policy.json create mode 100644 test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/meta/main.yml create mode 100644 test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/tasks/cpu_options.yml create mode 100644 test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/tasks/iam_instance_role.yml create mode 100644 test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/tasks/main.yml create mode 100644 test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/tasks/tags_and_vpc_settings.yml create mode 100644 test/integration/targets/ec2_launch_template/playbooks/version_fail.yml create mode 100755 test/integration/targets/ec2_launch_template/runme.sh diff --git a/lib/ansible/config/module_defaults.yml b/lib/ansible/config/module_defaults.yml index de58c47ed12..5af7fa62c5a 100644 --- a/lib/ansible/config/module_defaults.yml +++ b/lib/ansible/config/module_defaults.yml @@ -140,6 +140,8 @@ groupings: - aws ec2_key: - aws + ec2_launch_template: + - aws ec2_lc: - aws ec2_lc_facts: diff --git a/lib/ansible/modules/cloud/amazon/ec2_launch_template.py b/lib/ansible/modules/cloud/amazon/ec2_launch_template.py new file mode 100644 index 00000000000..c6fe085342e --- /dev/null +++ b/lib/ansible/modules/cloud/amazon/ec2_launch_template.py @@ -0,0 +1,649 @@ +#!/usr/bin/python +# Copyright (c) 2018 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 + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = ''' +--- +module: ec2_launch_template +version_added: "2.8" +short_description: Manage EC2 launch templates +description: + - Create, modify, and delete EC2 Launch Templates, which can be used to + create individual instances or with Autoscaling Groups. + - The I(ec2_instance) and I(ec2_asg) modules can, instead of specifying all + parameters on those tasks, be passed a Launch Template which contains + settings like instance size, disk type, subnet, and more. +requirements: + - botocore + - boto3 >= 1.6.0 +extends_documentation_fragment: + - aws + - ec2 +author: + - Ryan Scott Brown (@ryansb) +options: + template_id: + description: + - The ID for the launch template, can be used for all cases except creating a new Launch Template. + aliases: [id] + template_name: + description: + - The template name. This must be unique in the region-account combination you are using. + aliases: [name] + default_version: + description: + - Which version should be the default when users spin up new instances based on this template? By default, the latest version will be made the default. + default: latest + state: + description: + - Whether the launch template should exist or not. To delete only a + specific version of a launch template, combine I(state=absent) with + the I(version) option. By default, I(state=absent) will remove all + versions of the template. + choices: [present, absent] + default: present + block_device_mappings: + description: + - The block device mapping. Supplying both a snapshot ID and an encryption + value as arguments for block-device mapping results in an error. This is + because only blank volumes can be encrypted on start, and these are not + created from a snapshot. If a snapshot is the basis for the volume, it + contains data by definition and its encryption status cannot be changed + using this action. + suboptions: + device_name: + description: The device name (for example, /dev/sdh or xvdh). + no_device: + description: Suppresses the specified device included in the block device mapping of the AMI. + virtual_name: + description: > + The virtual device name (ephemeralN). Instance store volumes are + numbered starting from 0. An instance type with 2 available instance + store volumes can specify mappings for ephemeral0 and ephemeral1. The + number of available instance store volumes depends on the instance + type. After you connect to the instance, you must mount the volume. + ebs: + description: Parameters used to automatically set up EBS volumes when the instance is launched. + suboptions: + delete_on_termintation: + description: Indicates whether the EBS volume is deleted on instance termination. + type: bool + encrypted: + description: > + Indicates whether the EBS volume is encrypted. Encrypted volumes + can only be attached to instances that support Amazon EBS + encryption. If you are creating a volume from a snapshot, you + can't specify an encryption value. + iops: + description: + - The number of I/O operations per second (IOPS) that the volume + supports. For io1, this represents the number of IOPS that are + provisioned for the volume. For gp2, this represents the baseline + performance of the volume and the rate at which the volume + accumulates I/O credits for bursting. For more information about + General Purpose SSD baseline performance, I/O credits, and + bursting, see Amazon EBS Volume Types in the Amazon Elastic + Compute Cloud User Guide. + - > + Condition: This parameter is required for requests to create io1 + volumes; it is not used in requests to create gp2, st1, sc1, or + standard volumes. + kms_key_id: + description: The ARN of the AWS Key Management Service (AWS KMS) CMK used for encryption. + snapshot_id: + description: The ID of the snapshot to create the volume from + volume_size: + description: + - The size of the volume, in GiB. + - "Default: If you're creating the volume from a snapshot and don't specify a volume size, the default is the snapshot size." + volume_type: + description: The volume type + cpu_options: + description: + - Choose CPU settings for the EC2 instances that will be created with this template. + - For more information, see U(http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-optimize-cpu.html) + suboptions: + core_count: + description: The number of CPU cores for the instance. + threads_per_core: + description: > + The number of threads per CPU core. To disable Intel Hyper-Threading + Technology for the instance, specify a value of 1. Otherwise, specify + the default value of 2. + credit_specification: + description: The credit option for CPU usage of the instance. Valid for T2 or T3 instances only. + suboptions: + cpu_credits: + description: > + The credit option for CPU usage of a T2 or T3 instance. Valid values + are I(standard) and I(unlimited). + choices: [standard, unlimited] + disable_api_termination: + description: > + This helps protect instances from accidental termination. If set to true, + you can't terminate the instance using the Amazon EC2 console, CLI, or + API. To change this attribute to false after launch, use + I(ModifyInstanceAttribute). + type: bool + ebs_optimized: + description: > + Indicates whether the instance is optimized for Amazon EBS I/O. This + optimization provides dedicated throughput to Amazon EBS and an optimized + configuration stack to provide optimal Amazon EBS I/O performance. This + optimization isn't available with all instance types. Additional usage + charges apply when using an EBS-optimized instance. + type: bool + elastic_gpu_specifications: + description: Settings for Elastic GPU attachments. See U(https://aws.amazon.com/ec2/elastic-gpus/) for details. + suboptions: + type: + description: The type of Elastic GPU to attach + iam_instance_profile: + description: > + The name or ARN of an IAM instance profile. Requires permissions to + describe existing instance roles to confirm ARN is properly formed. + image_id: + description: > + The AMI ID to use for new instances launched with this template. This + value is region-dependent since AMIs are not global resources. + instance_initiated_shutdown_behavior: + description: > + Indicates whether an instance stops or terminates when you initiate + shutdown from the instance using the operating system shutdown command. + choices: [stop, terminate] + instance_market_options: + description: Options for alternative instance markets, currently only the spot market is supported. + suboptions: + market_type: + description: The market type. This should always be 'spot'. + spot_options: + description: Spot-market specific settings + suboptions: + block_duration_minutes: + description: > + The required duration for the Spot Instances (also known as Spot + blocks), in minutes. This value must be a multiple of 60 (60, + 120, 180, 240, 300, or 360). + instance_interruption_behavior: + description: The behavior when a Spot Instance is interrupted. The default is I(terminate) + choices: [hibernate, stop, terminate] + max_price: + description: The highest hourly price you're willing to pay for this Spot Instance. + spot_instance_type: + description: The request type to send. + choices: [one-time, persistent] + type: dict + instance_type: + description: > + The instance type, such as I(c5.2xlarge). For a full list of instance types, see + http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-types.html + kernel_id: + description: > + The ID of the kernel. We recommend that you use PV-GRUB instead of + kernels and RAM disks. For more information, see + U(http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/UserProvidedkernels.html) + key_name: + description: + - The name of the key pair. You can create a key pair using + I(CreateKeyPair) or I(ImportKeyPair). + - If you do not specify a key pair, you can't connect to the instance + unless you choose an AMI that is configured to allow users another way to + log in. + monitoring: + description: Settings for instance monitoring + suboptions: + enabled: + type: bool + description: Whether to turn on detailed monitoring for new instances. This will incur extra charges. + network_interfaces: + description: One or more network interfaces. + suboptions: + associate_public_ip_address: + description: Associates a public IPv4 address with eth0 for a new network interface. + type: bool + delete_on_termination: + description: Indicates whether the network interface is deleted when the instance is terminated. + type: bool + description: + description: A description for the network interface. + device_index: + description: The device index for the network interface attachment. + groups: + description: List of security group IDs to include on this instance + ipv6_address_count: + description: > + The number of IPv6 addresses to assign to a network interface. Amazon + EC2 automatically selects the IPv6 addresses from the subnet range. + You can't use this option if specifying the I(ipv6_addresses) option. + ipv6_addresses: + description: > + A list of one or more specific IPv6 addresses from the IPv6 CIDR + block range of your subnet. You can't use this option if you're + specifying the I(ipv6_address_count) option. + network_interface_id: + description: The eni ID of a network interface to attach. + private_ip_address: + description: The primary private IPv4 address of the network interface. + private_ip_addresses: + description: One or more private IPv4 addresses. + suboptions: + primary: + description: > + Indicates whether the private IPv4 address is the primary private + IPv4 address. Only one IPv4 address can be designated as primary. + private_ip_address: + description: The primary private IPv4 address of the network interface. + subnet_id: + description: The ID of the subnet for the network interface. + secondary_private_ip_address_count: + description: The number of secondary private IPv4 addresses to assign to a network interface. + placement: + description: The placement group settings for the instance. + suboptions: + affinity: + description: The affinity setting for an instance on a Dedicated Host. + availability_zone: + description: The Availability Zone for the instance. + group_name: + description: The name of the placement group for the instance. + host_id: + description: The ID of the Dedicated Host for the instance. + tenancy: + description: > + The tenancy of the instance (if the instance is running in a VPC). An + instance with a tenancy of dedicated runs on single-tenant hardware. + ram_disk_id: + description: > + The ID of the RAM disk to launch the instance with. We recommend that you + use PV-GRUB instead of kernels and RAM disks. For more information, see + U(http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/UserProvidedkernels.html) + security_group_ids: + description: A list of security group IDs (VPC or EC2-Classic) that the new instances will be added to. + type: list + security_groups: + description: A list of security group names (VPC or EC2-Classic) that the new instances will be added to. + type: list + tags: + type: dict + description: + - A set of key-value pairs to be applied to resources when this Launch Template is used. + - "Tag key constraints: Tag keys are case-sensitive and accept a maximum of 127 Unicode characters. May not begin with I(aws:)" + - "Tag value constraints: Tag values are case-sensitive and accept a maximum of 255 Unicode characters." + user_data: + description: > + The Base64-encoded user data to make available to the instance. For more information, see the Linux + U(http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/user-data.html) and Windows + U(http://docs.aws.amazon.com/AWSEC2/latest/WindowsGuide/ec2-instance-metadata.html#instancedata-add-user-data) + documentation on user-data. +''' + +EXAMPLES = ''' +- name: Make instance with an instance_role + ec2_launch_template: + name: "test-with-instance-role" + image_id: "ami-foobarbaz" + key_name: my_ssh_key + instance_type: t2.micro + iam_instance_profile: myTestProfile + disable_api_termination: true + +- name: Make one with a different instance type, but leave the older version as default + ec2_launch_template: + name: "test-with-instance-role" + image_id: "ami-foobarbaz" + default_version: 1 + key_name: my_ssh_key + instance_type: c5.4xlarge + iam_instance_profile: myTestProfile + disable_api_termination: true +''' + +RETURN = ''' +latest_version: + description: Latest available version of the launch template + returned: when state=present + type: int +default_version: + description: The version that will be used if only the template name is specified. Often this is the same as the latest version, but not always. + returned: when state=present + type: int +''' +import re +from uuid import uuid4 + +from ansible.module_utils._text import to_text +from ansible.module_utils.aws.core import AnsibleAWSModule, is_boto3_error_code, get_boto3_client_method_parameters +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict, snake_dict_to_camel_dict +from ansible.module_utils.ec2 import ansible_dict_to_boto3_tag_list, AWSRetry, boto3_tag_list_to_ansible_dict, ansible_dict_to_boto3_tag_list + +try: + from botocore.exceptions import ClientError, BotoCoreError, WaiterError +except ImportError: + pass # caught by AnsibleAWSModule + + +def determine_iam_role(module, name_or_arn): + if re.match(r'^arn:aws:iam::\d+:instance-profile/[\w+=/,.@-]+$', name_or_arn): + return name_or_arn + iam = module.client('iam', retry_decorator=AWSRetry.jittered_backoff()) + try: + role = iam.get_instance_profile(InstanceProfileName=name_or_arn, aws_retry=True) + return {'arn': role['InstanceProfile']['Arn']} + except is_boto3_error_code('NoSuchEntity') as e: + module.fail_json_aws(e, msg="Could not find instance_role {0}".format(name_or_arn)) + except (BotoCoreError, ClientError) as e: # pylint: disable=duplicate-except + module.fail_json_aws(e, msg="An error occurred while searching for instance_role {0}. Please try supplying the full ARN.".format(name_or_arn)) + + +def existing_templates(module): + ec2 = module.client('ec2', retry_decorator=AWSRetry.jittered_backoff()) + matches = None + try: + if module.params.get('template_id'): + matches = ec2.describe_launch_templates(LaunchTemplateIds=[module.params.get('template_id')]) + elif module.params.get('template_name'): + matches = ec2.describe_launch_templates(LaunchTemplateNames=[module.params.get('template_name')]) + except is_boto3_error_code('InvalidLaunchTemplateName.NotFoundException') as e: + # no named template was found, return nothing/empty versions + return None, [] + except is_boto3_error_code('InvalidLaunchTemplateId.Malformed') as e: # pylint: disable=duplicate-except + module.fail_json_aws(e, msg='Launch template with ID {0} is not a valid ID. It should start with `lt-....`'.format( + module.params.get('launch_template_id'))) + except is_boto3_error_code('InvalidLaunchTemplateId.NotFoundException') as e: # pylint: disable=duplicate-except + module.fail_json_aws( + e, msg='Launch template with ID {0} could not be found, please supply a name ' + 'instead so that a new template can be created'.format(module.params.get('launch_template_id'))) + except (ClientError, BotoCoreError, WaiterError) as e: # pylint: disable=duplicate-except + module.fail_json_aws(e, msg='Could not check existing launch templates. This may be an IAM permission problem.') + else: + template = matches['LaunchTemplates'][0] + template_id, template_version, template_default = template['LaunchTemplateId'], template['LatestVersionNumber'], template['DefaultVersionNumber'] + try: + return template, ec2.describe_launch_template_versions(LaunchTemplateId=template_id)['LaunchTemplateVersions'] + except (ClientError, BotoCoreError, WaiterError) as e: + module.fail_json_aws(e, msg='Could not find launch template versions for {0} (ID: {1}).'.format(template['LaunchTemplateName'], template_id)) + + +def params_to_launch_data(module, template_params): + if template_params.get('tags'): + template_params['tag_specifications'] = [ + { + 'resource_type': r_type, + 'tags': [ + {'Key': k, 'Value': v} for k, v + in template_params['tags'].items() + ] + } + for r_type in ('instance', 'network-interface', 'volume') + ] + del template_params['tags'] + if module.params.get('iam_instance_profile'): + template_params['iam_instance_profile'] = determine_iam_role(module, module.params['iam_instance_profile']) + params = snake_dict_to_camel_dict( + dict((k, v) for k, v in template_params.items() if v is not None), + capitalize_first=True, + ) + return params + + +def delete_template(module): + ec2 = module.client('ec2', retry_decorator=AWSRetry.jittered_backoff()) + template, template_versions = existing_templates(module) + deleted_versions = [] + if template or template_versions: + non_default_versions = [to_text(t['VersionNumber']) for t in template_versions if not t['DefaultVersion']] + if non_default_versions: + try: + v_resp = ec2.delete_launch_template_versions( + LaunchTemplateId=template['LaunchTemplateId'], + Versions=non_default_versions, + ) + if v_resp['UnsuccessfullyDeletedLaunchTemplateVersions']: + module.warn('Failed to delete template versions {0} on launch template {1}'.format( + v_resp['UnsuccessfullyDeletedLaunchTemplateVersions'], + template['LaunchTemplateId'], + )) + deleted_versions = [camel_dict_to_snake_dict(v) for v in v_resp['SuccessfullyDeletedLaunchTemplateVersions']] + except (ClientError, BotoCoreError) as e: + module.fail_json_aws(e, msg="Could not delete existing versions of the launch template {0}".format(template['LaunchTemplateId'])) + try: + resp = ec2.delete_launch_template( + LaunchTemplateId=template['LaunchTemplateId'], + ) + except (ClientError, BotoCoreError) as e: + module.fail_json_aws(e, msg="Could not delete launch template {0}".format(template['LaunchTemplateId'])) + return { + 'deleted_versions': deleted_versions, + 'deleted_template': camel_dict_to_snake_dict(resp['LaunchTemplate']), + 'changed': True, + } + else: + return {'changed': False} + + +def create_or_update(module, template_options): + ec2 = module.client('ec2', retry_decorator=AWSRetry.jittered_backoff()) + template, template_versions = existing_templates(module) + out = {} + lt_data = params_to_launch_data(module, dict((k, v) for k, v in module.params.items() if k in template_options)) + if not (template or template_versions): + # create a full new one + try: + resp = ec2.create_launch_template( + LaunchTemplateName=module.params['template_name'], + LaunchTemplateData=lt_data, + ClientToken=uuid4().hex, + aws_retry=True, + ) + except (ClientError, BotoCoreError) as e: + module.fail_json_aws(e, msg="Couldn't create launch template") + template, template_versions = existing_templates(module) + out['changed'] = True + elif template and template_versions: + most_recent = sorted(template_versions, key=lambda x: x['VersionNumber'])[-1] + if lt_data == most_recent['LaunchTemplateData']: + out['changed'] = False + return out + try: + resp = ec2.create_launch_template_version( + LaunchTemplateId=template['LaunchTemplateId'], + LaunchTemplateData=lt_data, + ClientToken=uuid4().hex, + aws_retry=True, + ) + if module.params.get('default_version') in (None, ''): + # no need to do anything, leave the existing version as default + pass + elif module.params.get('default_version') == 'latest': + set_default = ec2.modify_launch_template( + LaunchTemplateId=template['LaunchTemplateId'], + DefaultVersion=to_text(resp['LaunchTemplateVersion']['VersionNumber']), + ClientToken=uuid4().hex, + aws_retry=True, + ) + else: + try: + int(module.params.get('default_version')) + except ValueError: + module.fail_json(msg='default_version param was not a valid integer, got "{0}"'.format(module.params.get('default_version'))) + set_default = ec2.modify_launch_template( + LaunchTemplateId=template['LaunchTemplateId'], + DefaultVersion=to_text(int(module.params.get('default_version'))), + ClientToken=uuid4().hex, + aws_retry=True, + ) + except (ClientError, BotoCoreError) as e: + module.fail_json_aws(e, msg="Couldn't create subsequent launch template version") + template, template_versions = existing_templates(module) + out['changed'] = True + return out + + +def format_module_output(module): + output = {} + template, template_versions = existing_templates(module) + template = camel_dict_to_snake_dict(template) + template_versions = [camel_dict_to_snake_dict(v) for v in template_versions] + for v in template_versions: + for ts in (v['launch_template_data'].get('tag_specifications') or []): + ts['tags'] = boto3_tag_list_to_ansible_dict(ts.pop('tags')) + output.update(dict(template=template, versions=template_versions)) + output['default_template'] = [ + v for v in template_versions + if v.get('default_version') + ][0] + output['latest_template'] = [ + v for v in template_versions + if ( + v.get('version_number') and + int(v['version_number']) == int(template['latest_version_number']) + ) + ][0] + return output + + +def main(): + template_options = dict( + block_device_mappings=dict( + type='list', + options=dict( + device_name=dict(), + ebs=dict( + type='dict', + options=dict( + delete_on_termination=dict(type='bool'), + encrypted=dict(type='bool'), + iops=dict(type='int'), + kms_key_id=dict(), + snapshot_id=dict(), + volume_size=dict(type='int'), + volume_type=dict(), + ), + ), + no_device=dict(), + virtual_name=dict(), + ), + ), + cpu_options=dict( + type='dict', + options=dict( + core_count=dict(type='int'), + threads_per_core=dict(type='int'), + ), + ), + credit_specification=dict( + dict(type='dict'), + options=dict( + cpu_credits=dict(), + ), + ), + disable_api_termination=dict(type='bool'), + ebs_optimized=dict(type='bool'), + elastic_gpu_specifications=dict( + options=dict(type=dict()), + type='list', + ), + iam_instance_profile=dict(), + image_id=dict(), + instance_initiated_shutdown_behavior=dict(choices=['stop', 'terminate']), + instance_market_options=dict( + type='dict', + options=dict( + market_type=dict(), + spot_options=dict( + type='dict', + options=dict( + block_duration_minutes=dict(type='int'), + instance_interruption_behavior=dict(choices=['hibernate', 'stop', 'terminate']), + max_price=dict(), + spot_instance_type=dict(choices=['one-time', 'persistent']), + ), + ), + ), + ), + instance_type=dict(), + kernel_id=dict(), + key_name=dict(), + monitoring=dict( + type='dict', + options=dict( + enabled=dict(type='bool') + ), + ), + network_interfaces=dict( + type='list', + options=dict( + associate_public_ip_address=dict(type='bool'), + delete_on_termination=dict(type='bool'), + description=dict(), + device_index=dict(type='int'), + groups=dict(type='list'), + ipv6_address_count=dict(type='int'), + ipv6_addresses=dict(type='list'), + network_interface_id=dict(), + private_ip_address=dict(), + subnet_id=dict(), + ), + ), + placement=dict( + options=dict( + affinity=dict(), + availability_zone=dict(), + group_name=dict(), + host_id=dict(), + tenancy=dict(), + ), + type='dict', + ), + ram_disk_id=dict(), + security_group_ids=dict(type='list'), + security_groups=dict(type='list'), + tags=dict(type='dict'), + user_data=dict(), + ) + + arg_spec = dict( + state=dict(choices=['present', 'absent'], default='present'), + template_name=dict(aliases=['name']), + template_id=dict(aliases=['id']), + default_version=dict(default='latest'), + ) + + arg_spec.update(template_options) + + module = AnsibleAWSModule( + argument_spec=arg_spec, + required_one_of=[ + ('template_name', 'template_id') + ], + supports_check_mode=True + ) + + if not module.boto3_at_least('1.6.0'): + module.fail_json(msg="ec2_launch_template requires boto3 >= 1.6.0") + + for interface in (module.params.get('network_interfaces') or []): + if interface.get('ipv6_addresses'): + interface['ipv6_addresses'] = [{'ipv6_address': x} for x in interface['ipv6_addresses']] + + if module.params.get('state') == 'present': + out = create_or_update(module, template_options) + out.update(format_module_output(module)) + elif module.params.get('state') == 'absent': + out = delete_template(module) + else: + module.fail_json(msg='Unsupported value "{0}" for `state` parameter'.format(module.params.get('state'))) + + module.exit_json(**out) + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/ec2_launch_template/aliases b/test/integration/targets/ec2_launch_template/aliases new file mode 100644 index 00000000000..56927195182 --- /dev/null +++ b/test/integration/targets/ec2_launch_template/aliases @@ -0,0 +1,2 @@ +cloud/aws +unsupported diff --git a/test/integration/targets/ec2_launch_template/playbooks/full_test.yml b/test/integration/targets/ec2_launch_template/playbooks/full_test.yml new file mode 100644 index 00000000000..7664841d140 --- /dev/null +++ b/test/integration/targets/ec2_launch_template/playbooks/full_test.yml @@ -0,0 +1,4 @@ +- hosts: localhost + connection: local + roles: + - ec2_launch_template diff --git a/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/defaults/main.yml b/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/defaults/main.yml new file mode 100644 index 00000000000..9651b91642a --- /dev/null +++ b/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/defaults/main.yml @@ -0,0 +1,18 @@ +--- +resource_prefix: ansible-test-default-group +ec2_ami_image: + # https://wiki.centos.org/Cloud/AWS collected 2018-01-10 + ap-northeast-1: ami-571e3c30 + ap-northeast-2: ami-97cb19f9 + ap-south-1: ami-11f0837e + ap-southeast-1: ami-30318f53 + ap-southeast-2: ami-24959b47 + ca-central-1: ami-daeb57be + eu-central-1: ami-7cbc6e13 + eu-west-1: ami-0d063c6b + eu-west-2: ami-c22236a6 + sa-east-1: ami-864f2dea + us-east-1: ami-ae7bfdb8 + us-east-2: ami-9cbf9bf9 + us-west-1: ami-7c280d1c + us-west-2: ami-0c2aba6c diff --git a/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/files/assume-role-policy.json b/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/files/assume-role-policy.json new file mode 100644 index 00000000000..72413abdd38 --- /dev/null +++ b/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/files/assume-role-policy.json @@ -0,0 +1,13 @@ +{ + "Version": "2008-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] +} diff --git a/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/meta/main.yml b/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/meta/main.yml new file mode 100644 index 00000000000..1f64f1169a9 --- /dev/null +++ b/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/meta/main.yml @@ -0,0 +1,3 @@ +dependencies: + - prepare_tests + - setup_ec2 diff --git a/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/tasks/cpu_options.yml b/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/tasks/cpu_options.yml new file mode 100644 index 00000000000..8d610a2ea75 --- /dev/null +++ b/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/tasks/cpu_options.yml @@ -0,0 +1,38 @@ +- block: + - name: delete a non-existent template + ec2_launch_template: + name: "{{ resource_prefix }}-not-a-real-template" + state: absent + register: del_fake_lt + ignore_errors: true + - assert: + that: + - del_fake_lt is not failed + - name: create c4.large instance with cpu_options + ec2_launch_template: + name: "{{ resource_prefix }}-c4large-1-threads-per-core" + image_id: "{{ ec2_ami_image[aws_region] }}" + tags: + TestId: "{{ resource_prefix }}" + instance_type: c4.large + cpu_options: + core_count: 1 + threads_per_core: 1 + register: lt + + - name: instance with cpu_options created with the right options + assert: + that: + - lt is success + - lt is changed + - "lt.latest_template.launch_template_data.cpu_options.core_count == 1" + - "lt.latest_template.launch_template_data.cpu_options.threads_per_core == 1" + always: + - name: delete the template + ec2_launch_template: + name: "{{ resource_prefix }}-c4large-1-threads-per-core" + state: absent + register: del_lt + retries: 10 + until: del_lt is not failed + ignore_errors: true diff --git a/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/tasks/iam_instance_role.yml b/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/tasks/iam_instance_role.yml new file mode 100644 index 00000000000..5e9b7f563de --- /dev/null +++ b/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/tasks/iam_instance_role.yml @@ -0,0 +1,104 @@ +- block: + - name: Create IAM role for test + iam_role: + name: "{{ resource_prefix }}-test-policy" + assume_role_policy_document: "{{ lookup('file','assume-role-policy.json') }}" + state: present + create_instance_profile: yes + managed_policy: + - AmazonS3ReadOnlyAccess + register: iam_role + + - name: Create second IAM role for test + iam_role: + name: "{{ resource_prefix }}-test-policy-2" + assume_role_policy_document: "{{ lookup('file','assume-role-policy.json') }}" + state: present + create_instance_profile: yes + managed_policy: + - AmazonS3ReadOnlyAccess + register: iam_role_2 + + - name: Make instance with an instance_role + ec2_launch_template: + name: "{{ resource_prefix }}-test-instance-role" + image_id: "{{ ec2_ami_image[aws_region] }}" + instance_type: t2.micro + iam_instance_profile: "{{ resource_prefix }}-test-policy" + register: template_with_role + + - assert: + that: + - 'template_with_role.default_template.launch_template_data.iam_instance_profile.arn == iam_role.arn.replace(":role/", ":instance-profile/")' + + - name: Create template again, with no change to instance_role + ec2_launch_template: + name: "{{ resource_prefix }}-test-instance-role" + image_id: "{{ ec2_ami_image[aws_region] }}" + instance_type: t2.micro + iam_instance_profile: "{{ resource_prefix }}-test-policy" + register: template_with_role + + - assert: + that: + - 'template_with_role.default_template.launch_template_data.iam_instance_profile.arn == iam_role.arn.replace(":role/", ":instance-profile/")' + - 'template_with_role is not changed' + + - name: Update instance with new instance_role + ec2_launch_template: + name: "{{ resource_prefix }}-test-instance-role" + image_id: "{{ ec2_ami_image[aws_region] }}" + instance_type: t2.micro + iam_instance_profile: "{{ resource_prefix }}-test-policy-2" + register: template_with_updated_role + + - assert: + that: + - 'template_with_updated_role.default_template.launch_template_data.iam_instance_profile.arn == iam_role_2.arn.replace(":role/", ":instance-profile/")' + - 'template_with_updated_role.default_template.launch_template_data.iam_instance_profile.arn == iam_role_2.arn.replace(":role/", ":instance-profile/")' + - 'template_with_role.default_template.version_number < template_with_updated_role.default_template.version_number' + - 'template_with_updated_role is changed' + - 'template_with_updated_role is not failed' + + - name: Re-set with same new instance_role + ec2_launch_template: + name: "{{ resource_prefix }}-test-instance-role" + image_id: "{{ ec2_ami_image[aws_region] }}" + instance_type: t2.micro + iam_instance_profile: "{{ resource_prefix }}-test-policy-2" + register: template_with_updated_role + + - assert: + that: + - 'template_with_updated_role is not changed' + - 'template_with_updated_role.default_template.launch_template_data.iam_instance_profile.arn == iam_role_2.arn.replace(":role/", ":instance-profile/")' + + always: + - name: delete launch template + ec2_launch_template: + name: "{{ resource_prefix }}-test-instance-role" + state: absent + register: lt_removed + until: lt_removed is not failed + ignore_errors: yes + retries: 10 + - name: Delete IAM role for test + iam_role: + name: "{{ resource_prefix }}-test-policy" + assume_role_policy_document: "{{ lookup('file','assume-role-policy.json') }}" + state: absent + create_instance_profile: yes + register: iam_removed + until: iam_removed is not failed + ignore_errors: yes + retries: 10 + - name: Delete IAM role for test + iam_role: + name: "{{ resource_prefix }}-test-policy-2" + assume_role_policy_document: "{{ lookup('file','assume-role-policy.json') }}" + state: absent + create_instance_profile: yes + register: iam_2_removed + until: iam_2_removed is not failed + ignore_errors: yes + retries: 10 diff --git a/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/tasks/main.yml b/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/tasks/main.yml new file mode 100644 index 00000000000..09ca075f218 --- /dev/null +++ b/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/tasks/main.yml @@ -0,0 +1,23 @@ +--- +# A Note about ec2 environment variable name preference: +# - EC2_URL -> AWS_URL +# - EC2_ACCESS_KEY -> AWS_ACCESS_KEY_ID -> AWS_ACCESS_KEY +# - EC2_SECRET_KEY -> AWS_SECRET_ACCESS_KEY -> AWX_SECRET_KEY +# - EC2_REGION -> AWS_REGION +# + +# - include: ../../../../../setup_ec2/tasks/common.yml module_name: ec2_instance + +- module_defaults: + group/aws: + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + region: "{{ aws_region }}" + block: + - include_tasks: cpu_options.yml + - include_tasks: iam_instance_role.yml + + always: + - debug: + msg: teardown goes here diff --git a/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/tasks/tags_and_vpc_settings.yml b/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/tasks/tags_and_vpc_settings.yml new file mode 100644 index 00000000000..9f24580a88b --- /dev/null +++ b/test/integration/targets/ec2_launch_template/playbooks/roles/ec2_launch_template/tasks/tags_and_vpc_settings.yml @@ -0,0 +1,216 @@ +- block: + # ============================================================ + # set up VPC + - name: Create VPC for use in testing + ec2_vpc_net: + name: "{{ resource_prefix }}-vpc" + cidr_block: 10.99.0.0/16 + tags: + Name: Ansible ec2_instance Testing VPC + tenancy: default + register: testing_vpc + + - name: Create default subnet in zone A + ec2_vpc_subnet: + state: present + vpc_id: "{{ testing_vpc.vpc.id }}" + cidr: 10.99.0.0/24 + az: "{{ aws_region }}a" + resource_tags: + Name: "{{ resource_prefix }}-subnet-a" + register: testing_subnet_a + + - name: Create secondary subnet in zone B + ec2_vpc_subnet: + state: present + vpc_id: "{{ testing_vpc.vpc.id }}" + cidr: 10.99.1.0/24 + az: "{{ aws_region }}b" + resource_tags: + Name: "{{ resource_prefix }}-subnet-b" + register: testing_subnet_b + + - name: create a security group with the vpc + ec2_group: + name: "{{ resource_prefix }}-sg" + description: a security group for ansible tests + vpc_id: "{{ testing_vpc.vpc.id }}" + rules: + - proto: tcp + ports: [22, 80] + cidr_ip: 0.0.0.0/0 + register: sg + # TODO: switch these tests from instances + - assert: + that: + - 1 == 0 + # ============================================================ + # start subnet/sg testing + - name: Make instance in the testing subnet created in the test VPC + ec2_instance: + name: "{{ resource_prefix }}-test-basic-vpc-create" + image_id: "{{ ec2_ami_image[aws_region] }}" + user_data: | + #cloud-config + package_upgrade: true + package_update: true + tags: + TestId: "{{ resource_prefix }}" + Something: else + security_groups: "{{ sg.group_id }}" + network: + source_dest_check: false + vpc_subnet_id: "{{ testing_subnet_b.subnet.id }}" + instance_type: t2.micro + volumes: + - device_name: /dev/sda1 + ebs: + delete_on_termination: true + <<: *aws_connection_info + register: in_test_vpc + + - name: Try to re-make the instance, hopefully this shows changed=False + ec2_instance: + name: "{{ resource_prefix }}-test-basic-vpc-create" + image_id: "{{ ec2_ami_image[aws_region] }}" + user_data: | + #cloud-config + package_upgrade: true + package_update: true + tags: + TestId: "{{ resource_prefix }}" + Something: else + security_groups: "{{ sg.group_id }}" + vpc_subnet_id: "{{ testing_subnet_b.subnet.id }}" + instance_type: t2.micro + <<: *aws_connection_info + register: remake_in_test_vpc + - name: "Remaking the same instance resulted in no changes" + assert: + that: not remake_in_test_vpc.changed + - name: check that instance IDs match anyway + assert: + that: 'remake_in_test_vpc.instance_ids[0] == in_test_vpc.instance_ids[0]' + - name: check that source_dest_check was set to false + assert: + that: 'not remake_in_test_vpc.instances[0].source_dest_check' + + - name: Alter it by adding tags + ec2_instance: + name: "{{ resource_prefix }}-test-basic-vpc-create" + image_id: "{{ ec2_ami_image[aws_region] }}" + tags: + TestId: "{{ resource_prefix }}" + Another: thing + security_groups: "{{ sg.group_id }}" + vpc_subnet_id: "{{ testing_subnet_b.subnet.id }}" + instance_type: t2.micro + <<: *aws_connection_info + register: add_another_tag + + - ec2_instance_facts: + instance_ids: "{{ add_another_tag.instance_ids }}" + <<: *aws_connection_info + register: check_tags + - name: "Remaking the same instance resulted in no changes" + assert: + that: + - check_tags.instances[0].tags.Another == 'thing' + - check_tags.instances[0].tags.Something == 'else' + + - name: Purge a tag + ec2_instance: + name: "{{ resource_prefix }}-test-basic-vpc-create" + image_id: "{{ ec2_ami_image[aws_region] }}" + purge_tags: true + tags: + TestId: "{{ resource_prefix }}" + Another: thing + security_groups: "{{ sg.group_id }}" + vpc_subnet_id: "{{ testing_subnet_b.subnet.id }}" + instance_type: t2.micro + <<: *aws_connection_info + - ec2_instance_facts: + instance_ids: "{{ add_another_tag.instance_ids }}" + <<: *aws_connection_info + register: check_tags + - name: "Remaking the same instance resulted in no changes" + assert: + that: + - "'Something' not in check_tags.instances[0].tags" + + - name: Terminate instance + ec2_instance: + filters: + tag:TestId: "{{ resource_prefix }}" + state: absent + <<: *aws_connection_info + register: result + - assert: + that: result.changed + + - name: Terminate instance + ec2_instance: + instance_ids: "{{ in_test_vpc.instance_ids }}" + state: absent + <<: *aws_connection_info + register: result + - assert: + that: not result.changed + + - name: check that subnet-default public IP rule was followed + assert: + that: + - in_test_vpc.instances[0].public_dns_name == "" + - in_test_vpc.instances[0].private_ip_address.startswith("10.22.33") + - in_test_vpc.instances[0].subnet_id == testing_subnet_b.subnet.id + - name: check that tags were applied + assert: + that: + - in_test_vpc.instances[0].tags.Name.startswith(resource_prefix) + - in_test_vpc.instances[0].state.name == 'running' + + always: + - name: remove the security group + ec2_group: + name: "{{ resource_prefix }}-sg" + description: a security group for ansible tests + vpc_id: "{{ testing_vpc.vpc.id }}" + state: absent + register: removed + until: removed is not failed + ignore_errors: yes + retries: 10 + + - name: remove subnet A + ec2_vpc_subnet: + state: absent + vpc_id: "{{ testing_vpc.vpc.id }}" + cidr: 10.99.0.0/24 + register: removed + until: removed is not failed + ignore_errors: yes + retries: 10 + + - name: remove subnet B + ec2_vpc_subnet: + state: absent + vpc_id: "{{ testing_vpc.vpc.id }}" + cidr: 10.99.1.0/24 + register: removed + until: removed is not failed + ignore_errors: yes + retries: 10 + + - name: remove the VPC + ec2_vpc_net: + name: "{{ resource_prefix }}-vpc" + cidr_block: 10.99.0.0/16 + state: absent + tags: + Name: Ansible Testing VPC + tenancy: default + register: removed + until: removed is not failed + ignore_errors: yes + retries: 10 diff --git a/test/integration/targets/ec2_launch_template/playbooks/version_fail.yml b/test/integration/targets/ec2_launch_template/playbooks/version_fail.yml new file mode 100644 index 00000000000..d8a44610851 --- /dev/null +++ b/test/integration/targets/ec2_launch_template/playbooks/version_fail.yml @@ -0,0 +1,35 @@ +- hosts: localhost + connection: local + vars: + resource_prefix: 'ansible-testing' + module_defaults: + group/aws: + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + region: "{{ aws_region }}" + tasks: + - block: + - name: Include vars file in roles/ec2_instance/defaults/main.yml + include_vars: + file: 'roles/ec2_launch_template/defaults/main.yml' + + - name: create c4.large template (failure expected) + ec2_launch_template: + state: present + name: "ansible-test-{{ resource_prefix | regex_search('([0-9]+)$') }}-tpl" + instance_type: c4.large + register: ec2_lt + ignore_errors: yes + + - name: check that graceful error message is returned when creation with cpu_options and old botocore + assert: + that: + - ec2_lt is failed + - 'ec2_lt.msg == "ec2_launch_template requires boto3 >= 1.6.0"' + always: + - name: delete the c4.large template just in case it was created + ec2_launch_template: + state: absent + name: "ansible-test-{{ resource_prefix | regex_search('([0-9]+)$') }}-tpl" + ignore_errors: yes diff --git a/test/integration/targets/ec2_launch_template/runme.sh b/test/integration/targets/ec2_launch_template/runme.sh new file mode 100755 index 00000000000..31fa9b1558f --- /dev/null +++ b/test/integration/targets/ec2_launch_template/runme.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +# We don't set -u here, due to pypa/virtualenv#150 +set -ex + +MYTMPDIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'mytmpdir') + +trap 'rm -rf "${MYTMPDIR}"' EXIT + +# This is needed for the ubuntu1604py3 tests +# Ubuntu patches virtualenv to make the default python2 +# but for the python3 tests we need virtualenv to use python3 +PYTHON=${ANSIBLE_TEST_PYTHON_INTERPRETER:-python} + +# Test graceful failure for older versions of botocore +export ANSIBLE_ROLES_PATH=../ +virtualenv --system-site-packages --python "${PYTHON}" "${MYTMPDIR}/boto3-less-than-1.6.0" +source "${MYTMPDIR}/boto3-less-than-1.6.0/bin/activate" +"${PYTHON}" -m pip install 'boto3<1.6.0' +ansible-playbook -i ../../inventory -e @../../integration_config.yml -e @../../cloud-config-aws.yml -v playbooks/version_fail.yml "$@" + +# Run full test suite +virtualenv --system-site-packages --python "${PYTHON}" "${MYTMPDIR}/boto3-recent" +source "${MYTMPDIR}/boto3-recent/bin/activate" +$PYTHON -m pip install 'boto3>1.6.0' +ansible-playbook -i ../../inventory -e @../../integration_config.yml -e @../../cloud-config-aws.yml -v playbooks/full_test.yml "$@"