Repository: getmoto/moto

Issue:
create_capacity_provider for ECS
Hi there. I was just wondering if you'll be adding support for `create_capacity_provider`?

Thank you.


Discussion:
Hi @OzNetNerd, thanks for raising this - I'll mark it as an enhancement. 
This is not in the pipeline at the moment, so if this is something you need with high priority, PR's are always welcome! 

http://docs.getmoto.org/en/latest/docs/contributing/index.html

Resolving patch:
diff --git a/moto/ecs/models.py b/moto/ecs/models.py
--- a/moto/ecs/models.py
+++ b/moto/ecs/models.py
@@ -61,7 +61,7 @@ def __init__(self, name, value):
 
 
 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 @@ def __init__(self, cluster_name, region_name):
         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 @@ def response_object(self):
         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 @@ def _get_cluster(self, name):
 
         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 @@ def describe_task_definition(self, task_definition_str):
         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 @@ def stop_task(self, cluster_str, task_str, reason):
             "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 @@ def list_services(self, cluster_str, scheduling_strategy=None):
 
     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 @@ def update_service(
 
     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
--- a/moto/ecs/responses.py
+++ b/moto/ecs/responses.py
@@ -25,12 +25,20 @@ def request_params(self):
     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 @@ def list_clusters(self):
             }
         )
 
+    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")


Test patch:
diff --git a/tests/test_ecs/test_ecs_boto3.py b/tests/test_ecs/test_ecs_boto3.py
--- 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
--- /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",
+        }
+    )