From 3600745a1dc15e8f3114a53cb1f2572f6e245a72 Mon Sep 17 00:00:00 2001 From: Bikouo Aubin <79859644+abikouo@users.noreply.github.com> Date: Tue, 15 Oct 2024 19:15:19 +0200 Subject: [PATCH] Minor update on module_utils/ec2 + add mew module ec2_launch_template_info (#2319) SUMMARY Move shared code from community.aws.ec2_launch_template into module_utils/ec2 Move function determine_iam_role from modules amazon.aws.ec2_instance and community.aws.ec2_launch_template Create new module ec2_launch_template_info ISSUE TYPE Feature Pull Request New Module Pull Request Reviewed-by: Alina Buzachis Reviewed-by: Mark Chappell Reviewed-by: Bikouo Aubin Reviewed-by: GomathiselviS --- ...41002-module_utils-ec2-add-shared-code.yml | 4 + meta/runtime.yml | 1 + plugins/doc_fragments/tags.py | 14 +- plugins/module_utils/ec2.py | 116 +++- plugins/modules/ec2_instance.py | 36 +- plugins/modules/ec2_launch_template_info.py | 584 ++++++++++++++++++ .../ec2/test_determine_iam_role.py | 75 +++ .../test_build_run_instance_spec.py | 4 +- .../ec2_instance/test_determine_iam_role.py | 98 --- 9 files changed, 800 insertions(+), 132 deletions(-) create mode 100644 changelogs/fragments/20241002-module_utils-ec2-add-shared-code.yml create mode 100644 plugins/modules/ec2_launch_template_info.py create mode 100644 tests/unit/module_utils/ec2/test_determine_iam_role.py delete mode 100644 tests/unit/plugins/modules/ec2_instance/test_determine_iam_role.py diff --git a/changelogs/fragments/20241002-module_utils-ec2-add-shared-code.yml b/changelogs/fragments/20241002-module_utils-ec2-add-shared-code.yml new file mode 100644 index 00000000000..794b1b6bc5c --- /dev/null +++ b/changelogs/fragments/20241002-module_utils-ec2-add-shared-code.yml @@ -0,0 +1,4 @@ +--- +minor_changes: + - Move function ``determine_iam_role`` from module ``ec2_instance`` to module_utils/ec2 so that it can be used by ``community.aws.ec2_launch_template`` module (https://github.com/ansible-collections/amazon.aws/pull/2319). + - module_utils/ec2 - add some shared code for Launch template AWS API calls (https://github.com/ansible-collections/amazon.aws/pull/2319). \ No newline at end of file diff --git a/meta/runtime.yml b/meta/runtime.yml index 3f60d9fcff6..b62a89aae8d 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -43,6 +43,7 @@ action_groups: - ec2_instance_info - ec2_key - ec2_key_info + - ec2_launch_template_info - ec2_security_group - ec2_security_group_info - ec2_snapshot diff --git a/plugins/doc_fragments/tags.py b/plugins/doc_fragments/tags.py index afd29dedf7d..a3149825d26 100644 --- a/plugins/doc_fragments/tags.py +++ b/plugins/doc_fragments/tags.py @@ -11,19 +11,19 @@ class ModuleDocFragment: tags: description: - A dictionary representing the tags to be applied to the resource. - - If the I(tags) parameter is not set then tags will not be modified. + - If the O(tags) parameter is not set then tags will not be modified. type: dict required: false aliases: ['resource_tags'] purge_tags: description: - - If I(purge_tags=true) and I(tags) is set, existing tags will be purged - from the resource to match exactly what is defined by I(tags) parameter. - - If the I(tags) parameter is not set then tags will not be modified, even - if I(purge_tags=True). - - Tag keys beginning with C(aws:) are reserved by Amazon and can not be + - If O(purge_tags=true) and O(tags) is set, existing tags will be purged + from the resource to match exactly what is defined by O(tags) parameter. + - If the O(tags) parameter is not set then tags will not be modified, even + if O(purge_tags=True). + - Tag keys beginning with V(aws:) are reserved by Amazon and can not be modified. As such they will be ignored for the purposes of the - I(purge_tags) parameter. See the Amazon documentation for more information + O(purge_tags) parameter. See the Amazon documentation for more information U(https://docs.aws.amazon.com/general/latest/gr/aws_tagging.html#tag-conventions). type: bool default: true diff --git a/plugins/module_utils/ec2.py b/plugins/module_utils/ec2.py index 1b26c2386fa..e488984649e 100644 --- a/plugins/module_utils/ec2.py +++ b/plugins/module_utils/ec2.py @@ -58,6 +58,7 @@ # Used to live here, moved into ansible_collections.amazon.aws.plugins.module_utils.arn from .arn import is_outpost_arn as is_outposts_arn # pylint: disable=unused-import +from .arn import validate_aws_arn # Used to live here, moved into ansible_collections.amazon.aws.plugins.module_utils.botocore from .botocore import HAS_BOTO3 # pylint: disable=unused-import @@ -72,6 +73,7 @@ # Used to live here, moved into ansible_collections.amazon.aws.plugins.module_utils.exceptions from .exceptions import AnsibleAWSError # pylint: disable=unused-import +from .iam import list_iam_instance_profiles # Used to live here, moved into ansible_collections.amazon.aws.plugins.module_utils.modules # The names have been changed in .modules to better reflect their applicability. @@ -1239,7 +1241,7 @@ class EC2NetworkAclErrorHandler(AWSErrorHandler): @classmethod def _is_missing(cls): - return is_boto3_error_code("") + return is_boto3_error_code("InvalidNetworkAclID.NotFound") @EC2NetworkAclErrorHandler.list_error_handler("describe network acls", []) @@ -1359,6 +1361,108 @@ def create_ec2_placement_group(client, **params: Dict[str, Union[str, EC2TagSpec return client.create_placement_group(**params)["PlacementGroup"] +# EC2 Launch template +class EC2LaunchTemplateErrorHandler(AWSErrorHandler): + _CUSTOM_EXCEPTION = AnsibleEC2Error + + @classmethod + def _is_missing(cls): + return is_boto3_error_code(["InvalidLaunchTemplateName.NotFoundException", "InvalidLaunchTemplateId.NotFound"]) + + +@EC2LaunchTemplateErrorHandler.list_error_handler("describe launch templates", []) +@AWSRetry.jittered_backoff() +def describe_launch_templates( + client, + launch_template_ids: Optional[List[str]] = None, + launch_template_names: Optional[List[str]] = None, + filters: Optional[List[Dict[str, List[str]]]] = None, +) -> List[Dict[str, Any]]: + params = {} + if launch_template_ids: + params["LaunchTemplateIds"] = launch_template_ids + if launch_template_names: + params["LaunchTemplateNames"] = launch_template_names + if filters: + params["Filters"] = filters + paginator = client.get_paginator("describe_launch_templates") + return paginator.paginate(**params).build_full_result()["LaunchTemplates"] + + +@EC2LaunchTemplateErrorHandler.common_error_handler("describe launch template versions") +@AWSRetry.jittered_backoff() +def describe_launch_template_versions(client, **params: Dict[str, Any]) -> List[Dict[str, Any]]: + paginator = client.get_paginator("describe_launch_template_versions") + return paginator.paginate(**params).build_full_result()["LaunchTemplateVersions"] + + +@EC2LaunchTemplateErrorHandler.common_error_handler("delete launch template versions") +@AWSRetry.jittered_backoff() +def delete_launch_template_versions( + client, versions: List[str], launch_template_id: Optional[str] = None, launch_template_name: Optional[str] = None +) -> Dict[str, Any]: + params = {} + if launch_template_id: + params["LaunchTemplateId"] = launch_template_id + if launch_template_name: + params["LaunchTemplateName"] = launch_template_name + response = { + "UnsuccessfullyDeletedLaunchTemplateVersions": [], + "SuccessfullyDeletedLaunchTemplateVersions": [], + } + # Using this API, You can specify up to 200 launch template version numbers. + for i in range(0, len(versions), 200): + result = client.delete_launch_template_versions(Versions=list(versions[i : i + 200]), **params) + for x in ("SuccessfullyDeletedLaunchTemplateVersions", "UnsuccessfullyDeletedLaunchTemplateVersions"): + response[x] += result.get(x, []) + return response + + +@EC2LaunchTemplateErrorHandler.common_error_handler("delete launch template") +@AWSRetry.jittered_backoff() +def delete_launch_template( + client, launch_template_id: Optional[str] = None, launch_template_name: Optional[str] = None +) -> Dict[str, Any]: + params = {} + if launch_template_id: + params["LaunchTemplateId"] = launch_template_id + if launch_template_name: + params["LaunchTemplateName"] = launch_template_name + return client.delete_launch_template(**params)["LaunchTemplate"] + + +@EC2LaunchTemplateErrorHandler.common_error_handler("create launch template") +@AWSRetry.jittered_backoff() +def create_launch_template( + client, + launch_template_name: str, + launch_template_data: Dict[str, Any], + tags: Optional[EC2TagSpecifications] = None, + **kwargs: Dict[str, Any], +) -> Dict[str, Any]: + params = {"LaunchTemplateName": launch_template_name, "LaunchTemplateData": launch_template_data} + if tags: + params["TagSpecifications"] = boto3_tag_specifications(tags, types="launch-template") + params.update(kwargs) + return client.create_launch_template(**params)["LaunchTemplate"] + + +@EC2LaunchTemplateErrorHandler.common_error_handler("create launch template version") +@AWSRetry.jittered_backoff() +def create_launch_template_version( + client, launch_template_data: Dict[str, Any], **params: Dict[str, Any] +) -> Dict[str, Any]: + return client.create_launch_template_version(LaunchTemplateData=launch_template_data, **params)[ + "LaunchTemplateVersion" + ] + + +@EC2LaunchTemplateErrorHandler.common_error_handler("modify launch template") +@AWSRetry.jittered_backoff() +def modify_launch_template(client, **params: Dict[str, Any]) -> Dict[str, Any]: + return client.modify_launch_template(**params)["LaunchTemplate"] + + def get_ec2_security_group_ids_from_names(sec_group_list, ec2_connection, vpc_id=None, boto3=None): """Return list of security group IDs from security group names. Note that security group names are not unique across VPCs. If a name exists across multiple VPCs and no VPC ID is supplied, all matching IDs will be returned. This @@ -1580,3 +1684,13 @@ def normalize_ec2_vpc_dhcp_config(option_config: List[Dict[str, Any]]) -> Dict[s config_data[option] = [val["Value"] for val in config_item["Values"]] return config_data + + +def determine_iam_arn_from_name(iam_client, name_or_arn: str) -> str: + if validate_aws_arn(name_or_arn, service="iam", resource_type="instance-profile"): + return name_or_arn + + iam_instance_profiles = list_iam_instance_profiles(iam_client, name=name_or_arn) + if not iam_instance_profiles: + raise AnsibleEC2Error(message=f"Could not find IAM instance profile {name_or_arn}") + return iam_instance_profiles[0]["Arn"] diff --git a/plugins/modules/ec2_instance.py b/plugins/modules/ec2_instance.py index 78939e5b90c..639ee189fdc 100644 --- a/plugins/modules/ec2_instance.py +++ b/plugins/modules/ec2_instance.py @@ -1338,8 +1338,6 @@ from ansible.module_utils.common.dict_transformations import snake_dict_to_camel_dict from ansible.module_utils.six import string_types -from ansible_collections.amazon.aws.plugins.module_utils.arn import validate_aws_arn -from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AnsibleEC2Error from ansible_collections.amazon.aws.plugins.module_utils.ec2 import associate_iam_instance_profile from ansible_collections.amazon.aws.plugins.module_utils.ec2 import attach_network_interface @@ -1349,6 +1347,7 @@ from ansible_collections.amazon.aws.plugins.module_utils.ec2 import describe_instances from ansible_collections.amazon.aws.plugins.module_utils.ec2 import describe_subnets from ansible_collections.amazon.aws.plugins.module_utils.ec2 import describe_vpcs +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import determine_iam_arn_from_name from ansible_collections.amazon.aws.plugins.module_utils.ec2 import ensure_ec2_tags from ansible_collections.amazon.aws.plugins.module_utils.ec2 import get_ec2_security_group_ids_from_names from ansible_collections.amazon.aws.plugins.module_utils.ec2 import modify_instance_attribute @@ -1391,12 +1390,14 @@ def add_or_update_instance_profile( client, module: AnsibleAWSModule, instance: Dict[str, Any], desired_profile_name: str ) -> bool: instance_profile_setting = instance.get("IamInstanceProfile") + iam_client = None if instance_profile_setting and desired_profile_name: if desired_profile_name in (instance_profile_setting.get("Name"), instance_profile_setting.get("Arn")): # great, the profile we asked for is what's there return False else: - desired_arn = determine_iam_role(module, desired_profile_name) + iam_client = module.client("iam") + desired_arn = determine_iam_arn_from_name(iam_client, desired_profile_name) if instance_profile_setting.get("Arn") == desired_arn: return False @@ -1409,10 +1410,11 @@ def add_or_update_instance_profile( # check for InvalidAssociationID.NotFound module.fail_json_aws(e, "Could not find instance profile association") try: + iam_client = iam_client or module.client("iam") replace_iam_instance_profile_association( client, association_id=association[0]["AssociationId"], - iam_instance_profile={"Arn": determine_iam_role(module, desired_profile_name)}, + iam_instance_profile={"Arn": determine_iam_arn_from_name(iam_client, desired_profile_name)}, ) return True except AnsibleEC2Error as e: @@ -1421,9 +1423,10 @@ def add_or_update_instance_profile( if not instance_profile_setting and desired_profile_name: # create association try: + iam_client = iam_client or module.client("iam") associate_iam_instance_profile( client, - iam_instance_profile={"Arn": determine_iam_role(module, desired_profile_name)}, + iam_instance_profile={"Arn": determine_iam_arn_from_name(iam_client, desired_profile_name)}, instance_id=instance["InstanceId"], ) return True @@ -1806,7 +1809,9 @@ def build_run_instance_spec(client, module: AnsibleAWSModule, current_count: int # IAM profile if params.get("iam_instance_profile"): - spec["IamInstanceProfile"] = dict(Arn=determine_iam_role(module, params.get("iam_instance_profile"))) + spec["IamInstanceProfile"] = dict( + Arn=determine_iam_arn_from_name(module.client("iam"), params.get("iam_instance_profile")) + ) if params.get("instance_type"): spec["InstanceType"] = params["instance_type"] @@ -2310,25 +2315,6 @@ def pretty_instance(i): return instance -def determine_iam_role(module: AnsibleAWSModule, name_or_arn: Optional[str]) -> str: - if validate_aws_arn(name_or_arn, service="iam", resource_type="instance-profile"): - 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 role["InstanceProfile"]["Arn"] - except is_boto3_error_code("NoSuchEntity") as e: - module.fail_json_aws(e, msg=f"Could not find iam_instance_profile {name_or_arn}") - except ( - botocore.exceptions.ClientError, - botocore.exceptions.BotoCoreError, - ) as e: # pylint: disable=duplicate-except - module.fail_json_aws( - e, - msg=f"An error occurred while searching for iam_instance_profile {name_or_arn}. Please try supplying the full ARN.", - ) - - def modify_instance_type( client, module: AnsibleAWSModule, diff --git a/plugins/modules/ec2_launch_template_info.py b/plugins/modules/ec2_launch_template_info.py new file mode 100644 index 00000000000..bf6bc6a95d1 --- /dev/null +++ b/plugins/modules/ec2_launch_template_info.py @@ -0,0 +1,584 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: ec2_launch_template_info +version_added: 9.0.0 +short_description: Gather information about launch templates and versions +description: + - Gather information about launch templates. +author: + - Aubin Bikouo (@abikouo) +options: + launch_template_ids: + description: The IDs of the launch templates. + type: list + elements: str + filters: + description: + - A dict of filters to apply. Each dict item consists of a filter key and a filter value. + - See U(https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeLaunchTemplates.html) for possible filters. + - Filter names and values are case sensitive. + type: dict + default: {} + +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Note: These examples do not set authentication details, see the AWS Guide for details. + +- name: Gather information about a launch template + amazon.aws.ec2_launch_template_info: + launch_template_ids: + - 'lt-01238c059e3466abc' + +- name: Gather information launch template using name + amazon.aws.ec2_launch_template_info: + filters: + launch-template-name: my-test-launch-template +""" + +RETURN = r""" +launch_templates: + description: A list of launch templates. + returned: always + type: list + elements: dict + contains: + launch_template_id: + description: The ID of the launch template. + type: str + returned: always + launch_template_name: + description: The name of the launch template. + type: str + returned: always + create_time: + description: The time launch template was created. + type: str + returned: always + created_by: + description: The principal that created the launch template. + type: str + returned: always + default_version_number: + description: The version number of the default version of the launch template. + type: int + returned: always + latest_version_number: + description: The version number of the latest version of the launch template. + type: int + returned: always + tags: + description: A dictionary of tags assigned to image. + returned: when AMI is created or already exists + type: dict + sample: { + "Env": "devel", + "Name": "nat-server" + } + versions: + description: All available versions of the launch template. + type: list + elements: dict + returned: always + contains: + launch_template_id: + description: The ID of the launch template. + type: str + returned: always + launch_template_name: + description: The name of the launch template. + type: str + returned: always + create_time: + description: The time the version was created. + type: str + returned: always + created_by: + description: The principal that created the version. + type: str + returned: always + default_version: + description: Indicates whether the version is the default version. + type: bool + returned: always + version_number: + description: The version number. + type: int + returned: always + version_description: + description: The description for the version. + type: str + returned: always + launch_template_data: + description: Information about the launch template. + returned: always + type: dict + contains: + kernel_id: + description: + - The ID of the kernel. + returned: if applicable + type: str + image_id: + description: The ID of the AMI or a Systems Manager parameter. + type: str + returned: if applicable + instance_type: + description: The instance type. + type: str + returned: if applicable + key_name: + description: The name of the key pair. + type: str + returned: if applicable + monitoring: + description: The monitoring for the instance. + type: dict + returned: if applicable + contains: + enabled: + description: Indicates whether detailed monitoring is enabled. Otherwise, basic monitoring is enabled. + type: bool + returned: always + placement: + description: The placement of the instance. + type: dict + returned: if applicable + contains: + availability_zone: + description: The Availability Zone of the instance. + type: str + returned: if applicable + affinity: + description: The affinity setting for the instance on the Dedicated Host. + type: str + returned: if applicable + group_name: + description: The name of the placement group for the instance. + type: str + returned: if applicable + host_id: + description: The ID of the Dedicated Host for the instance. + type: str + returned: if applicable + tenancy: + description: The tenancy of the instance. + type: str + returned: if applicable + host_resource_group_arn: + description: The ARN of the host resource group in which to launch the instances. + type: str + returned: if applicable + partition_number: + description: The number of the partition the instance should launch in. + type: int + returned: if applicable + group_id: + description: The Group ID of the placement group. + type: str + returned: if applicable + ebs_optimized: + description: + - Indicates whether the instance is optimized for Amazon EBS I/O. + type: bool + returned: always + iam_instance_profile: + description: + - The IAM instance profile. + type: dict + returned: if application + contains: + arn: + description: The Amazon Resource Name (ARN) of the instance profile. + type: str + returned: always + name: + description: The name of the instance profile. + type: str + returned: always + block_device_mappings: + description: The block device mappings. + type: list + elements: dict + returned: if applicable + contains: + device_name: + description: The device name. + type: str + returned: always + virtual_name: + description: The virtual device name. + type: str + returned: always + ebs: + description: Information about the block device for an EBS volume. + type: str + returned: if applicable + contains: + encrypted: + description: Indicates whether the EBS volume is encrypted. + type: bool + returned: always + delete_on_termination: + description: Indicates whether the EBS volume is deleted on instance termination. + type: bool + returned: always + iops: + description: The number of I/O operations per second (IOPS) that the volume supports. + type: int + returned: always + kms_key_id: + description: The ARN of the Key Management Service (KMS) CMK used for encryption. + type: int + returned: always + snapshot_id: + description: The ID of the snapshot. + type: str + returned: always + volume_size: + description: The size of the volume, in GiB. + type: int + returned: always + volume_type: + description: The volume type. + type: str + returned: always + throughput: + description: The throughput that the volume supports, in MiB/s. + type: int + returned: always + no_device: + description: To omit the device from the block device mapping, specify an empty string. + type: str + network_interfaces: + description: The network interfaces. + type: list + elements: dict + returned: if applicable + contains: + associate_carrier_ip_address: + description: Indicates whether to associate a Carrier IP address with eth0 for a new network interface. + type: bool + returned: always + associate_public_ip_address: + description: Indicates whether to associate a public IPv4 address with eth0 for a new network interface. + type: bool + returned: always + delete_on_termination: + description: Indicates whether the network interface is deleted when the instance is terminated. + type: bool + returned: always + description: + description: A description for the network interface. + type: str + returned: always + device_index: + description: The device index for the network interface attachment. + type: int + returned: always + groups: + description: The IDs of one or more security groups. + type: list + elements: str + returned: if applicable + interface_type: + description: The type of network interface. + type: str + returned: always + ipv6_address_count: + description: The number of IPv6 addresses for the network interface. + type: int + returned: if applicable + ipv6_addresses: + description: The IPv6 addresses for the network interface. + returned: if applicable + type: list + elements: dict + contains: + ipv6_address: + description: The IPv6 address. + type: str + returned: always + is_primary_ipv6: + description: Determines if an IPv6 address associated with a network interface is the primary IPv6 address. + type: bool + returned: always + network_interface_id: + description: The ID of the network interface. + type: str + returned: always + private_ip_address: + description: The primary private IPv4 address of the network interface. + type: str + returned: if applicable + private_ip_addresses: + description: A list of private IPv4 addresses. + type: list + elements: str + returned: if applicable + contains: + primary: + description: Indicates whether the private IPv4 address is the primary private IPv4 address. + type: bool + returned: always + private_ip_address: + description: The private IPv4 address. + type: bool + returned: always + secondary_private_ip_address_count: + description: The number of secondary private IPv4 addresses for the network interface. + type: int + returned: if applicable + subnet_id: + description: The ID of the subnet for the network interface. + type: str + returned: always + network_card_index: + description: The index of the network card. + type: int + returned: if applicable + ipv4_prefixes: + description: A list of IPv4 prefixes assigned to the network interface. + type: list + elements: dict + returned: if applicable + contains: + ipv4_prefix: + description: The IPv4 delegated prefixes assigned to the network interface. + type: str + returned: always + ipv4_prefix_count: + description: The number of IPv4 prefixes that Amazon Web Services automatically assigned to the network interface. + type: int + returned: if applicable + ipv6_prefixes: + description: A list of IPv6 prefixes assigned to the network interface. + type: list + elements: dict + returned: if applicable + contains: + ipv6_prefix: + description: The IPv6 delegated prefixes assigned to the network interface. + type: str + returned: always + ipv6_prefix_count: + description: The number of IPv6 prefixes that Amazon Web Services automatically assigned to the network interface. + type: int + returned: if applicable + primary_ipv6: + description: The primary IPv6 address of the network interface. + type: str + returned: if applicable + ena_srd_specification: + description: Contains the ENA Express settings for instances launched from your launch template. + type: dict + returned: if applicable + contains: + ena_srd_enabled: + description: Indicates whether ENA Express is enabled for the network interface. + type: bool + returned: always + ena_srd_udp_specification: + description: Configures ENA Express for UDP network traffic. + type: dict + returned: always + contains: + ena_srd_udp_enabled: + description: Indicates whether UDP traffic to and from the instance uses ENA Express. + type: bool + returned: always + connection_tracking_specification: + description: + - A security group connection tracking specification that enables you to set the timeout + for connection tracking on an Elastic network interface. + type: dict + returned: if applicable + contains: + tcp_established_timeout: + description: Timeout (in seconds) for idle TCP connections in an established state. + type: int + returned: always + udp_timeout: + description: + - Timeout (in seconds) for idle UDP flows that have seen traffic only in a single direction + or a single request-response transaction. + type: int + returned: always + udp_stream_timeout: + description: + - Timeout (in seconds) for idle UDP flows classified as streams which have seen more + than one request-response transaction. + type: int + returned: always + ram_disk_id: + description: The ID of the RAM disk, if applicable. + type: str + returned: if applicable + disable_api_termination: + description: If set to true, indicates that the instance cannot be terminated using the Amazon EC2 console, command line tool, or API. + type: bool + returned: if applicable + instance_initiated_shutdown_behavior: + description: Indicates whether an instance stops or terminates when you initiate shutdown from the instance. + type: str + returned: if applicable + user_data: + description: The user data for the instance. + type: str + returned: if applicable + tag_specifications: + description: The tags that are applied to the resources that are created during instance launch. + type: list + elements: dict + returned: if applicable + contains: + resource_type: + description: The type of resource to tag. + type: str + returned: always + tags: + description: The tags for the resource. + type: list + elements: dict + contains: + key: + description: The key of the tag. + type: str + returned: always + value: + description: The value of the tag. + type: str + returned: always + enclave_options: + description: Indicates whether the instance is enabled for Amazon Web Services Nitro Enclaves. + type: dict + returned: if applicable + contains: + enabled: + description: If this parameter is set to true, the instance is enabled for Amazon Web Services Nitro Enclaves. + type: bool + returned: always + metadata_options: + description: The metadata options for the instance. + type: dict + returned: if applicable + contains: + state: + description: The state of the metadata option changes. + type: str + returned: if applicable + http_tokens: + description: Indicates whether IMDSv2 is required. + type: str + returned: if applicable + http_put_response_hop_limit: + description: The desired HTTP PUT response hop limit for instance metadata requests. + type: int + returned: if applicable + http_endpoint: + description: Enables or disables the HTTP metadata endpoint on your instances. + type: str + returned: if applicable + http_protocol_ipv6: + description: Enables or disables the IPv6 endpoint for the instance metadata service. + type: str + returned: if applicable + instance_metadata_tags: + description: Set to enabled to allow access to instance tags from the instance metadata. + type: str + returned: if applicable + cpu_options: + description: The CPU options for the instance. + type: dict + returned: if applicable + contains: + core_count: + description: The number of CPU cores for the instance. + type: int + returned: if applicable + threads_per_core: + description: The number of threads per CPU core. + type: int + returned: if applicable + amd_sev_snp: + description: Indicates whether the instance is enabled for AMD SEV-SNP. + type: int + returned: if applicable + security_group_ids: + description: The security group IDs. + type: list + elements: str + returned: if applicable + security_groups: + description: The security group names. + type: list + elements: str + returned: if applicable +""" + +from typing import Any +from typing import Dict +from typing import List + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict + +from ansible_collections.amazon.aws.plugins.module_utils.botocore import normalize_boto3_result +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AnsibleEC2Error +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import describe_launch_template_versions +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import describe_launch_templates +from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.tagging import boto3_tag_list_to_ansible_dict +from ansible_collections.amazon.aws.plugins.module_utils.transformation import ansible_dict_to_boto3_filter_list + + +def list_launch_templates(client, module: AnsibleAWSModule) -> List[Dict[str, Any]]: + try: + # Describe launch templates + launch_templates = describe_launch_templates( + client, + launch_template_ids=module.params.get("launch_template_ids"), + filters=ansible_dict_to_boto3_filter_list(module.params.get("filters")), + ) + + # Describe launch templates versions + for template in launch_templates: + template["Versions"] = describe_launch_template_versions( + client, LaunchTemplateId=template["LaunchTemplateId"] + ) + + # format output + launch_templates = [camel_dict_to_snake_dict(t, ignore_list=["Tags"]) for t in launch_templates] + for t in launch_templates: + t["tags"] = boto3_tag_list_to_ansible_dict(t.pop("tags", {})) + + return normalize_boto3_result(launch_templates) + + except AnsibleEC2Error as e: + module.fail_json_aws_error(e) + + +def main(): + argument_spec = dict( + launch_template_ids=dict(type="list", elements="str"), + filters=dict(default={}, type="dict"), + ) + + module = AnsibleAWSModule(argument_spec=argument_spec, supports_check_mode=True) + client = module.client("ec2") + + launch_templates = list_launch_templates(client, module) + module.exit_json(launch_templates=launch_templates) + + +if __name__ == "__main__": + main() diff --git a/tests/unit/module_utils/ec2/test_determine_iam_role.py b/tests/unit/module_utils/ec2/test_determine_iam_role.py new file mode 100644 index 00000000000..bb7ac59263a --- /dev/null +++ b/tests/unit/module_utils/ec2/test_determine_iam_role.py @@ -0,0 +1,75 @@ +# (c) 2022 Red Hat Inc. +# +# This file is part of Ansible +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from unittest.mock import MagicMock +from unittest.mock import sentinel + +import pytest + +import ansible_collections.amazon.aws.plugins.module_utils.arn as utils_arn +import ansible_collections.amazon.aws.plugins.module_utils.ec2 as ec2_utils +from ansible_collections.amazon.aws.plugins.module_utils.botocore import HAS_BOTO3 + +try: + import botocore +except ImportError: + pass + +pytest.mark.skipif( + not HAS_BOTO3, reason="test_determine_iam_role.py requires the python modules 'boto3' and 'botocore'" +) + + +def _client_error(code="GenericError"): + return botocore.exceptions.ClientError( + { + "Error": {"Code": code, "Message": "Something went wrong"}, + "ResponseMetadata": {"RequestId": "01234567-89ab-cdef-0123-456789abcdef"}, + }, + "some_called_method", + ) + + +class FailJsonException(Exception): + def __init__(self): + pass + + +@pytest.fixture +def ec2_utils_fixture(monkeypatch): + monkeypatch.setattr(ec2_utils, "validate_aws_arn", lambda arn, service, resource_type: None) + return ec2_utils + + +@pytest.fixture +def iam_client(): + client = MagicMock() + return client + + +def test_determine_iam_role_arn(ec2_utils_fixture, iam_client, monkeypatch): + # Revert the default monkey patch to make it simple to try passing a valid ARNs + monkeypatch.setattr(ec2_utils_fixture, "validate_aws_arn", utils_arn.validate_aws_arn) + + # Simplest example, someone passes a valid instance profile ARN + arn = ec2_utils_fixture.determine_iam_arn_from_name( + iam_client, "arn:aws:iam::123456789012:instance-profile/myprofile" + ) + assert arn == "arn:aws:iam::123456789012:instance-profile/myprofile" + + +def test_determine_iam_role_name(ec2_utils_fixture, iam_client, monkeypatch): + monkeypatch.setattr( + ec2_utils_fixture, "list_iam_instance_profiles", lambda arn, **kwargs: [{"Arn": sentinel.IAM_PROFILE_ARN}] + ) + arn = ec2_utils_fixture.determine_iam_arn_from_name(iam_client, sentinel.IAM_PROFILE_NAME) + assert arn == sentinel.IAM_PROFILE_ARN + + +def test_determine_iam_role_missing(ec2_utils_fixture, iam_client, monkeypatch): + monkeypatch.setattr(ec2_utils_fixture, "list_iam_instance_profiles", lambda arn, **kwargs: []) + with pytest.raises(ec2_utils.AnsibleEC2Error) as excinfo: + ec2_utils_fixture.determine_iam_arn_from_name(iam_client, sentinel.IAM_PROFILE_NAME) + assert "Could not find IAM instance profile" in str(excinfo.value) diff --git a/tests/unit/plugins/modules/ec2_instance/test_build_run_instance_spec.py b/tests/unit/plugins/modules/ec2_instance/test_build_run_instance_spec.py index 7f45f02f80c..07ab282148d 100644 --- a/tests/unit/plugins/modules/ec2_instance/test_build_run_instance_spec.py +++ b/tests/unit/plugins/modules/ec2_instance/test_build_run_instance_spec.py @@ -35,7 +35,9 @@ def ec2_instance(monkeypatch): monkeypatch.setattr(ec2_instance_module, "build_network_spec", lambda client, params: sentinel.NETWORK_SPEC) monkeypatch.setattr(ec2_instance_module, "build_volume_spec", lambda params: sentinel.VOlUME_SPEC) monkeypatch.setattr(ec2_instance_module, "build_instance_tags", lambda params: sentinel.TAG_SPEC) - monkeypatch.setattr(ec2_instance_module, "determine_iam_role", lambda module, name_or_arn: sentinel.IAM_PROFILE_ARN) + monkeypatch.setattr( + ec2_instance_module, "determine_iam_arn_from_name", lambda module, name_or_arn: sentinel.IAM_PROFILE_ARN + ) return ec2_instance_module diff --git a/tests/unit/plugins/modules/ec2_instance/test_determine_iam_role.py b/tests/unit/plugins/modules/ec2_instance/test_determine_iam_role.py deleted file mode 100644 index edcc7c043e2..00000000000 --- a/tests/unit/plugins/modules/ec2_instance/test_determine_iam_role.py +++ /dev/null @@ -1,98 +0,0 @@ -# (c) 2022 Red Hat Inc. -# -# This file is part of Ansible -# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) - -import sys -from unittest.mock import MagicMock -from unittest.mock import sentinel - -import pytest - -import ansible_collections.amazon.aws.plugins.module_utils.arn as utils_arn -import ansible_collections.amazon.aws.plugins.modules.ec2_instance as ec2_instance_module -from ansible_collections.amazon.aws.plugins.module_utils.botocore import HAS_BOTO3 - -try: - import botocore -except ImportError: - pass - -pytest.mark.skipif( - not HAS_BOTO3, reason="test_determine_iam_role.py requires the python modules 'boto3' and 'botocore'" -) - - -def _client_error(code="GenericError"): - return botocore.exceptions.ClientError( - { - "Error": {"Code": code, "Message": "Something went wrong"}, - "ResponseMetadata": {"RequestId": "01234567-89ab-cdef-0123-456789abcdef"}, - }, - "some_called_method", - ) - - -class FailJsonException(Exception): - def __init__(self): - pass - - -@pytest.fixture -def ec2_instance(monkeypatch): - monkeypatch.setattr(ec2_instance_module, "validate_aws_arn", lambda arn, service, resource_type: None) - return ec2_instance_module - - -@pytest.fixture -def ansible_module(): - module = MagicMock() - module.fail_json.side_effect = FailJsonException() - module.fail_json_aws.side_effect = FailJsonException() - return module - - -def test_determine_iam_role_arn(ec2_instance, ansible_module, monkeypatch): - # Revert the default monkey patch to make it simple to try passing a valid ARNs - monkeypatch.setattr(ec2_instance, "validate_aws_arn", utils_arn.validate_aws_arn) - - # Simplest example, someone passes a valid instance profile ARN - arn = ec2_instance.determine_iam_role(ansible_module, "arn:aws:iam::123456789012:instance-profile/myprofile") - assert arn == "arn:aws:iam::123456789012:instance-profile/myprofile" - - -def test_determine_iam_role_name(ec2_instance, ansible_module): - profile_description = {"InstanceProfile": {"Arn": sentinel.IAM_PROFILE_ARN}} - iam_client = MagicMock(**{"get_instance_profile.return_value": profile_description}) - ansible_module.client.return_value = iam_client - - arn = ec2_instance.determine_iam_role(ansible_module, sentinel.IAM_PROFILE_NAME) - assert arn == sentinel.IAM_PROFILE_ARN - - -def test_determine_iam_role_missing(ec2_instance, ansible_module): - missing_exception = _client_error("NoSuchEntity") - iam_client = MagicMock(**{"get_instance_profile.side_effect": missing_exception}) - ansible_module.client.return_value = iam_client - - with pytest.raises(FailJsonException): - ec2_instance.determine_iam_role(ansible_module, sentinel.IAM_PROFILE_NAME) - - assert ansible_module.fail_json_aws.call_count == 1 - assert ansible_module.fail_json_aws.call_args.args[0] is missing_exception - assert "Could not find" in ansible_module.fail_json_aws.call_args.kwargs["msg"] - - -@pytest.mark.skipif(sys.version_info < (3, 8), reason="call_args behaviour changed in Python 3.8") -def test_determine_iam_role_missing(ec2_instance, ansible_module): - missing_exception = _client_error() - iam_client = MagicMock(**{"get_instance_profile.side_effect": missing_exception}) - ansible_module.client.return_value = iam_client - - with pytest.raises(FailJsonException): - ec2_instance.determine_iam_role(ansible_module, sentinel.IAM_PROFILE_NAME) - - assert ansible_module.fail_json_aws.call_count == 1 - assert ansible_module.fail_json_aws.call_args.args[0] is missing_exception - assert "An error occurred while searching" in ansible_module.fail_json_aws.call_args.kwargs["msg"] - assert "Please try supplying the full ARN" in ansible_module.fail_json_aws.call_args.kwargs["msg"]