From c648c0f95767e040b33d988fcace705180e552d8 Mon Sep 17 00:00:00 2001 From: Timothy Klopotoski Date: Fri, 16 Sep 2022 06:01:43 -0400 Subject: [PATCH] Add CloudFormation support to EC2 Launch Templates (#5477) --- moto/ec2/exceptions.py | 8 + moto/ec2/models/launch_templates.py | 91 ++++++++++- moto/ec2/utils.py | 9 +- tests/test_ec2/test_ec2_cloudformation.py | 181 ++++++++++++++++++++++ tests/test_ec2/test_launch_templates.py | 15 ++ 5 files changed, 298 insertions(+), 6 deletions(-) diff --git a/moto/ec2/exceptions.py b/moto/ec2/exceptions.py index fbb3d9a9d..7a2ee1f04 100644 --- a/moto/ec2/exceptions.py +++ b/moto/ec2/exceptions.py @@ -718,6 +718,14 @@ class InvalidLaunchTemplateNameNotFoundError(EC2ClientError): ) +class InvalidLaunchTemplateNameNotFoundWithNameError(EC2ClientError): + def __init__(self, name): + super().__init__( + "InvalidLaunchTemplateName.NotFoundException", + f"The specified launch template, with template name {name}, does not exist", + ) + + class InvalidParameterDependency(EC2ClientError): def __init__(self, param, param_needed): super().__init__( diff --git a/moto/ec2/models/launch_templates.py b/moto/ec2/models/launch_templates.py index 4a8e7e06c..1a24bca40 100644 --- a/moto/ec2/models/launch_templates.py +++ b/moto/ec2/models/launch_templates.py @@ -1,9 +1,17 @@ from collections import OrderedDict + +from moto.core import CloudFormationModel from .core import TaggedEC2Resource -from ..utils import generic_filter, random_launch_template_id, utc_date_and_time +from ..utils import ( + generic_filter, + random_launch_template_id, + utc_date_and_time, + convert_tag_spec, +) from ..exceptions import ( InvalidLaunchTemplateNameAlreadyExistsError, InvalidLaunchTemplateNameNotFoundError, + InvalidLaunchTemplateNameNotFoundWithNameError, ) @@ -32,7 +40,7 @@ class LaunchTemplateVersion(object): return self.data.get("UserData", "") -class LaunchTemplate(TaggedEC2Resource): +class LaunchTemplate(TaggedEC2Resource, CloudFormationModel): def __init__(self, backend, name, template_data, version_description, tag_spec): self.ec2_backend = backend self.name = name @@ -72,12 +80,89 @@ class LaunchTemplate(TaggedEC2Resource): def latest_version_number(self): return self.latest_version().number + @property + def physical_resource_id(self): + return self.id + def get_filter_value(self, filter_name): if filter_name == "launch-template-name": return self.name else: return super().get_filter_value(filter_name, "DescribeLaunchTemplates") + @staticmethod + def cloudformation_name_type(): + return "LaunchTemplateName" + + @staticmethod + def cloudformation_type(): + # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-launchtemplate.html + return "AWS::EC2::LaunchTemplate" + + @classmethod + def create_from_cloudformation_json( + cls, resource_name, cloudformation_json, account_id, region_name, **kwargs + ): + + from ..models import ec2_backends + + backend = ec2_backends[account_id][region_name] + + properties = cloudformation_json["Properties"] + name = properties.get("LaunchTemplateName") + data = properties.get("LaunchTemplateData") + description = properties.get("VersionDescription") + tag_spec = convert_tag_spec( + properties.get("TagSpecifications", {}), tag_key="Tags" + ) + + launch_template = backend.create_launch_template( + name, description, data, tag_spec + ) + + return launch_template + + @classmethod + def update_from_cloudformation_json( + cls, + original_resource, + new_resource_name, + cloudformation_json, + account_id, + region_name, + ): + + from ..models import ec2_backends + + backend = ec2_backends[account_id][region_name] + + properties = cloudformation_json["Properties"] + + name = properties.get("LaunchTemplateName") + data = properties.get("LaunchTemplateData") + description = properties.get("VersionDescription") + + launch_template = backend.get_launch_template_by_name(name) + + launch_template.create_version(data, description) + + return launch_template + + @classmethod + def delete_from_cloudformation_json( + cls, resource_name, cloudformation_json, account_id, region_name + ): + + from ..models import ec2_backends + + backend = ec2_backends[account_id][region_name] + + properties = cloudformation_json["Properties"] + + name = properties.get("LaunchTemplateName") + + backend.delete_launch_template(name, None) + class LaunchTemplateBackend: def __init__(self): @@ -98,6 +183,8 @@ class LaunchTemplateBackend: return self.launch_templates[template_id] def get_launch_template_by_name(self, name): + if name not in self.launch_template_name_to_ids: + raise InvalidLaunchTemplateNameNotFoundWithNameError(name) return self.get_launch_template(self.launch_template_name_to_ids[name]) def delete_launch_template(self, name, tid): diff --git a/moto/ec2/utils.py b/moto/ec2/utils.py index 4978ee47e..1c080b34f 100644 --- a/moto/ec2/utils.py +++ b/moto/ec2/utils.py @@ -787,14 +787,15 @@ def gen_moto_amis(described_images, drop_images_missing_keys=True): return result -def convert_tag_spec(tag_spec_set): - # IN: [{"ResourceType": _type, "Tag": [{"Key": k, "Value": v}, ..]}] - # OUT: {_type: {k: v, ..}} +def convert_tag_spec(tag_spec_set, tag_key="Tag"): + # IN: [{"ResourceType": _type, "Tag": [{"Key": k, "Value": v}, ..]}] + # (or) [{"ResourceType": _type, "Tags": [{"Key": k, "Value": v}, ..]}] <-- special cfn case + # OUT: {_type: {k: v, ..}} tags = {} for tag_spec in tag_spec_set: if tag_spec["ResourceType"] not in tags: tags[tag_spec["ResourceType"]] = {} tags[tag_spec["ResourceType"]].update( - {tag["Key"]: tag["Value"] for tag in tag_spec["Tag"]} + {tag["Key"]: tag["Value"] for tag in tag_spec[tag_key]} ) return tags diff --git a/tests/test_ec2/test_ec2_cloudformation.py b/tests/test_ec2/test_ec2_cloudformation.py index 6f15b657e..a3120f663 100644 --- a/tests/test_ec2/test_ec2_cloudformation.py +++ b/tests/test_ec2/test_ec2_cloudformation.py @@ -760,3 +760,184 @@ def test_vpc_endpoint_creation(): endpoint.should.have.key("State").equals("available") endpoint.should.have.key("SubnetIds").equals([subnet1.id]) endpoint.should.have.key("VpcEndpointType").equals("GatewayLoadBalancer") + + +@mock_cloudformation +@mock_ec2 +def test_launch_template_create(): + + cf = boto3.client("cloudformation", region_name="us-west-1") + ec2 = boto3.client("ec2", region_name="us-west-1") + + launch_template_name = str(uuid4())[0:6] + logical_id = str(uuid4())[0:6] + stack_name = str(uuid4())[0:6] + + lt_tags = [{"Key": "lt-tag-key", "Value": "lt-tag-value"}] + + template_json = json.dumps( + { + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": { + logical_id: { + "Type": "AWS::EC2::LaunchTemplate", + "Properties": { + "LaunchTemplateName": launch_template_name, + "VersionDescription": "some template", + "LaunchTemplateData": { + "TagSpecifications": [ + { + "ResourceType": "instance", + "Tags": [ + {"Key": "i-tag-key", "Value": "i-tag-value"} + ], + } + ] + }, + "TagSpecifications": [ + { + "ResourceType": "launch-template", + "Tags": lt_tags, + } + ], + }, + } + }, + "Outputs": { + "LaunchTemplateId": { + "Description": "The ID of the created launch template", + "Value": {"Ref": logical_id}, + }, + }, + } + ) + + cf.create_stack(StackName=stack_name, TemplateBody=template_json) + + resources = cf.list_stack_resources(StackName=stack_name)["StackResourceSummaries"] + resources.should.have.length_of(1) + resources[0].should.have.key("LogicalResourceId").equals(logical_id) + launch_template_id = resources[0]["PhysicalResourceId"] + + outputs = cf.describe_stacks(StackName=stack_name)["Stacks"][0]["Outputs"] + outputs.should.have.length_of(1) + outputs[0].should.equal( + {"OutputKey": "LaunchTemplateId", "OutputValue": launch_template_id} + ) + + launch_template = ec2.describe_launch_templates( + LaunchTemplateNames=[launch_template_name] + )["LaunchTemplates"][0] + launch_template.should.have.key("LaunchTemplateName").equals(launch_template_name) + launch_template.should.have.key("LaunchTemplateId").equals(launch_template_id) + launch_template.should.have.key("Tags").equals(lt_tags) + + launch_template_version = ec2.describe_launch_template_versions( + LaunchTemplateName=launch_template_name + )["LaunchTemplateVersions"][0] + launch_template_version.should.have.key("LaunchTemplateName").equals( + launch_template_name + ) + launch_template_version.should.have.key("LaunchTemplateId").equals( + launch_template_id + ) + launch_template_version["LaunchTemplateData"]["TagSpecifications"][ + 0 + ].should.have.key("ResourceType").equals("instance") + + +@mock_cloudformation +@mock_ec2 +def test_launch_template_update(): + + cf = boto3.client("cloudformation", region_name="us-west-1") + ec2 = boto3.client("ec2", region_name="us-west-1") + + launch_template_name = str(uuid4())[0:6] + logical_id = str(uuid4())[0:6] + stack_name = str(uuid4())[0:6] + + template_json = json.dumps( + { + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": { + logical_id: { + "Type": "AWS::EC2::LaunchTemplate", + "Properties": { + "LaunchTemplateName": launch_template_name, + "VersionDescription": "some template", + "LaunchTemplateData": {"UserData": ""}, + }, + } + }, + } + ) + + cf.create_stack(StackName=stack_name, TemplateBody=template_json) + + template_json = json.dumps( + { + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": { + logical_id: { + "Type": "AWS::EC2::LaunchTemplate", + "Properties": { + "LaunchTemplateName": launch_template_name, + "VersionDescription": "a better template", + "LaunchTemplateData": {"UserData": ""}, + }, + } + }, + } + ) + + cf.update_stack(StackName=stack_name, TemplateBody=template_json) + + resources = cf.list_stack_resources(StackName=stack_name)["StackResourceSummaries"] + resources.should.have.length_of(1) + + launch_template_versions = ec2.describe_launch_template_versions( + LaunchTemplateName=launch_template_name + )["LaunchTemplateVersions"] + launch_template_versions.should.have.length_of(2) + launch_template_versions[0].should.have.key("VersionDescription").equals( + "some template" + ) + launch_template_versions[1].should.have.key("VersionDescription").equals( + "a better template" + ) + + +@mock_cloudformation +@mock_ec2 +def test_launch_template_delete(): + + cf = boto3.client("cloudformation", region_name="us-west-1") + ec2 = boto3.client("ec2", region_name="us-west-1") + + launch_template_name = str(uuid4())[0:6] + logical_id = str(uuid4())[0:6] + stack_name = str(uuid4())[0:6] + + template_json = json.dumps( + { + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": { + logical_id: { + "Type": "AWS::EC2::LaunchTemplate", + "Properties": { + "LaunchTemplateName": launch_template_name, + "VersionDescription": "some template", + "LaunchTemplateData": {"UserData": ""}, + }, + } + }, + } + ) + cf.create_stack(StackName=stack_name, TemplateBody=template_json) + + cf.delete_stack(StackName=stack_name) + + ec2.describe_launch_templates(LaunchTemplateNames=[launch_template_name])[ + "LaunchTemplates" + ].should.have.length_of(0) diff --git a/tests/test_ec2/test_launch_templates.py b/tests/test_ec2/test_launch_templates.py index 645c7d776..f6142b0a3 100644 --- a/tests/test_ec2/test_launch_templates.py +++ b/tests/test_ec2/test_launch_templates.py @@ -86,6 +86,21 @@ def test_describe_launch_template_versions(): templ.should.equal(template_data) +@mock_ec2 +def test_describe_launch_template_versions_by_name_when_absent(): + cli = boto3.client("ec2", region_name="us-east-1") + + template_name = "foo" + + # test using name + with pytest.raises( + ClientError, + match=f"The specified launch template, with template name {template_name}, does not exist", + ): + + cli.describe_launch_template_versions(LaunchTemplateName=template_name) + + @mock_ec2 def test_create_launch_template_version(): cli = boto3.client("ec2", region_name="us-east-1")