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 94614615f1e..13f88e22309 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 8dfeb59cc14..78bd99b8367 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 @@ -1110,7 +1111,6 @@ def authorize_security_group_egress(client, **params: Dict[str, Any]) -> bool: # EC2 Egress only internet Gateway class EC2EgressOnlyInternetGatewayErrorHandler(AWSErrorHandler): - _CUSTOM_EXCEPTION = AnsibleEC2Error @classmethod def _is_missing(cls): @@ -1241,6 +1241,99 @@ def replace_network_acl_association(client, network_acl_id: str, association_id: ] +# 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 = {"Versions": versions} + if launch_template_id: + params["LaunchTemplateId"] = launch_template_id + if launch_template_name: + params["LaunchTemplateName"] = launch_template_name + return client.delete_launch_template_versions(**params) + + +@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 @@ -1462,3 +1555,21 @@ 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(module, name_or_arn: 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: + return iam.get_instance_profile(InstanceProfileName=name_or_arn, aws_retry=True)["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.", + ) diff --git a/plugins/modules/ec2_instance.py b/plugins/modules/ec2_instance.py index 78939e5b90c..3f94142208f 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 @@ -1396,7 +1395,7 @@ def add_or_update_instance_profile( # great, the profile we asked for is what's there return False else: - desired_arn = determine_iam_role(module, desired_profile_name) + desired_arn = determine_iam_arn_from_name(module, desired_profile_name) if instance_profile_setting.get("Arn") == desired_arn: return False @@ -1412,7 +1411,7 @@ def add_or_update_instance_profile( 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(module, desired_profile_name)}, ) return True except AnsibleEC2Error as e: @@ -1423,7 +1422,7 @@ def add_or_update_instance_profile( try: 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(module, desired_profile_name)}, instance_id=instance["InstanceId"], ) return True @@ -1806,7 +1805,7 @@ 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, params.get("iam_instance_profile"))) if params.get("instance_type"): spec["InstanceType"] = params["instance_type"] @@ -2310,25 +2309,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..e520fd9f12c --- /dev/null +++ b/plugins/modules/ec2_launch_template_info.py @@ -0,0 +1,209 @@ +#!/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 + sample: { + "BlockDeviceMappings": [ + { + "DeviceName": "/dev/sdb", + "Ebs": { + "DeleteOnTermination": true, + "Encrypted": true, + "VolumeSize": 5 + } + } + ], + "EbsOptimized": false, + "ImageId": "ami-0231217be14a6f3ba", + "InstanceType": "t2.micro", + "NetworkInterfaces": [ + { + "AssociatePublicIpAddress": false, + "DeviceIndex": 0, + "Ipv6Addresses": [ + { + "Ipv6Address": "2001:0:130F:0:0:9C0:876A:130B" + } + ] + } + ] + } +""" + +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()