diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 7f6e87018..24be57b19 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -1483,10 +1483,10 @@ - [ ] post_content - [ ] post_text -## ecs - 74% implemented +## ecs - 90% implemented - [X] create_cluster - [X] create_service -- [ ] delete_attributes +- [X] delete_attributes - [X] delete_cluster - [X] delete_service - [X] deregister_container_instance @@ -1496,15 +1496,15 @@ - [X] describe_services - [X] describe_task_definition - [X] describe_tasks -- [ ] discover_poll_endpoint -- [ ] list_attributes +- [X] discover_poll_endpoint +- [X] list_attributes - [X] list_clusters - [X] list_container_instances - [X] list_services -- [ ] list_task_definition_families +- [X] list_task_definition_families - [X] list_task_definitions - [X] list_tasks -- [ ] put_attributes +- [X] put_attributes - [X] register_container_instance - [X] register_task_definition - [X] run_task diff --git a/moto/ecs/models.py b/moto/ecs/models.py index f5a928791..b44184033 100644 --- a/moto/ecs/models.py +++ b/moto/ecs/models.py @@ -4,6 +4,7 @@ from datetime import datetime from random import random, randint import pytz +from moto.core.exceptions import JsonRESTError from moto.core import BaseBackend, BaseModel from moto.ec2 import ec2_backends from copy import copy @@ -148,7 +149,7 @@ class Task(BaseObject): resource_requirements, overrides={}, started_by=''): self.cluster_arn = cluster.arn self.task_arn = 'arn:aws:ecs:us-east-1:012345678910:task/{0}'.format( - str(uuid.uuid1())) + str(uuid.uuid4())) self.container_instance_arn = container_instance_arn self.last_status = 'RUNNING' self.desired_status = 'RUNNING' @@ -288,7 +289,7 @@ class ContainerInstance(BaseObject): 'stringSetValue': [], 'type': 'STRINGSET'}] self.container_instance_arn = "arn:aws:ecs:us-east-1:012345678910:container-instance/{0}".format( - str(uuid.uuid1())) + str(uuid.uuid4())) self.pending_tasks_count = 0 self.remaining_resources = [ {'doubleValue': 0.0, @@ -321,6 +322,8 @@ class ContainerInstance(BaseObject): 'dockerVersion': 'DockerVersion: 1.5.0' } + self.attributes = {} + @property def response_object(self): response_object = self.gen_response_object() @@ -766,6 +769,102 @@ class EC2ContainerServiceBackend(BaseBackend): raise Exception("{0} is not a cluster".format(cluster_name)) pass + def put_attributes(self, cluster_name, attributes=None): + if cluster_name is None or cluster_name not in self.clusters: + raise JsonRESTError('ClusterNotFoundException', 'Cluster not found', status=400) + + if attributes is None: + raise JsonRESTError('InvalidParameterException', 'attributes value is required') + + for attr in attributes: + self._put_attribute(cluster_name, attr['name'], attr.get('value'), attr.get('targetId'), attr.get('targetType')) + + def _put_attribute(self, cluster_name, name, value=None, target_id=None, target_type=None): + if target_id is None and target_type is None: + for instance in self.container_instances[cluster_name].values(): + instance.attributes[name] = value + elif target_type is None: + # targetId is full container instance arn + try: + arn = target_id.rsplit('/', 1)[-1] + self.container_instances[cluster_name][arn].attributes[name] = value + except KeyError: + raise JsonRESTError('TargetNotFoundException', 'Could not find {0}'.format(target_id)) + else: + # targetId is container uuid, targetType must be container-instance + try: + if target_type != 'container-instance': + raise JsonRESTError('TargetNotFoundException', 'Could not find {0}'.format(target_id)) + + self.container_instances[cluster_name][target_id].attributes[name] = value + except KeyError: + raise JsonRESTError('TargetNotFoundException', 'Could not find {0}'.format(target_id)) + + def list_attributes(self, target_type, cluster_name=None, attr_name=None, attr_value=None, max_results=None, next_token=None): + if target_type != 'container-instance': + raise JsonRESTError('InvalidParameterException', 'targetType must be container-instance') + + filters = [lambda x: True] + + # item will be {0 cluster_name, 1 arn, 2 name, 3 value} + if cluster_name is not None: + filters.append(lambda item: item[0] == cluster_name) + if attr_name: + filters.append(lambda item: item[2] == attr_name) + if attr_name: + filters.append(lambda item: item[3] == attr_value) + + all_attrs = [] + for cluster_name, cobj in self.container_instances.items(): + for container_instance in cobj.values(): + for key, value in container_instance.attributes.items(): + all_attrs.append((cluster_name, container_instance.container_instance_arn, key, value)) + + return filter(lambda x: all(f(x) for f in filters), all_attrs) + + def delete_attributes(self, cluster_name, attributes=None): + if cluster_name is None or cluster_name not in self.clusters: + raise JsonRESTError('ClusterNotFoundException', 'Cluster not found', status=400) + + if attributes is None: + raise JsonRESTError('InvalidParameterException', 'attributes value is required') + + for attr in attributes: + self._delete_attribute(cluster_name, attr['name'], attr.get('value'), attr.get('targetId'), attr.get('targetType')) + + def _delete_attribute(self, cluster_name, name, value=None, target_id=None, target_type=None): + if target_id is None and target_type is None: + for instance in self.container_instances[cluster_name].values(): + if name in instance.attributes and instance.attributes[name] == value: + del instance.attributes[name] + elif target_type is None: + # targetId is full container instance arn + try: + arn = target_id.rsplit('/', 1)[-1] + instance = self.container_instances[cluster_name][arn] + if name in instance.attributes and instance.attributes[name] == value: + del instance.attributes[name] + except KeyError: + raise JsonRESTError('TargetNotFoundException', 'Could not find {0}'.format(target_id)) + else: + # targetId is container uuid, targetType must be container-instance + try: + if target_type != 'container-instance': + raise JsonRESTError('TargetNotFoundException', 'Could not find {0}'.format(target_id)) + + instance = self.container_instances[cluster_name][target_id] + if name in instance.attributes and instance.attributes[name] == value: + del instance.attributes[name] + except KeyError: + raise JsonRESTError('TargetNotFoundException', 'Could not find {0}'.format(target_id)) + + def list_task_definition_families(self, family_prefix=None, status=None, max_results=None, next_token=None): + for task_fam in self.task_definitions: + if family_prefix is not None and not task_fam.startswith(family_prefix): + continue + + yield task_fam + ecs_backends = {} for region, ec2_backend in ec2_backends.items(): diff --git a/moto/ecs/responses.py b/moto/ecs/responses.py index 8f6fe850f..e81e04145 100644 --- a/moto/ecs/responses.py +++ b/moto/ecs/responses.py @@ -9,6 +9,12 @@ class EC2ContainerServiceResponse(BaseResponse): @property def ecs_backend(self): + """ + ECS Backend + + :return: ECS Backend object + :rtype: moto.ecs.models.EC2ContainerServiceBackend + """ return ecs_backends[self.region] @property @@ -34,7 +40,7 @@ class EC2ContainerServiceResponse(BaseResponse): cluster_arns = self.ecs_backend.list_clusters() return json.dumps({ 'clusterArns': cluster_arns - # 'nextToken': str(uuid.uuid1()) + # 'nextToken': str(uuid.uuid4()) }) def describe_clusters(self): @@ -66,7 +72,7 @@ class EC2ContainerServiceResponse(BaseResponse): task_definition_arns = self.ecs_backend.list_task_definitions() return json.dumps({ 'taskDefinitionArns': task_definition_arns - # 'nextToken': str(uuid.uuid1()) + # 'nextToken': str(uuid.uuid4()) }) def describe_task_definition(self): @@ -159,7 +165,7 @@ class EC2ContainerServiceResponse(BaseResponse): return json.dumps({ 'serviceArns': service_arns # , - # 'nextToken': str(uuid.uuid1()) + # 'nextToken': str(uuid.uuid4()) }) def describe_services(self): @@ -245,3 +251,62 @@ class EC2ContainerServiceResponse(BaseResponse): 'failures': [ci.response_object for ci in failures], 'containerInstances': [ci.response_object for ci in container_instances] }) + + def put_attributes(self): + cluster_name = self._get_param('cluster') + attributes = self._get_param('attributes') + + self.ecs_backend.put_attributes(cluster_name, attributes) + + return json.dumps({'attributes': attributes}) + + def list_attributes(self): + cluster_name = self._get_param('cluster') + attr_name = self._get_param('attributeName') + attr_value = self._get_param('attributeValue') + target_type = self._get_param('targetType') + max_results = self._get_param('maxResults') + next_token = self._get_param('nextToken') + + results = self.ecs_backend.list_attributes(target_type, cluster_name, attr_name, attr_value, max_results, next_token) + # Result will be [item will be {0 cluster_name, 1 arn, 2 name, 3 value}] + + formatted_results = [] + for _, arn, name, value in results: + tmp_result = { + 'name': name, + 'targetId': arn + } + if value is not None: + tmp_result['value'] = value + formatted_results.append(tmp_result) + + return json.dumps({'attributes': formatted_results}) + + def delete_attributes(self): + cluster_name = self._get_param('cluster') + attributes = self._get_param('attributes') + + self.ecs_backend.delete_attributes(cluster_name, attributes) + + return json.dumps({'attributes': attributes}) + + def discover_poll_endpoint(self): + # Here are the arguments, this api is used by the ecs client so obviously no decent + # documentation. Hence I've responded with valid but useless data + # cluster_name = self._get_param('cluster') + # instance = self._get_param('containerInstance') + return json.dumps({ + 'endpoint': 'http://localhost', + 'telemetryEndpoint': 'http://localhost' + }) + + def list_task_definition_families(self): + family_prefix = self._get_param('familyPrefix') + status = self._get_param('status') + max_results = self._get_param('maxResults') + next_token = self._get_param('nextToken') + + results = self.ecs_backend.list_task_definition_families(family_prefix, status, max_results, next_token) + + return json.dumps({'families': list(results)}) diff --git a/tests/test_ecs/test_ecs_boto3.py b/tests/test_ecs/test_ecs_boto3.py index 9b6e99b57..9e5e4ff08 100644 --- a/tests/test_ecs/test_ecs_boto3.py +++ b/tests/test_ecs/test_ecs_boto3.py @@ -1611,6 +1611,152 @@ def test_update_service_through_cloudformation_should_trigger_replacement(): len(resp['serviceArns']).should.equal(1) +@mock_ec2 +@mock_ecs +def test_attributes(): + # Combined put, list delete attributes into the same test due to the amount of setup + ecs_client = boto3.client('ecs', region_name='us-east-1') + ec2 = boto3.resource('ec2', region_name='us-east-1') + + test_cluster_name = 'test_ecs_cluster' + + _ = ecs_client.create_cluster( + clusterName=test_cluster_name + ) + + test_instance = ec2.create_instances( + ImageId="ami-1234abcd", + MinCount=1, + MaxCount=1, + )[0] + + instance_id_document = json.dumps( + ec2_utils.generate_instance_identity_document(test_instance) + ) + + response = ecs_client.register_container_instance( + cluster=test_cluster_name, + instanceIdentityDocument=instance_id_document + ) + + response['containerInstance'][ + 'ec2InstanceId'].should.equal(test_instance.id) + full_arn1 = response['containerInstance']['containerInstanceArn'] + + test_instance = ec2.create_instances( + ImageId="ami-1234abcd", + MinCount=1, + MaxCount=1, + )[0] + + instance_id_document = json.dumps( + ec2_utils.generate_instance_identity_document(test_instance) + ) + + response = ecs_client.register_container_instance( + cluster=test_cluster_name, + instanceIdentityDocument=instance_id_document + ) + + response['containerInstance'][ + 'ec2InstanceId'].should.equal(test_instance.id) + full_arn2 = response['containerInstance']['containerInstanceArn'] + partial_arn2 = full_arn2.rsplit('/', 1)[-1] + + full_arn2.should_not.equal(full_arn1) # uuid1 isnt unique enough when the pc is fast ;-) + + # Ok set instance 1 with 1 attribute, instance 2 with another, and all of them with a 3rd. + ecs_client.put_attributes( + cluster=test_cluster_name, + attributes=[ + {'name': 'env', 'value': 'prod'}, + {'name': 'attr1', 'value': 'instance1', 'targetId': full_arn1}, + {'name': 'attr1', 'value': 'instance2', 'targetId': partial_arn2, 'targetType': 'container-instance'} + ] + ) + + resp = ecs_client.list_attributes( + cluster=test_cluster_name, + targetType='container-instance' + ) + attrs = resp['attributes'] + len(attrs).should.equal(4) + + # Tests that the attrs have been set properly + len(list(filter(lambda item: item['name'] == 'env', attrs))).should.equal(2) + len(list(filter(lambda item: item['name'] == 'attr1' and item['value'] == 'instance1', attrs))).should.equal(1) + + ecs_client.delete_attributes( + cluster=test_cluster_name, + attributes=[ + {'name': 'attr1', 'value': 'instance2', 'targetId': partial_arn2, 'targetType': 'container-instance'} + ] + ) + + resp = ecs_client.list_attributes( + cluster=test_cluster_name, + targetType='container-instance' + ) + attrs = resp['attributes'] + len(attrs).should.equal(3) + + +@mock_ecs +def test_poll_endpoint(): + # Combined put, list delete attributes into the same test due to the amount of setup + ecs_client = boto3.client('ecs', region_name='us-east-1') + + # Just a placeholder until someone actually wants useless data, just testing it doesnt raise an exception + resp = ecs_client.discover_poll_endpoint(cluster='blah', containerInstance='blah') + resp.should.contain('endpoint') + resp.should.contain('telemetryEndpoint') + + +@mock_ecs +def test_list_task_definition_families(): + 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='alt_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'} + } + ] + ) + + resp1 = client.list_task_definition_families() + resp2 = client.list_task_definition_families(familyPrefix='alt') + + len(resp1['families']).should.equal(2) + len(resp2['families']).should.equal(1) + + def _fetch_container_instance_resources(container_instance_description): remaining_resources = {} registered_resources = {}