Repository: getmoto/moto

Issue:
Add support for new ECS ARN format
As far as I can (I made sure I was using the newest release + searched existing issues), Moto currently does not support [the new ARN format for ECS](https://aws.amazon.com/blogs/compute/migrating-your-amazon-ecs-deployment-to-the-new-arn-and-resource-id-format-2/).

It would be nice if there was a way to enable this - for me, I'm working on some tooling that depends on using this new format.

I'd be happy to try implementing this, the only thing that might be helpful is some guidance on how the API should be to enable this new format.


Discussion:
Hi @tonycosentini, thanks for raising this.

Best way to make this configurable is IMO to have an additional [environment variable](https://github.com/spulec/moto/blob/master/moto/settings.py) that defaults to the new format.

PR's are always welcome! Let me know if you need any help.



Resolving patch:
diff --git a/moto/ecs/models.py b/moto/ecs/models.py
--- a/moto/ecs/models.py
+++ b/moto/ecs/models.py
@@ -7,6 +7,7 @@
 import pytz
 from boto3 import Session
 
+from moto import settings
 from moto.core import BaseBackend, BaseModel, CloudFormationModel, ACCOUNT_ID
 from moto.core.exceptions import JsonRESTError
 from moto.core.utils import unix_time, pascal_to_camelcase, remap_nested_keys
@@ -272,10 +273,9 @@ def __init__(
         started_by="",
         tags=[],
     ):
+        self.id = str(uuid.uuid4())
+        self.cluster_name = cluster.name
         self.cluster_arn = cluster.arn
-        self.task_arn = "arn:aws:ecs:{0}:{1}:task/{2}".format(
-            cluster.region_name, ACCOUNT_ID, str(uuid.uuid4())
-        )
         self.container_instance_arn = container_instance_arn
         self.last_status = "RUNNING"
         self.desired_status = "RUNNING"
@@ -286,10 +286,20 @@ def __init__(
         self.tags = tags
         self.stopped_reason = ""
         self.resource_requirements = resource_requirements
+        self.region_name = cluster.region_name
+
+    @property
+    def task_arn(self):
+        if settings.ecs_new_arn_format():
+            return f"arn:aws:ecs:{self.region_name}:{ACCOUNT_ID}:task/{self.cluster_name}/{self.id}"
+        return "arn:aws:ecs:{0}:{1}:task/{2}".format(
+            self.region_name, ACCOUNT_ID, self.id
+        )
 
     @property
     def response_object(self):
         response_object = self.gen_response_object()
+        response_object["taskArn"] = self.task_arn
         return response_object
 
 
@@ -307,10 +317,8 @@ def __init__(
         launch_type=None,
         service_registries=None,
     ):
+        self.cluster_name = cluster.name
         self.cluster_arn = cluster.arn
-        self.arn = "arn:aws:ecs:{0}:{1}:service/{2}".format(
-            cluster.region_name, ACCOUNT_ID, service_name
-        )
         self.name = service_name
         self.status = "ACTIVE"
         self.running_count = 0
@@ -346,6 +354,15 @@ def __init__(
         )
         self.tags = tags if tags is not None else []
         self.pending_count = 0
+        self.region_name = cluster.region_name
+
+    @property
+    def arn(self):
+        if settings.ecs_new_arn_format():
+            return f"arn:aws:ecs:{self.region_name}:{ACCOUNT_ID}:service/{self.cluster_name}/{self.name}"
+        return "arn:aws:ecs:{0}:{1}:service/{2}".format(
+            self.region_name, ACCOUNT_ID, self.name
+        )
 
     @property
     def physical_resource_id(self):
@@ -354,7 +371,7 @@ def physical_resource_id(self):
     @property
     def response_object(self):
         response_object = self.gen_response_object()
-        del response_object["name"], response_object["arn"], response_object["tags"]
+        del response_object["name"], response_object["tags"]
         response_object["serviceName"] = self.name
         response_object["serviceArn"] = self.arn
         response_object["schedulingStrategy"] = self.scheduling_strategy
@@ -450,7 +467,7 @@ def get_cfn_attribute(self, attribute_name):
 
 
 class ContainerInstance(BaseObject):
-    def __init__(self, ec2_instance_id, region_name):
+    def __init__(self, ec2_instance_id, region_name, cluster_name):
         self.ec2_instance_id = ec2_instance_id
         self.agent_connected = True
         self.status = "ACTIVE"
@@ -486,9 +503,6 @@ def __init__(self, ec2_instance_id, region_name):
                 "type": "STRINGSET",
             },
         ]
-        self.container_instance_arn = "arn:aws:ecs:{0}:{1}:container-instance/{2}".format(
-            region_name, ACCOUNT_ID, str(uuid.uuid4())
-        )
         self.pending_tasks_count = 0
         self.remaining_resources = [
             {
@@ -539,10 +553,22 @@ def __init__(self, ec2_instance_id, region_name):
             else "linux",  # options are windows and linux, linux is default
         }
         self.registered_at = datetime.now(pytz.utc)
+        self.region_name = region_name
+        self.id = str(uuid.uuid4())
+        self.cluster_name = cluster_name
+
+    @property
+    def container_instance_arn(self):
+        if settings.ecs_new_arn_format():
+            return f"arn:aws:ecs:{self.region_name}:{ACCOUNT_ID}:container-instance/{self.cluster_name}/{self.id}"
+        return (
+            f"arn:aws:ecs:{self.region_name}:{ACCOUNT_ID}:container-instance/{self.id}"
+        )
 
     @property
     def response_object(self):
         response_object = self.gen_response_object()
+        response_object["containerInstanceArn"] = self.container_instance_arn
         response_object["attributes"] = [
             self._format_attribute(name, value)
             for name, value in response_object["attributes"].items()
@@ -1198,7 +1224,9 @@ def register_container_instance(self, cluster_str, ec2_instance_id):
         cluster_name = cluster_str.split("/")[-1]
         if cluster_name not in self.clusters:
             raise Exception("{0} is not a cluster".format(cluster_name))
-        container_instance = ContainerInstance(ec2_instance_id, self.region_name)
+        container_instance = ContainerInstance(
+            ec2_instance_id, self.region_name, cluster_name
+        )
         if not self.container_instances.get(cluster_name):
             self.container_instances[cluster_name] = {}
         container_instance_id = container_instance.container_instance_arn.split("/")[-1]
diff --git a/moto/settings.py b/moto/settings.py
--- a/moto/settings.py
+++ b/moto/settings.py
@@ -32,3 +32,7 @@ def get_s3_default_key_buffer_size():
             "MOTO_S3_DEFAULT_KEY_BUFFER_SIZE", S3_UPLOAD_PART_MIN_SIZE - 1024
         )
     )
+
+
+def ecs_new_arn_format():
+    return os.environ.get("MOTO_ECS_NEW_ARN", "false").lower() == "true"


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
@@ -2,15 +2,16 @@
 
 from botocore.exceptions import ClientError
 import boto3
+import mock
 import sure  # noqa # pylint: disable=unused-import
 import json
+import os
 
 from moto.core import ACCOUNT_ID
 from moto.ec2 import utils as ec2_utils
 from uuid import UUID
 
-from moto import mock_ecs
-from moto import mock_ec2
+from moto import mock_ecs, mock_ec2, settings
 from moto.ecs.exceptions import (
     ClusterNotFoundException,
     ServiceNotFoundException,
@@ -20,6 +21,7 @@
 )
 import pytest
 from tests import EXAMPLE_AMI_ID
+from unittest import SkipTest
 
 
 @mock_ecs
@@ -724,6 +726,36 @@ def test_describe_services():
     response["services"][1]["launchType"].should.equal("EC2")
 
 
+@mock_ecs
+@mock.patch.dict(os.environ, {"MOTO_ECS_NEW_ARN": "TrUe"})
+def test_describe_services_new_arn():
+    if settings.TEST_SERVER_MODE:
+        raise SkipTest("Cant set environment variables in server mode")
+    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",}
+        ],
+    )
+    _ = client.create_service(
+        cluster="test_ecs_cluster",
+        serviceName="test_ecs_service1",
+        taskDefinition="test_ecs_task",
+        desiredCount=2,
+        tags=[{"key": "Name", "value": "test_ecs_service1"}],
+    )
+    response = client.describe_services(
+        cluster="test_ecs_cluster", services=["test_ecs_service1"]
+    )
+    response["services"][0]["serviceArn"].should.equal(
+        "arn:aws:ecs:us-east-1:{}:service/test_ecs_cluster/test_ecs_service1".format(
+            ACCOUNT_ID
+        )
+    )
+
+
 @mock_ecs
 def test_describe_services_scheduling_strategy():
     client = boto3.client("ecs", region_name="us-east-1")
@@ -1125,6 +1157,37 @@ def test_register_container_instance():
     )
 
 
+@mock_ec2
+@mock_ecs
+@mock.patch.dict(os.environ, {"MOTO_ECS_NEW_ARN": "TrUe"})
+def test_register_container_instance_new_arn_format():
+    if settings.TEST_SERVER_MODE:
+        raise SkipTest("Cant set environment variables in server mode")
+    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=EXAMPLE_AMI_ID, 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
+    )
+
+    full_arn = response["containerInstance"]["containerInstanceArn"]
+    full_arn.should.match(
+        f"arn:aws:ecs:us-east-1:{ACCOUNT_ID}:container-instance/{test_cluster_name}/[a-z0-9-]+$"
+    )
+
+
 @mock_ec2
 @mock_ecs
 def test_deregister_container_instance():
@@ -1545,8 +1608,8 @@ def test_run_task_default_cluster():
         startedBy="moto",
     )
     len(response["tasks"]).should.equal(2)
-    response["tasks"][0]["taskArn"].should.contain(
-        "arn:aws:ecs:us-east-1:{}:task/".format(ACCOUNT_ID)
+    response["tasks"][0]["taskArn"].should.match(
+        "arn:aws:ecs:us-east-1:{}:task/[a-z0-9-]+$".format(ACCOUNT_ID)
     )
     response["tasks"][0]["clusterArn"].should.equal(
         "arn:aws:ecs:us-east-1:{}:cluster/default".format(ACCOUNT_ID)
@@ -1564,6 +1627,54 @@ def test_run_task_default_cluster():
     response["tasks"][0]["stoppedReason"].should.equal("")
 
 
+@mock_ec2
+@mock_ecs
+@mock.patch.dict(os.environ, {"MOTO_ECS_NEW_ARN": "TrUe"})
+def test_run_task_default_cluster_new_arn_format():
+    if settings.TEST_SERVER_MODE:
+        raise SkipTest("Cant set environment variables in server mode")
+    client = boto3.client("ecs", region_name="us-east-1")
+    ec2 = boto3.resource("ec2", region_name="us-east-1")
+
+    test_cluster_name = "default"
+
+    client.create_cluster(clusterName=test_cluster_name)
+
+    test_instance = ec2.create_instances(
+        ImageId=EXAMPLE_AMI_ID, MinCount=1, MaxCount=1
+    )[0]
+
+    instance_id_document = json.dumps(
+        ec2_utils.generate_instance_identity_document(test_instance)
+    )
+
+    client.register_container_instance(
+        cluster=test_cluster_name, instanceIdentityDocument=instance_id_document
+    )
+
+    client.register_task_definition(
+        family="test_ecs_task",
+        containerDefinitions=[
+            {
+                "name": "hello_world",
+                "image": "docker/hello-world:latest",
+                "cpu": 1024,
+                "memory": 400,
+            }
+        ],
+    )
+    response = client.run_task(
+        launchType="FARGATE",
+        overrides={},
+        taskDefinition="test_ecs_task",
+        count=1,
+        startedBy="moto",
+    )
+    response["tasks"][0]["taskArn"].should.match(
+        f"arn:aws:ecs:us-east-1:{ACCOUNT_ID}:task/{test_cluster_name}/[a-z0-9-]+$"
+    )
+
+
 @mock_ecs
 def test_run_task_exceptions():
     client = boto3.client("ecs", region_name="us-east-1")
@@ -1849,7 +1960,7 @@ def test_describe_task_definition_by_family():
     task["containerDefinitions"][0].should.equal(
         dict(
             container_definition,
-            **{"mountPoints": [], "portMappings": [], "volumesFrom": []}
+            **{"mountPoints": [], "portMappings": [], "volumesFrom": []},
         )
     )
     task["taskDefinitionArn"].should.equal(