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
This commit is contained in:
Hugo Lopes Tavares 2016-12-20 10:37:18 -05:00 committed by Steve Pulec
parent a20906ff15
commit 0115267f2a
3 changed files with 366 additions and 2 deletions

View File

@ -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)

View File

@ -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):

View File

@ -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)