diff --git a/README.md b/README.md index d80e515ed..ade114c53 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ It gets even better! Moto isn't just S3. Here's the status of the other AWS serv | - Security Groups | | core endpoints done | | - Tags | | all endpoints done | |------------------------------------------------------------------------------| +| ECS | @mock_ecs | basic endpoints done | +|------------------------------------------------------------------------------| | ELB | @mock_elb | core endpoints done | |------------------------------------------------------------------------------| | EMR | @mock_emr | core endpoints done | diff --git a/moto/__init__.py b/moto/__init__.py index fbbca07be..65b22b1ce 100644 --- a/moto/__init__.py +++ b/moto/__init__.py @@ -12,6 +12,7 @@ from .datapipeline import mock_datapipeline # flake8: noqa from .dynamodb import mock_dynamodb # flake8: noqa from .dynamodb2 import mock_dynamodb2 # flake8: noqa from .ec2 import mock_ec2 # flake8: noqa +from .ecs import mock_ecs # flake8: noqa from .elb import mock_elb # flake8: noqa from .emr import mock_emr # flake8: noqa from .glacier import mock_glacier # flake8: noqa diff --git a/moto/ecs/__init__.py b/moto/ecs/__init__.py new file mode 100644 index 000000000..0844b256c --- /dev/null +++ b/moto/ecs/__init__.py @@ -0,0 +1,11 @@ +from __future__ import unicode_literals +from .models import ecs_backends +from ..core.models import MockAWS + +ecs_backend = ecs_backends['us-east-1'] + +def mock_ecs(func=None): + if func: + return MockAWS(ecs_backends)(func) + else: + return MockAWS(ecs_backends) diff --git a/moto/ecs/models.py b/moto/ecs/models.py new file mode 100644 index 000000000..812a3fa29 --- /dev/null +++ b/moto/ecs/models.py @@ -0,0 +1,204 @@ +from __future__ import unicode_literals +import uuid + +from moto.core import BaseBackend +from moto.ec2 import ec2_backends + + +class BaseObject(object): + def camelCase(self, key): + words = [] + for i, word in enumerate(key.split('_')): + if i > 0: + words.append(word.title()) + else: + words.append(word) + return ''.join(words) + + def gen_response_object(self): + response_object = self.__dict__.copy() + for key, value in response_object.iteritems(): + if '_' in key: + response_object[self.camelCase(key)] = value + del response_object[key] + return response_object + + @property + def response_object(self): + return self.gen_response_object() + + +class Cluster(BaseObject): + def __init__(self, cluster_name): + self.active_services_count = 0 + self.arn = 'arn:aws:ecs:us-east-1:012345678910:cluster/{0}'.format(cluster_name) + self.name = cluster_name + self.pending_tasks_count = 0 + self.registered_container_instances_count = 0 + self.running_tasks_count = 0 + self.status = 'ACTIVE' + + @property + def response_object(self): + response_object = self.gen_response_object() + response_object['clusterArn'] = self.arn + response_object['clusterName'] = self.name + del response_object['arn'], response_object['name'] + return response_object + + +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: + self.volumes = volumes + + @property + def response_object(self): + response_object = self.gen_response_object() + response_object['taskDefinitionArn'] = response_object['arn'] + del response_object['arn'] + return response_object + + +class Service(BaseObject): + def __init__(self, cluster, service_name, task_definition, desired_count): + self.cluster_arn = cluster.arn + self.arn = 'arn:aws:ecs:us-east-1:012345678910:service/{0}'.format(service_name) + self.name = service_name + self.status = 'ACTIVE' + self.running_count = 0 + self.task_definition = task_definition.arn + self.desired_count = desired_count + self.events = [] + self.load_balancers = [] + self.pending_count = 0 + + @property + def response_object(self): + response_object = self.gen_response_object() + del response_object['name'], response_object['arn'] + response_object['serviceName'] = self.name + response_object['serviceArn'] = self.arn + return response_object + + +class EC2ContainerServiceBackend(BaseBackend): + def __init__(self): + self.clusters = {} + self.task_definitions = {} + self.services = {} + + def fetch_task_definition(self, task_definition_str): + task_definition_components = task_definition_str.split(':') + if len(task_definition_components) == 2: + family, revision = task_definition_components + revision = int(revision) + else: + family = task_definition_components[0] + revision = -1 + if family in self.task_definitions and 0 < revision <= len(self.task_definitions[family]): + return self.task_definitions[family][revision - 1] + elif family in self.task_definitions and revision == -1: + return self.task_definitions[family][revision] + else: + raise Exception("{0} is not a task_definition".format(task_definition_str)) + + def create_cluster(self, cluster_name): + cluster = Cluster(cluster_name) + self.clusters[cluster_name] = cluster + return cluster + + def list_clusters(self): + """ + maxSize and pagination not implemented + """ + return [cluster.arn for cluster in self.clusters.values()] + + def delete_cluster(self, cluster_str): + cluster_name = cluster_str.split('/')[-1] + if cluster_name in self.clusters: + return self.clusters.pop(cluster_name) + else: + raise Exception("{0} is not a cluster".format(cluster_name)) + + def register_task_definition(self, family, container_definitions, volumes): + if family in self.task_definitions: + revision = len(self.task_definitions[family]) + 1 + else: + self.task_definitions[family] = [] + revision = 1 + task_definition = TaskDefinition(family, revision, container_definitions, volumes) + self.task_definitions[family].append(task_definition) + + return task_definition + + def list_task_definitions(self): + """ + Filtering not implemented + """ + task_arns = [] + for task_definition_list in self.task_definitions.values(): + task_arns.extend([task_definition.arn for task_definition in task_definition_list]) + return task_arns + + def deregister_task_definition(self, task_definition_str): + task_definition_name = task_definition_str.split('/')[-1] + family, revision = task_definition_name.split(':') + revision = int(revision) + if family in self.task_definitions and 0 < revision <= len(self.task_definitions[family]): + return self.task_definitions[family].pop(revision - 1) + else: + raise Exception("{0} is not a task_definition".format(task_definition_name)) + + def create_service(self, cluster_str, service_name, task_definition_str, desired_count): + cluster_name = cluster_str.split('/')[-1] + if cluster_name in self.clusters: + cluster = self.clusters[cluster_name] + else: + raise Exception("{0} is not a cluster".format(cluster_name)) + task_definition = self.fetch_task_definition(task_definition_str) + desired_count = desired_count if desired_count is not None else 0 + service = Service(cluster, service_name, task_definition, desired_count) + cluster_service_pair = '{0}:{1}'.format(cluster_name, service_name) + self.services[cluster_service_pair] = service + return service + + def list_services(self, cluster_str): + cluster_name = cluster_str.split('/')[-1] + service_arns = [] + for key, value in self.services.iteritems(): + if cluster_name + ':' in key: + service_arns.append(self.services[key].arn) + return sorted(service_arns) + + def update_service(self, cluster_str, service_name, task_definition_str, desired_count): + cluster_name = cluster_str.split('/')[-1] + cluster_service_pair = '{0}:{1}'.format(cluster_name, service_name) + if cluster_service_pair in self.services: + if task_definition_str is not None: + task_definition = self.fetch_task_definition(task_definition_str) + self.services[cluster_service_pair].task_definition = task_definition + if desired_count is not None: + self.services[cluster_service_pair].desired_count = desired_count + return self.services[cluster_service_pair] + else: + raise Exception("cluster {0} or service {1} does not exist".format(cluster_name, service_name)) + + def delete_service(self, cluster_name, service_name): + cluster_service_pair = '{0}:{1}'.format(cluster_name, service_name) + if cluster_service_pair in self.services: + service = self.services[cluster_service_pair] + if service.desired_count > 0: + raise Exception("Service must have desiredCount=0") + else: + return self.services.pop(cluster_service_pair) + else: + raise Exception("cluster {0} or service {1} does not exist".format(cluster_name, service_name)) + + +ecs_backends = {} +for region, ec2_backend in ec2_backends.items(): + ecs_backends[region] = EC2ContainerServiceBackend() diff --git a/moto/ecs/responses.py b/moto/ecs/responses.py new file mode 100644 index 000000000..6608a9cfb --- /dev/null +++ b/moto/ecs/responses.py @@ -0,0 +1,102 @@ +from __future__ import unicode_literals +import json +import uuid + +from moto.core.responses import BaseResponse +from .models import ecs_backends + + +class EC2ContainerServiceResponse(BaseResponse): + @property + def ecs_backend(self): + return ecs_backends[self.region] + + @property + def request_params(self): + try: + return json.loads(self.body) + except ValueError: + return {} + + def _get_param(self, param): + return self.request_params.get(param, None) + + def create_cluster(self): + cluster_name = self._get_param('clusterName') + cluster = self.ecs_backend.create_cluster(cluster_name) + return json.dumps({ + 'cluster': cluster.response_object + }) + + def list_clusters(self): + cluster_arns = self.ecs_backend.list_clusters() + return json.dumps({ + 'clusterArns': cluster_arns, + 'nextToken': str(uuid.uuid1()) + }) + + def delete_cluster(self): + cluster_str = self._get_param('cluster') + cluster = self.ecs_backend.delete_cluster(cluster_str) + return json.dumps({ + 'cluster': cluster.response_object + }) + + def register_task_definition(self): + family = self._get_param('family') + container_definitions = self._get_param('containerDefinitions') + volumes = self._get_param('volumes') + task_definition = self.ecs_backend.register_task_definition(family, container_definitions, volumes) + return json.dumps({ + 'taskDefinition': task_definition.response_object + }) + + def list_task_definitions(self): + task_definition_arns = self.ecs_backend.list_task_definitions() + return json.dumps({ + 'taskDefinitionArns': task_definition_arns, + 'nextToken': str(uuid.uuid1()) + }) + + def deregister_task_definition(self): + task_definition_str = self._get_param('taskDefinition') + task_definition = self.ecs_backend.deregister_task_definition(task_definition_str) + return json.dumps({ + 'taskDefinition': task_definition.response_object + }) + + def create_service(self): + cluster_str = self._get_param('cluster') + service_name = self._get_param('serviceName') + task_definition_str = self._get_param('taskDefinition') + desired_count = self._get_int_param('desiredCount') + service = self.ecs_backend.create_service(cluster_str, service_name, task_definition_str, desired_count) + return json.dumps({ + 'service': service.response_object + }) + + def list_services(self): + cluster_str = self._get_param('cluster') + service_arns = self.ecs_backend.list_services(cluster_str) + return json.dumps({ + 'serviceArns': service_arns, + 'nextToken': str(uuid.uuid1()) + }) + + def update_service(self): + cluster_str = self._get_param('cluster') + service_name = self._get_param('service') + task_definition = self._get_param('taskDefinition') + desired_count = self._get_int_param('desiredCount') + service = self.ecs_backend.update_service(cluster_str, service_name, task_definition, desired_count) + return json.dumps({ + 'service': service.response_object + }) + + def delete_service(self): + service_name = self._get_param('service') + cluster_name = self._get_param('cluster') + service = self.ecs_backend.delete_service(cluster_name, service_name) + return json.dumps({ + 'service': service.response_object + }) diff --git a/moto/ecs/urls.py b/moto/ecs/urls.py new file mode 100644 index 000000000..1e0d5fbf9 --- /dev/null +++ b/moto/ecs/urls.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals +from .responses import EC2ContainerServiceResponse + +url_bases = [ + "https?://ecs.(.+).amazonaws.com", +] + +url_paths = { + '{0}/$': EC2ContainerServiceResponse.dispatch, +} diff --git a/tests/test_ecs/test_ecs_boto3.py b/tests/test_ecs/test_ecs_boto3.py new file mode 100644 index 000000000..e902c5d91 --- /dev/null +++ b/tests/test_ecs/test_ecs_boto3.py @@ -0,0 +1,336 @@ +from __future__ import unicode_literals +import boto3 +import sure # noqa + +from moto import mock_ecs + + +@mock_ecs +def test_create_cluster(): + client = boto3.client('ecs', region_name='us-east-1') + response = client.create_cluster( + clusterName='test_ecs_cluster' + ) + response['cluster']['clusterName'].should.equal('test_ecs_cluster') + response['cluster']['clusterArn'].should.equal('arn:aws:ecs:us-east-1:012345678910:cluster/test_ecs_cluster') + response['cluster']['status'].should.equal('ACTIVE') + response['cluster']['registeredContainerInstancesCount'].should.equal(0) + response['cluster']['runningTasksCount'].should.equal(0) + response['cluster']['pendingTasksCount'].should.equal(0) + response['cluster']['activeServicesCount'].should.equal(0) + + +@mock_ecs +def test_list_clusters(): + client = boto3.client('ecs', region_name='us-east-1') + _ = client.create_cluster( + clusterName='test_cluster0' + ) + _ = client.create_cluster( + clusterName='test_cluster1' + ) + response = client.list_clusters() + response['clusterArns'].should.contain('arn:aws:ecs:us-east-1:012345678910:cluster/test_cluster0') + response['clusterArns'].should.contain('arn:aws:ecs:us-east-1:012345678910:cluster/test_cluster1') + + +@mock_ecs +def test_delete_cluster(): + client = boto3.client('ecs', region_name='us-east-1') + _ = client.create_cluster( + clusterName='test_ecs_cluster' + ) + response = client.delete_cluster(cluster='test_ecs_cluster') + response['cluster']['clusterName'].should.equal('test_ecs_cluster') + response['cluster']['clusterArn'].should.equal('arn:aws:ecs:us-east-1:012345678910:cluster/test_ecs_cluster') + response['cluster']['status'].should.equal('ACTIVE') + response['cluster']['registeredContainerInstancesCount'].should.equal(0) + response['cluster']['runningTasksCount'].should.equal(0) + response['cluster']['pendingTasksCount'].should.equal(0) + response['cluster']['activeServicesCount'].should.equal(0) + + response = client.list_clusters() + len(response['clusterArns']).should.equal(0) + + +@mock_ecs +def test_register_task_definition(): + client = boto3.client('ecs', region_name='us-east-1') + response = client.register_task_definition( + family='test_ecs_task', + containerDefinitions=[ + { + 'name': 'hello_world', + 'image': 'docker/hello-world:latest', + 'cpu': 1024, + 'memory': 400, + 'essential': True, + 'environment': [{ + 'name': 'AWS_ACCESS_KEY_ID', + 'value': 'SOME_ACCESS_KEY' + }], + 'logConfiguration': {'logDriver': 'json-file'} + } + ] + ) + type(response['taskDefinition']).should.be(dict) + response['taskDefinition']['taskDefinitionArn'].should.equal('arn:aws:ecs:us-east-1:012345678910:task-definition/test_ecs_task:1') + response['taskDefinition']['containerDefinitions'][0]['name'].should.equal('hello_world') + response['taskDefinition']['containerDefinitions'][0]['image'].should.equal('docker/hello-world:latest') + response['taskDefinition']['containerDefinitions'][0]['cpu'].should.equal(1024) + response['taskDefinition']['containerDefinitions'][0]['memory'].should.equal(400) + response['taskDefinition']['containerDefinitions'][0]['essential'].should.equal(True) + response['taskDefinition']['containerDefinitions'][0]['environment'][0]['name'].should.equal('AWS_ACCESS_KEY_ID') + response['taskDefinition']['containerDefinitions'][0]['environment'][0]['value'].should.equal('SOME_ACCESS_KEY') + response['taskDefinition']['containerDefinitions'][0]['logConfiguration']['logDriver'].should.equal('json-file') + + +@mock_ecs +def test_list_task_definitions(): + client = boto3.client('ecs', region_name='us-east-1') + _ = client.register_task_definition( + family='test_ecs_task', + containerDefinitions=[ + { + 'name': 'hello_world', + 'image': 'docker/hello-world:latest', + 'cpu': 1024, + 'memory': 400, + 'essential': True, + 'environment': [{ + 'name': 'AWS_ACCESS_KEY_ID', + 'value': 'SOME_ACCESS_KEY' + }], + 'logConfiguration': {'logDriver': 'json-file'} + } + ] + ) + _ = client.register_task_definition( + family='test_ecs_task', + containerDefinitions=[ + { + 'name': 'hello_world2', + 'image': 'docker/hello-world2:latest', + 'cpu': 1024, + 'memory': 400, + 'essential': True, + 'environment': [{ + 'name': 'AWS_ACCESS_KEY_ID', + 'value': 'SOME_ACCESS_KEY2' + }], + 'logConfiguration': {'logDriver': 'json-file'} + } + ] + ) + response = client.list_task_definitions() + len(response['taskDefinitionArns']).should.equal(2) + response['taskDefinitionArns'][0].should.equal('arn:aws:ecs:us-east-1:012345678910:task-definition/test_ecs_task:1') + response['taskDefinitionArns'][1].should.equal('arn:aws:ecs:us-east-1:012345678910:task-definition/test_ecs_task:2') + + +@mock_ecs +def test_deregister_task_definition(): + client = boto3.client('ecs', region_name='us-east-1') + _ = client.register_task_definition( + family='test_ecs_task', + containerDefinitions=[ + { + 'name': 'hello_world', + 'image': 'docker/hello-world:latest', + 'cpu': 1024, + 'memory': 400, + 'essential': True, + 'environment': [{ + 'name': 'AWS_ACCESS_KEY_ID', + 'value': 'SOME_ACCESS_KEY' + }], + 'logConfiguration': {'logDriver': 'json-file'} + } + ] + ) + response = client.deregister_task_definition( + taskDefinition='test_ecs_task:1' + ) + type(response['taskDefinition']).should.be(dict) + response['taskDefinition']['taskDefinitionArn'].should.equal('arn:aws:ecs:us-east-1:012345678910:task-definition/test_ecs_task:1') + response['taskDefinition']['containerDefinitions'][0]['name'].should.equal('hello_world') + response['taskDefinition']['containerDefinitions'][0]['image'].should.equal('docker/hello-world:latest') + response['taskDefinition']['containerDefinitions'][0]['cpu'].should.equal(1024) + response['taskDefinition']['containerDefinitions'][0]['memory'].should.equal(400) + response['taskDefinition']['containerDefinitions'][0]['essential'].should.equal(True) + response['taskDefinition']['containerDefinitions'][0]['environment'][0]['name'].should.equal('AWS_ACCESS_KEY_ID') + response['taskDefinition']['containerDefinitions'][0]['environment'][0]['value'].should.equal('SOME_ACCESS_KEY') + response['taskDefinition']['containerDefinitions'][0]['logConfiguration']['logDriver'].should.equal('json-file') + + +@mock_ecs +def test_create_service(): + client = boto3.client('ecs', region_name='us-east-1') + _ = client.create_cluster( + clusterName='test_ecs_cluster' + ) + _ = client.register_task_definition( + family='test_ecs_task', + containerDefinitions=[ + { + 'name': 'hello_world', + 'image': 'docker/hello-world:latest', + 'cpu': 1024, + 'memory': 400, + 'essential': True, + 'environment': [{ + 'name': 'AWS_ACCESS_KEY_ID', + 'value': 'SOME_ACCESS_KEY' + }], + 'logConfiguration': {'logDriver': 'json-file'} + } + ] + ) + response = client.create_service( + cluster='test_ecs_cluster', + serviceName='test_ecs_service', + taskDefinition='test_ecs_task', + desiredCount=2 + ) + response['service']['clusterArn'].should.equal('arn:aws:ecs:us-east-1:012345678910:cluster/test_ecs_cluster') + response['service']['desiredCount'].should.equal(2) + len(response['service']['events']).should.equal(0) + len(response['service']['loadBalancers']).should.equal(0) + response['service']['pendingCount'].should.equal(0) + response['service']['runningCount'].should.equal(0) + response['service']['serviceArn'].should.equal('arn:aws:ecs:us-east-1:012345678910:service/test_ecs_service') + response['service']['serviceName'].should.equal('test_ecs_service') + response['service']['status'].should.equal('ACTIVE') + response['service']['taskDefinition'].should.equal('arn:aws:ecs:us-east-1:012345678910:task-definition/test_ecs_task:1') + + +@mock_ecs +def test_list_services(): + client = boto3.client('ecs', region_name='us-east-1') + _ = client.create_cluster( + clusterName='test_ecs_cluster' + ) + _ = client.register_task_definition( + family='test_ecs_task', + containerDefinitions=[ + { + 'name': 'hello_world', + 'image': 'docker/hello-world:latest', + 'cpu': 1024, + 'memory': 400, + 'essential': True, + 'environment': [{ + 'name': 'AWS_ACCESS_KEY_ID', + 'value': 'SOME_ACCESS_KEY' + }], + 'logConfiguration': {'logDriver': 'json-file'} + } + ] + ) + _ = client.create_service( + cluster='test_ecs_cluster', + serviceName='test_ecs_service1', + taskDefinition='test_ecs_task', + desiredCount=2 + ) + _ = client.create_service( + cluster='test_ecs_cluster', + serviceName='test_ecs_service2', + taskDefinition='test_ecs_task', + desiredCount=2 + ) + response = client.list_services( + cluster='test_ecs_cluster' + ) + len(response['serviceArns']).should.equal(2) + response['serviceArns'][0].should.equal('arn:aws:ecs:us-east-1:012345678910:service/test_ecs_service1') + response['serviceArns'][1].should.equal('arn:aws:ecs:us-east-1:012345678910:service/test_ecs_service2') + + +@mock_ecs +def test_update_service(): + client = boto3.client('ecs', region_name='us-east-1') + _ = client.create_cluster( + clusterName='test_ecs_cluster' + ) + _ = client.register_task_definition( + family='test_ecs_task', + containerDefinitions=[ + { + 'name': 'hello_world', + 'image': 'docker/hello-world:latest', + 'cpu': 1024, + 'memory': 400, + 'essential': True, + 'environment': [{ + 'name': 'AWS_ACCESS_KEY_ID', + 'value': 'SOME_ACCESS_KEY' + }], + 'logConfiguration': {'logDriver': 'json-file'} + } + ] + ) + response = client.create_service( + cluster='test_ecs_cluster', + serviceName='test_ecs_service', + taskDefinition='test_ecs_task', + desiredCount=2 + ) + response['service']['desiredCount'].should.equal(2) + + response = client.update_service( + cluster='test_ecs_cluster', + service='test_ecs_service', + desiredCount=0 + ) + response['service']['desiredCount'].should.equal(0) + + +@mock_ecs +def test_delete_service(): + client = boto3.client('ecs', region_name='us-east-1') + _ = client.create_cluster( + clusterName='test_ecs_cluster' + ) + _ = client.register_task_definition( + family='test_ecs_task', + containerDefinitions=[ + { + 'name': 'hello_world', + 'image': 'docker/hello-world:latest', + 'cpu': 1024, + 'memory': 400, + 'essential': True, + 'environment': [{ + 'name': 'AWS_ACCESS_KEY_ID', + 'value': 'SOME_ACCESS_KEY' + }], + 'logConfiguration': {'logDriver': 'json-file'} + } + ] + ) + _ = client.create_service( + cluster='test_ecs_cluster', + serviceName='test_ecs_service', + taskDefinition='test_ecs_task', + desiredCount=2 + ) + _ = client.update_service( + cluster='test_ecs_cluster', + service='test_ecs_service', + desiredCount=0 + ) + response = client.delete_service( + cluster='test_ecs_cluster', + service='test_ecs_service' + ) + response['service']['clusterArn'].should.equal('arn:aws:ecs:us-east-1:012345678910:cluster/test_ecs_cluster') + response['service']['desiredCount'].should.equal(0) + len(response['service']['events']).should.equal(0) + len(response['service']['loadBalancers']).should.equal(0) + response['service']['pendingCount'].should.equal(0) + response['service']['runningCount'].should.equal(0) + response['service']['serviceArn'].should.equal('arn:aws:ecs:us-east-1:012345678910:service/test_ecs_service') + response['service']['serviceName'].should.equal('test_ecs_service') + response['service']['status'].should.equal('ACTIVE') + response['service']['taskDefinition'].should.equal('arn:aws:ecs:us-east-1:012345678910:task-definition/test_ecs_task:1') \ No newline at end of file