From 0115267f2ace863715f2a94aa4767144e85a2add Mon Sep 17 00:00:00 2001 From: Hugo Lopes Tavares Date: Tue, 20 Dec 2016 10:37:18 -0500 Subject: [PATCH] Add ECS CloudFormation support (#795) * Add cloudformation support to AWS::ECS::Cluster * Add CloudFormation support to AWS::ECS::TaskDefinition * Add CloudFormation support to AWS::ECS::Service * Add support to update AWS::ECS::Cluster through CloudFormation * Fix Cluster.update_from_cloudformation_json to return original_resource if nothing changed * Implement TaskDefinition.update_from_cloudformation_json * Implement Service.update_from_cloudformation_json --- moto/cloudformation/parsing.py | 7 + moto/ecs/models.py | 106 ++++++++++++- tests/test_ecs/test_ecs_boto3.py | 255 +++++++++++++++++++++++++++++++ 3 files changed, 366 insertions(+), 2 deletions(-) diff --git a/moto/cloudformation/parsing.py b/moto/cloudformation/parsing.py index 971adbbf6..3e348ac37 100644 --- a/moto/cloudformation/parsing.py +++ b/moto/cloudformation/parsing.py @@ -3,11 +3,13 @@ import collections import functools import logging import copy +import warnings from moto.autoscaling import models as autoscaling_models from moto.awslambda import models as lambda_models from moto.datapipeline import models as datapipeline_models from moto.ec2 import models as ec2_models +from moto.ecs import models as ecs_models from moto.elb import models as elb_models from moto.iam import models as iam_models from moto.kms import models as kms_models @@ -43,6 +45,9 @@ MODEL_MAP = { "AWS::EC2::VPC": ec2_models.VPC, "AWS::EC2::VPCGatewayAttachment": ec2_models.VPCGatewayAttachment, "AWS::EC2::VPCPeeringConnection": ec2_models.VPCPeeringConnection, + "AWS::ECS::Cluster": ecs_models.Cluster, + "AWS::ECS::TaskDefinition": ecs_models.TaskDefinition, + "AWS::ECS::Service": ecs_models.Service, "AWS::ElasticLoadBalancing::LoadBalancer": elb_models.FakeLoadBalancer, "AWS::DataPipeline::Pipeline": datapipeline_models.Pipeline, "AWS::IAM::InstanceProfile": iam_models.InstanceProfile, @@ -175,6 +180,8 @@ def parse_resource(logical_id, resource_json, resources_map): resource_type = resource_json['Type'] resource_class = resource_class_from_type(resource_type) if not resource_class: + warnings.warn( + "Tried to parse {0} but it's not supported by moto's CloudFormation implementation".format(resource_type)) return None resource_json = clean_json(resource_json, resources_map) diff --git a/moto/ecs/models.py b/moto/ecs/models.py index 0b3b3ec49..cdb04fcd6 100644 --- a/moto/ecs/models.py +++ b/moto/ecs/models.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals import uuid -from random import randint +from random import randint, random from moto.core import BaseBackend from moto.ec2 import ec2_backends @@ -48,13 +48,39 @@ class Cluster(BaseObject): del response_object['arn'], response_object['name'] return response_object + @classmethod + def create_from_cloudformation_json(cls, resource_name, cloudformation_json, region_name): + properties = cloudformation_json['Properties'] + + ecs_backend = ecs_backends[region_name] + return ecs_backend.create_cluster( + # ClusterName is optional in CloudFormation, thus create a random name if necessary + cluster_name=properties.get('ClusterName', 'ecscluster{0}'.format(int(random() * 10 ** 6))), + ) + @classmethod + def update_from_cloudformation_json(cls, original_resource, new_resource_name, cloudformation_json, region_name): + properties = cloudformation_json['Properties'] + + if original_resource.name != properties['ClusterName']: + ecs_backend = ecs_backends[region_name] + ecs_backend.delete_cluster(original_resource.arn) + return ecs_backend.create_cluster( + # ClusterName is optional in CloudFormation, thus create a random name if necessary + cluster_name=properties.get('ClusterName', 'ecscluster{0}'.format(int(random() * 10 ** 6))), + ) + else: + # no-op when nothing changed between old and new resources + return original_resource + class TaskDefinition(BaseObject): def __init__(self, family, revision, container_definitions, volumes=None): self.family = family self.arn = 'arn:aws:ecs:us-east-1:012345678910:task-definition/{0}:{1}'.format(family, revision) self.container_definitions = container_definitions - if volumes is not None: + if volumes is None: + self.volumes = [] + else: self.volumes = volumes @property @@ -64,6 +90,37 @@ class TaskDefinition(BaseObject): del response_object['arn'] return response_object + @classmethod + def create_from_cloudformation_json(cls, resource_name, cloudformation_json, region_name): + properties = cloudformation_json['Properties'] + + family = properties.get('Family', 'task-definition-{0}'.format(int(random() * 10 ** 6))) + container_definitions = properties['ContainerDefinitions'] + volumes = properties['Volumes'] + + ecs_backend = ecs_backends[region_name] + return ecs_backend.register_task_definition( + family=family, container_definitions=container_definitions, volumes=volumes) + + @classmethod + def update_from_cloudformation_json(cls, original_resource, new_resource_name, cloudformation_json, region_name): + properties = cloudformation_json['Properties'] + + family = properties.get('Family', 'task-definition-{0}'.format(int(random() * 10 ** 6))) + container_definitions = properties['ContainerDefinitions'] + volumes = properties['Volumes'] + if (original_resource.family != family or + original_resource.container_definitions != container_definitions or + original_resource.volumes != volumes + # currently TaskRoleArn isn't stored at TaskDefinition instances + ): + ecs_backend = ecs_backends[region_name] + ecs_backend.deregister_task_definition(original_resource.arn) + return ecs_backend.register_task_definition( + family=family, container_definitions=container_definitions, volumes=volumes) + else: + # no-op when nothing changed between old and new resources + return original_resource class Task(BaseObject): def __init__(self, cluster, task_definition, container_instance_arn, overrides={}, started_by=''): @@ -105,6 +162,51 @@ class Service(BaseObject): response_object['serviceArn'] = self.arn return response_object + @classmethod + def create_from_cloudformation_json(cls, resource_name, cloudformation_json, region_name): + properties = cloudformation_json['Properties'] + if isinstance(properties['Cluster'], Cluster): + cluster = properties['Cluster'].name + else: + cluster = properties['Cluster'] + if isinstance(properties['TaskDefinition'], TaskDefinition): + task_definition = properties['TaskDefinition'].family + else: + task_definition = properties['TaskDefinition'] + service_name = '{0}Service{1}'.format(cluster, int(random() * 10 ** 6)) + desired_count = properties['DesiredCount'] + # TODO: LoadBalancers + # TODO: Role + + ecs_backend = ecs_backends[region_name] + return ecs_backend.create_service( + cluster, service_name, task_definition, desired_count) + + @classmethod + def update_from_cloudformation_json(cls, original_resource, new_resource_name, cloudformation_json, region_name): + properties = cloudformation_json['Properties'] + if isinstance(properties['Cluster'], Cluster): + cluster_name = properties['Cluster'].name + else: + cluster_name = properties['Cluster'] + if isinstance(properties['TaskDefinition'], TaskDefinition): + task_definition = properties['TaskDefinition'].family + else: + task_definition = properties['TaskDefinition'] + desired_count = properties['DesiredCount'] + + ecs_backend = ecs_backends[region_name] + service_name = original_resource.name + if original_resource.cluster_arn != Cluster(cluster_name).arn: + # TODO: LoadBalancers + # TODO: Role + ecs_backend.delete_service(cluster_name, service_name) + new_service_name = '{0}Service{1}'.format(cluster_name, int(random() * 10 ** 6)) + return ecs_backend.create_service( + cluster_name, new_service_name, task_definition, desired_count) + else: + return ecs_backend.update_service(cluster_name, service_name, task_definition, desired_count) + class ContainerInstance(BaseObject): def __init__(self, ec2_instance_id): diff --git a/tests/test_ecs/test_ecs_boto3.py b/tests/test_ecs/test_ecs_boto3.py index baf236ece..5cd85549d 100644 --- a/tests/test_ecs/test_ecs_boto3.py +++ b/tests/test_ecs/test_ecs_boto3.py @@ -1,10 +1,14 @@ from __future__ import unicode_literals + +from copy import deepcopy + import boto3 import sure # noqa import json from moto.ec2 import utils as ec2_utils from uuid import UUID +from moto import mock_cloudformation from moto import mock_ecs from moto import mock_ec2 @@ -918,3 +922,254 @@ def test_stop_task(): stop_response['task']['lastStatus'].should.equal('STOPPED') stop_response['task']['desiredStatus'].should.equal('STOPPED') stop_response['task']['stoppedReason'].should.equal('moto testing') + + +@mock_ecs +@mock_cloudformation +def test_create_cluster_through_cloudformation(): + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "ECS Cluster Test CloudFormation", + "Resources": { + "testCluster": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": "testcluster" + } + } + } + } + template_json = json.dumps(template) + cfn_conn = boto3.client('cloudformation', region_name='us-west-1') + cfn_conn.create_stack( + StackName="test_stack", + TemplateBody=template_json, + ) + + ecs_conn = boto3.client('ecs', region_name='us-west-1') + resp = ecs_conn.list_clusters() + len(resp['clusterArns']).should.equal(1) + + +@mock_ecs +@mock_cloudformation +def test_update_cluster_name_through_cloudformation_should_trigger_a_replacement(): + template1 = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "ECS Cluster Test CloudFormation", + "Resources": { + "testCluster": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": "testcluster1" + } + } + } + } + template2 = deepcopy(template1) + template2['Resources']['testCluster']['Properties']['ClusterName'] = 'testcluster2' + template1_json = json.dumps(template1) + cfn_conn = boto3.client('cloudformation', region_name='us-west-1') + stack_resp = cfn_conn.create_stack( + StackName="test_stack", + TemplateBody=template1_json, + ) + + template2_json = json.dumps(template2) + cfn_conn.update_stack( + StackName=stack_resp['StackId'], + TemplateBody=template2_json + ) + ecs_conn = boto3.client('ecs', region_name='us-west-1') + resp = ecs_conn.list_clusters() + len(resp['clusterArns']).should.equal(1) + resp['clusterArns'][0].endswith('testcluster2').should.be.true + + +@mock_ecs +@mock_cloudformation +def test_create_task_definition_through_cloudformation(): + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "ECS Cluster Test CloudFormation", + "Resources": { + "testTaskDefinition": { + "Type" : "AWS::ECS::TaskDefinition", + "Properties" : { + "ContainerDefinitions" : [ + { + "Name": "ecs-sample", + "Image":"amazon/amazon-ecs-sample", + "Cpu": "200", + "Memory": "500", + "Essential": "true" + } + ], + "Volumes" : [], + } + } + } + } + template_json = json.dumps(template) + cfn_conn = boto3.client('cloudformation', region_name='us-west-1') + cfn_conn.create_stack( + StackName="test_stack", + TemplateBody=template_json, + ) + + ecs_conn = boto3.client('ecs', region_name='us-west-1') + resp = ecs_conn.list_task_definitions() + len(resp['taskDefinitionArns']).should.equal(1) + + +@mock_ecs +@mock_cloudformation +def test_update_task_definition_family_through_cloudformation_should_trigger_a_replacement(): + template1 = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "ECS Cluster Test CloudFormation", + "Resources": { + "testTaskDefinition": { + "Type" : "AWS::ECS::TaskDefinition", + "Properties" : { + "Family": "testTaskDefinition1", + "ContainerDefinitions" : [ + { + "Name": "ecs-sample", + "Image":"amazon/amazon-ecs-sample", + "Cpu": "200", + "Memory": "500", + "Essential": "true" + } + ], + "Volumes" : [], + } + } + } + } + template1_json = json.dumps(template1) + cfn_conn = boto3.client('cloudformation', region_name='us-west-1') + cfn_conn.create_stack( + StackName="test_stack", + TemplateBody=template1_json, + ) + + template2 = deepcopy(template1) + template2['Resources']['testTaskDefinition']['Properties']['Family'] = 'testTaskDefinition2' + template2_json = json.dumps(template2) + cfn_conn.update_stack( + StackName="test_stack", + TemplateBody=template2_json, + ) + + ecs_conn = boto3.client('ecs', region_name='us-west-1') + resp = ecs_conn.list_task_definitions(familyPrefix='testTaskDefinition') + len(resp['taskDefinitionArns']).should.equal(1) + resp['taskDefinitionArns'][0].endswith('testTaskDefinition2:1').should.be.true + + +@mock_ecs +@mock_cloudformation +def test_create_service_through_cloudformation(): + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "ECS Cluster Test CloudFormation", + "Resources": { + "testCluster": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": "testcluster" + } + }, + "testTaskDefinition": { + "Type" : "AWS::ECS::TaskDefinition", + "Properties" : { + "ContainerDefinitions" : [ + { + "Name": "ecs-sample", + "Image":"amazon/amazon-ecs-sample", + "Cpu": "200", + "Memory": "500", + "Essential": "true" + } + ], + "Volumes" : [], + } + }, + "testService": { + "Type": "AWS::ECS::Service", + "Properties": { + "Cluster": {"Ref": "testCluster"}, + "DesiredCount": 10, + "TaskDefinition": {"Ref": "testTaskDefinition"}, + } + } + } + } + template_json = json.dumps(template) + cfn_conn = boto3.client('cloudformation', region_name='us-west-1') + cfn_conn.create_stack( + StackName="test_stack", + TemplateBody=template_json, + ) + + ecs_conn = boto3.client('ecs', region_name='us-west-1') + resp = ecs_conn.list_services(cluster='testcluster') + len(resp['serviceArns']).should.equal(1) + + +@mock_ecs +@mock_cloudformation +def test_update_service_through_cloudformation_should_trigger_replacement(): + template1 = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "ECS Cluster Test CloudFormation", + "Resources": { + "testCluster": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": "testcluster" + } + }, + "testTaskDefinition": { + "Type" : "AWS::ECS::TaskDefinition", + "Properties" : { + "ContainerDefinitions" : [ + { + "Name": "ecs-sample", + "Image":"amazon/amazon-ecs-sample", + "Cpu": "200", + "Memory": "500", + "Essential": "true" + } + ], + "Volumes" : [], + } + }, + "testService": { + "Type": "AWS::ECS::Service", + "Properties": { + "Cluster": {"Ref": "testCluster"}, + "TaskDefinition": {"Ref": "testTaskDefinition"}, + "DesiredCount": 10, + } + } + } + } + template_json1 = json.dumps(template1) + cfn_conn = boto3.client('cloudformation', region_name='us-west-1') + cfn_conn.create_stack( + StackName="test_stack", + TemplateBody=template_json1, + ) + template2 = deepcopy(template1) + template2['Resources']['testService']['Properties']['DesiredCount'] = 5 + template2_json = json.dumps(template2) + cfn_conn.update_stack( + StackName="test_stack", + TemplateBody=template2_json, + ) + + ecs_conn = boto3.client('ecs', region_name='us-west-1') + resp = ecs_conn.list_services(cluster='testcluster') + len(resp['serviceArns']).should.equal(1)