From 1a5c18878c1e1e307a6b00ef4041e8a3c9712b5a Mon Sep 17 00:00:00 2001 From: Bert Blommers Date: Tue, 29 Mar 2022 14:19:09 +0000 Subject: [PATCH] ECS - support for CapacityProviders (#4977) --- IMPLEMENTATION_COVERAGE.md | 8 +- docs/docs/services/ecs.rst | 10 +- moto/ecs/models.py | 127 ++++++++++---- moto/ecs/responses.py | 25 ++- tests/test_ecs/test_ecs_boto3.py | 65 ++++++- tests/test_ecs/test_ecs_capacity_provider.py | 173 +++++++++++++++++++ 6 files changed, 364 insertions(+), 44 deletions(-) create mode 100644 tests/test_ecs/test_ecs_capacity_provider.py diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index b113eddf4..a3485497f 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -1995,21 +1995,21 @@ ## ecs
-73% implemented +78% implemented -- [ ] create_capacity_provider +- [X] create_capacity_provider - [X] create_cluster - [X] create_service - [X] create_task_set - [X] delete_account_setting - [X] delete_attributes -- [ ] delete_capacity_provider +- [X] delete_capacity_provider - [X] delete_cluster - [X] delete_service - [X] delete_task_set - [X] deregister_container_instance - [X] deregister_task_definition -- [ ] describe_capacity_providers +- [X] describe_capacity_providers - [X] describe_clusters - [X] describe_container_instances - [X] describe_services diff --git a/docs/docs/services/ecs.rst b/docs/docs/services/ecs.rst index 87e90deeb..e23662779 100644 --- a/docs/docs/services/ecs.rst +++ b/docs/docs/services/ecs.rst @@ -27,13 +27,17 @@ ecs |start-h3| Implemented features for this service |end-h3| -- [ ] create_capacity_provider +- [X] create_capacity_provider - [X] create_cluster + + The following parameters are not yet implemented: configuration, capacityProviders, defaultCapacityProviderStrategy + + - [X] create_service - [X] create_task_set - [X] delete_account_setting - [X] delete_attributes -- [ ] delete_capacity_provider +- [X] delete_capacity_provider - [X] delete_cluster - [X] delete_service - [X] delete_task_set @@ -43,7 +47,7 @@ ecs - [X] deregister_container_instance - [X] deregister_task_definition -- [ ] describe_capacity_providers +- [X] describe_capacity_providers - [X] describe_clusters Only include=TAGS is currently supported. diff --git a/moto/ecs/models.py b/moto/ecs/models.py index 1b0cef6a7..984593f9e 100644 --- a/moto/ecs/models.py +++ b/moto/ecs/models.py @@ -61,7 +61,7 @@ class AccountSetting(BaseObject): class Cluster(BaseObject, CloudFormationModel): - def __init__(self, cluster_name, region_name): + def __init__(self, cluster_name, region_name, cluster_settings=None): self.active_services_count = 0 self.arn = "arn:aws:ecs:{0}:{1}:cluster/{2}".format( region_name, ACCOUNT_ID, cluster_name @@ -72,6 +72,7 @@ class Cluster(BaseObject, CloudFormationModel): self.running_tasks_count = 0 self.status = "ACTIVE" self.region_name = region_name + self.settings = cluster_settings @property def physical_resource_id(self): @@ -326,6 +327,31 @@ class Task(BaseObject): return response_object +class CapacityProvider(BaseObject): + def __init__(self, region_name, name, asg_details, tags): + self._id = str(uuid.uuid4()) + self.capacity_provider_arn = f"arn:aws:ecs:{region_name}:{ACCOUNT_ID}:capacity_provider/{name}/{self._id}" + self.name = name + self.status = "ACTIVE" + self.auto_scaling_group_provider = asg_details + self.tags = tags + + +class CapacityProviderFailure(BaseObject): + def __init__(self, reason, name, region_name): + self.reason = reason + self.arn = "arn:aws:ecs:{0}:{1}:capacity_provider/{2}".format( + region_name, ACCOUNT_ID, name + ) + + @property + def response_object(self): + response_object = self.gen_response_object() + response_object["reason"] = self.reason + response_object["arn"] = self.arn + return response_object + + class Service(BaseObject, CloudFormationModel): def __init__( self, @@ -727,6 +753,7 @@ class EC2ContainerServiceBackend(BaseBackend): def __init__(self, region_name): super().__init__() self.account_settings = dict() + self.capacity_providers = dict() self.clusters = {} self.task_definitions = {} self.tasks = {} @@ -760,6 +787,13 @@ class EC2ContainerServiceBackend(BaseBackend): return cluster + def create_capacity_provider(self, name, asg_details, tags): + capacity_provider = CapacityProvider(self.region_name, name, asg_details, tags) + self.capacity_providers[name] = capacity_provider + if tags: + self.tagger.tag_resource(capacity_provider.capacity_provider_arn, tags) + return capacity_provider + def describe_task_definition(self, task_definition_str): task_definition_name = task_definition_str.split("/")[-1] if ":" in task_definition_name: @@ -777,13 +811,42 @@ class EC2ContainerServiceBackend(BaseBackend): else: raise Exception("{0} is not a task_definition".format(task_definition_name)) - def create_cluster(self, cluster_name, tags=None): - cluster = Cluster(cluster_name, self.region_name) + def create_cluster(self, cluster_name, tags=None, cluster_settings=None): + """ + The following parameters are not yet implemented: configuration, capacityProviders, defaultCapacityProviderStrategy + """ + cluster = Cluster(cluster_name, self.region_name, cluster_settings) self.clusters[cluster_name] = cluster if tags: self.tagger.tag_resource(cluster.arn, tags) return cluster + def _get_provider(self, name_or_arn): + for provider in self.capacity_providers.values(): + if ( + provider.name == name_or_arn + or provider.capacity_provider_arn == name_or_arn + ): + return provider + + def describe_capacity_providers(self, names): + providers = [] + failures = [] + for name in names: + provider = self._get_provider(name) + if provider: + providers.append(provider) + else: + failures.append( + CapacityProviderFailure("MISSING", name, self.region_name) + ) + return providers, failures + + def delete_capacity_provider(self, name_or_arn): + provider = self._get_provider(name_or_arn) + self.capacity_providers.pop(provider.name) + return provider + def list_clusters(self): """ maxSize and pagination not implemented @@ -1165,6 +1228,15 @@ class EC2ContainerServiceBackend(BaseBackend): "Could not find task {} on cluster {}".format(task_str, cluster.name) ) + def _get_service(self, cluster_str, service_str): + cluster = self._get_cluster(cluster_str) + for service in self.services.values(): + if service.cluster_name == cluster.name and ( + service.name == service_str or service.arn == service_str + ): + return service + raise ServiceNotFoundException + def create_service( self, cluster_str, @@ -1223,31 +1295,19 @@ class EC2ContainerServiceBackend(BaseBackend): def describe_services(self, cluster_str, service_names_or_arns): cluster = self._get_cluster(cluster_str) + service_names = [name.split("/")[-1] for name in service_names_or_arns] result = [] failures = [] - for existing_service_name, existing_service_obj in sorted( - self.services.items() - ): - for requested_name_or_arn in service_names_or_arns: - cluster_service_pair = "{0}:{1}".format( - cluster.name, requested_name_or_arn + for name in service_names: + cluster_service_pair = "{0}:{1}".format(cluster.name, name) + if cluster_service_pair in self.services: + result.append(self.services[cluster_service_pair]) + else: + missing_arn = ( + f"arn:aws:ecs:{self.region_name}:{ACCOUNT_ID}:service/{name}" ) - if ( - cluster_service_pair == existing_service_name - or existing_service_obj.arn == requested_name_or_arn - ): - result.append(existing_service_obj) - else: - service_name = requested_name_or_arn.split("/")[-1] - failures.append( - { - "arn": "arn:aws:ecs:eu-central-1:{0}:service/{1}".format( - ACCOUNT_ID, service_name - ), - "reason": "MISSING", - } - ) + failures.append({"arn": missing_arn, "reason": "MISSING"}) return result, failures @@ -1272,18 +1332,17 @@ class EC2ContainerServiceBackend(BaseBackend): def delete_service(self, cluster_name, service_name, force): cluster = self._get_cluster(cluster_name) - cluster_service_pair = "{0}:{1}".format(cluster.name, service_name) + service = self._get_service(cluster_name, service_name) - if cluster_service_pair in self.services: - service = self.services[cluster_service_pair] - if service.desired_count > 0 and not force: - raise InvalidParameterException( - "The service cannot be stopped while it is scaled above 0." - ) - else: - return self.services.pop(cluster_service_pair) + cluster_service_pair = "{0}:{1}".format(cluster.name, service.name) + + service = self.services[cluster_service_pair] + if service.desired_count > 0 and not force: + raise InvalidParameterException( + "The service cannot be stopped while it is scaled above 0." + ) else: - raise ServiceNotFoundException + return self.services.pop(cluster_service_pair) def register_container_instance(self, cluster_str, ec2_instance_id): cluster_name = cluster_str.split("/")[-1] diff --git a/moto/ecs/responses.py b/moto/ecs/responses.py index b95168488..ef568b8d5 100644 --- a/moto/ecs/responses.py +++ b/moto/ecs/responses.py @@ -25,12 +25,20 @@ class EC2ContainerServiceResponse(BaseResponse): def _get_param(self, param_name, if_none=None): return self.request_params.get(param_name, if_none) + def create_capacity_provider(self): + name = self._get_param("name") + asg_provider = self._get_param("autoScalingGroupProvider") + tags = self._get_param("tags") + provider = self.ecs_backend.create_capacity_provider(name, asg_provider, tags) + return json.dumps({"capacityProvider": provider.response_object}) + def create_cluster(self): cluster_name = self._get_param("clusterName") tags = self._get_param("tags") + settings = self._get_param("settings") if cluster_name is None: cluster_name = "default" - cluster = self.ecs_backend.create_cluster(cluster_name, tags) + cluster = self.ecs_backend.create_cluster(cluster_name, tags, settings) return json.dumps({"cluster": cluster.response_object}) def list_clusters(self): @@ -42,6 +50,21 @@ class EC2ContainerServiceResponse(BaseResponse): } ) + def delete_capacity_provider(self): + name = self._get_param("capacityProvider") + provider = self.ecs_backend.delete_capacity_provider(name) + return json.dumps({"capacityProvider": provider.response_object}) + + def describe_capacity_providers(self): + names = self._get_param("capacityProviders") + providers, failures = self.ecs_backend.describe_capacity_providers(names) + return json.dumps( + { + "capacityProviders": [p.response_object for p in providers], + "failures": [p.response_object for p in failures], + } + ) + def describe_clusters(self): names = self._get_param("clusters") include = self._get_param("include") diff --git a/tests/test_ecs/test_ecs_boto3.py b/tests/test_ecs/test_ecs_boto3.py index 2a1893767..41c2439a7 100644 --- a/tests/test_ecs/test_ecs_boto3.py +++ b/tests/test_ecs/test_ecs_boto3.py @@ -39,6 +39,20 @@ def test_create_cluster(): response["cluster"]["activeServicesCount"].should.equal(0) +@mock_ecs +def test_create_cluster_with_setting(): + client = boto3.client("ecs", region_name="us-east-1") + cluster = client.create_cluster( + clusterName="test_ecs_cluster", + settings=[{"name": "containerInsights", "value": "disabled"}], + )["cluster"] + cluster["clusterName"].should.equal("test_ecs_cluster") + cluster["status"].should.equal("ACTIVE") + cluster.should.have.key("settings").equals( + [{"name": "containerInsights", "value": "disabled"}] + ) + + @mock_ecs def test_list_clusters(): client = boto3.client("ecs", region_name="us-east-2") @@ -112,7 +126,7 @@ def test_delete_cluster(): response["cluster"]["activeServicesCount"].should.equal(0) response = client.list_clusters() - len(response["clusterArns"]).should.equal(0) + response["clusterArns"].should.have.length_of(0) @mock_ecs @@ -685,7 +699,9 @@ def test_list_services(): @mock_ecs def test_describe_services(): client = boto3.client("ecs", region_name="us-east-1") - _ = client.create_cluster(clusterName="test_ecs_cluster") + cluster_arn = client.create_cluster(clusterName="test_ecs_cluster")["cluster"][ + "clusterArn" + ] _ = client.register_task_definition( family="test_ecs_task", containerDefinitions=[ @@ -721,6 +737,14 @@ def test_describe_services(): taskDefinition="test_ecs_task", desiredCount=2, ) + + # Verify we can describe services using the cluster ARN + response = client.describe_services( + cluster=cluster_arn, services=["test_ecs_service1"] + ) + response.should.have.key("services").length_of(1) + + # Verify we can describe services using both names and ARN's response = client.describe_services( cluster="test_ecs_cluster", services=[ @@ -1072,6 +1096,43 @@ def test_delete_service(): ) +@mock_ecs +def test_delete_service__using_arns(): + client = boto3.client("ecs", region_name="us-east-1") + cluster_arn = client.create_cluster(clusterName="test_ecs_cluster")["cluster"][ + "clusterArn" + ] + _ = 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"}, + } + ], + ) + service_arn = client.create_service( + cluster="test_ecs_cluster", + serviceName="test_ecs_service", + taskDefinition="test_ecs_task", + desiredCount=2, + )["service"]["serviceArn"] + _ = client.update_service( + cluster="test_ecs_cluster", service="test_ecs_service", desiredCount=0 + ) + response = client.delete_service(cluster=cluster_arn, service=service_arn) + response["service"]["clusterArn"].should.equal( + "arn:aws:ecs:us-east-1:{}:cluster/test_ecs_cluster".format(ACCOUNT_ID) + ) + + @mock_ecs def test_delete_service_force(): client = boto3.client("ecs", region_name="us-east-1") diff --git a/tests/test_ecs/test_ecs_capacity_provider.py b/tests/test_ecs/test_ecs_capacity_provider.py new file mode 100644 index 000000000..2311594a6 --- /dev/null +++ b/tests/test_ecs/test_ecs_capacity_provider.py @@ -0,0 +1,173 @@ +import boto3 + +from moto import mock_ecs +from moto.core import ACCOUNT_ID + + +@mock_ecs +def test_create_capacity_provider(): + client = boto3.client("ecs", region_name="us-west-1") + resp = client.create_capacity_provider( + name="my_provider", + autoScalingGroupProvider={ + "autoScalingGroupArn": "asg:arn", + "managedScaling": { + "status": "ENABLED", + "targetCapacity": 5, + "maximumScalingStepSize": 2, + }, + "managedTerminationProtection": "DISABLED", + }, + ) + resp.should.have.key("capacityProvider") + + provider = resp["capacityProvider"] + provider.should.have.key("capacityProviderArn") + provider.should.have.key("name").equals("my_provider") + provider.should.have.key("status").equals("ACTIVE") + provider.should.have.key("autoScalingGroupProvider").equals( + { + "autoScalingGroupArn": "asg:arn", + "managedScaling": { + "status": "ENABLED", + "targetCapacity": 5, + "maximumScalingStepSize": 2, + }, + "managedTerminationProtection": "DISABLED", + } + ) + + +@mock_ecs +def test_create_capacity_provider_with_tags(): + client = boto3.client("ecs", region_name="us-west-1") + resp = client.create_capacity_provider( + name="my_provider", + autoScalingGroupProvider={"autoScalingGroupArn": "asg:arn"}, + tags=[{"key": "k1", "value": "v1"}], + ) + resp.should.have.key("capacityProvider") + + provider = resp["capacityProvider"] + provider.should.have.key("capacityProviderArn") + provider.should.have.key("name").equals("my_provider") + provider.should.have.key("tags").equals([{"key": "k1", "value": "v1"}]) + + +@mock_ecs +def test_describe_capacity_provider__using_name(): + client = boto3.client("ecs", region_name="us-west-1") + client.create_capacity_provider( + name="my_provider", + autoScalingGroupProvider={ + "autoScalingGroupArn": "asg:arn", + "managedScaling": { + "status": "ENABLED", + "targetCapacity": 5, + "maximumScalingStepSize": 2, + }, + "managedTerminationProtection": "DISABLED", + }, + ) + + resp = client.describe_capacity_providers(capacityProviders=["my_provider"]) + resp.should.have.key("capacityProviders").length_of(1) + + provider = resp["capacityProviders"][0] + provider.should.have.key("capacityProviderArn") + provider.should.have.key("name").equals("my_provider") + provider.should.have.key("status").equals("ACTIVE") + provider.should.have.key("autoScalingGroupProvider").equals( + { + "autoScalingGroupArn": "asg:arn", + "managedScaling": { + "status": "ENABLED", + "targetCapacity": 5, + "maximumScalingStepSize": 2, + }, + "managedTerminationProtection": "DISABLED", + } + ) + + +@mock_ecs +def test_describe_capacity_provider__using_arn(): + client = boto3.client("ecs", region_name="us-west-1") + provider_arn = client.create_capacity_provider( + name="my_provider", + autoScalingGroupProvider={ + "autoScalingGroupArn": "asg:arn", + "managedScaling": { + "status": "ENABLED", + "targetCapacity": 5, + "maximumScalingStepSize": 2, + }, + "managedTerminationProtection": "DISABLED", + }, + )["capacityProvider"]["capacityProviderArn"] + + resp = client.describe_capacity_providers(capacityProviders=[provider_arn]) + resp.should.have.key("capacityProviders").length_of(1) + + provider = resp["capacityProviders"][0] + provider.should.have.key("name").equals("my_provider") + + +@mock_ecs +def test_describe_capacity_provider__missing(): + client = boto3.client("ecs", region_name="us-west-1") + client.create_capacity_provider( + name="my_provider", + autoScalingGroupProvider={ + "autoScalingGroupArn": "asg:arn", + "managedScaling": { + "status": "ENABLED", + "targetCapacity": 5, + "maximumScalingStepSize": 2, + }, + "managedTerminationProtection": "DISABLED", + }, + ) + + resp = client.describe_capacity_providers( + capacityProviders=["my_provider", "another_provider"] + ) + resp.should.have.key("capacityProviders").length_of(1) + resp.should.have.key("failures").length_of(1) + resp["failures"].should.contain( + { + "arn": f"arn:aws:ecs:us-west-1:{ACCOUNT_ID}:capacity_provider/another_provider", + "reason": "MISSING", + } + ) + + +@mock_ecs +def test_delete_capacity_provider(): + client = boto3.client("ecs", region_name="us-west-1") + client.create_capacity_provider( + name="my_provider", autoScalingGroupProvider={"autoScalingGroupArn": "asg:arn"} + ) + + resp = client.delete_capacity_provider(capacityProvider="my_provider") + resp.should.have.key("capacityProvider") + resp["capacityProvider"].should.have.key("name").equals("my_provider") + + # We can't find either provider + resp = client.describe_capacity_providers( + capacityProviders=["my_provider", "another_provider"] + ) + resp.should.have.key("capacityProviders").length_of(0) + resp.should.have.key("failures").length_of(2) + resp["failures"].should.contain( + { + "arn": f"arn:aws:ecs:us-west-1:{ACCOUNT_ID}:capacity_provider/another_provider", + "reason": "MISSING", + } + ) + resp["failures"].should.contain( + { + "arn": f"arn:aws:ecs:us-west-1:{ACCOUNT_ID}:capacity_provider/my_provider", + "reason": "MISSING", + } + )