diff --git a/moto/ecs/exceptions.py b/moto/ecs/exceptions.py index 40bd036f9..cfdc36fa3 100644 --- a/moto/ecs/exceptions.py +++ b/moto/ecs/exceptions.py @@ -48,10 +48,26 @@ class ClusterNotFoundException(JsonRESTError): ) +class EcsClientException(JsonRESTError): + code = 400 + + def __init__(self, message): + super(EcsClientException, self).__init__( + error_type="ClientException", message=message, + ) + + class InvalidParameterException(JsonRESTError): code = 400 def __init__(self, message): super(InvalidParameterException, self).__init__( - error_type="ClientException", message=message, + error_type="InvalidParameterException", message=message, + ) + + +class UnknownAccountSettingException(InvalidParameterException): + def __init__(self): + super().__init__( + "unknown should be one of [serviceLongArnFormat,taskLongArnFormat,containerInstanceLongArnFormat,containerLongArnFormat,awsvpcTrunking,containerInsights,dualStackIPv6]" ) diff --git a/moto/ecs/models.py b/moto/ecs/models.py index 82505f944..01b2e9f5f 100644 --- a/moto/ecs/models.py +++ b/moto/ecs/models.py @@ -13,12 +13,14 @@ from moto.core.exceptions import JsonRESTError from moto.core.utils import unix_time, pascal_to_camelcase, remap_nested_keys from moto.ec2 import ec2_backends from .exceptions import ( + EcsClientException, ServiceNotFoundException, TaskDefinitionNotFoundException, TaskSetNotFoundException, ClusterNotFoundException, InvalidParameterException, RevisionNotFoundException, + UnknownAccountSettingException, ) @@ -35,7 +37,9 @@ class BaseObject(BaseModel): def gen_response_object(self): response_object = copy(self.__dict__) for key, value in self.__dict__.items(): - if "_" in key: + if key.startswith("_"): + del response_object[key] + elif "_" in key: response_object[self.camelCase(key)] = value del response_object[key] return response_object @@ -45,6 +49,12 @@ class BaseObject(BaseModel): return self.gen_response_object() +class AccountSetting(BaseObject): + def __init__(self, name, value): + self.name = name + self.value = value + + class Cluster(BaseObject, CloudFormationModel): def __init__(self, cluster_name, region_name): self.active_services_count = 0 @@ -269,6 +279,7 @@ class Task(BaseObject): task_definition, container_instance_arn, resource_requirements, + backend, overrides={}, started_by="", tags=[], @@ -287,10 +298,11 @@ class Task(BaseObject): self.stopped_reason = "" self.resource_requirements = resource_requirements self.region_name = cluster.region_name + self._backend = backend @property def task_arn(self): - if settings.ecs_new_arn_format(): + if self._backend.enable_long_arn_for_name(name="taskLongArnFormat"): 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 @@ -315,6 +327,7 @@ class Service(BaseObject, CloudFormationModel): tags=None, deployment_controller=None, launch_type=None, + backend=None, service_registries=None, ): self.cluster_name = cluster.name @@ -355,10 +368,11 @@ class Service(BaseObject, CloudFormationModel): self.tags = tags if tags is not None else [] self.pending_count = 0 self.region_name = cluster.region_name + self._backend = backend @property def arn(self): - if settings.ecs_new_arn_format(): + if self._backend.enable_long_arn_for_name(name="serviceLongArnFormat"): 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 @@ -467,7 +481,7 @@ class Service(BaseObject, CloudFormationModel): class ContainerInstance(BaseObject): - def __init__(self, ec2_instance_id, region_name, cluster_name): + def __init__(self, ec2_instance_id, region_name, cluster_name, backend): self.ec2_instance_id = ec2_instance_id self.agent_connected = True self.status = "ACTIVE" @@ -556,10 +570,13 @@ class ContainerInstance(BaseObject): self.region_name = region_name self.id = str(uuid.uuid4()) self.cluster_name = cluster_name + self._backend = backend @property def container_instance_arn(self): - if settings.ecs_new_arn_format(): + if self._backend.enable_long_arn_for_name( + name="containerInstanceLongArnFormat" + ): 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}" @@ -686,6 +703,7 @@ class TaskSet(BaseObject): class EC2ContainerServiceBackend(BaseBackend): def __init__(self, region_name): super(EC2ContainerServiceBackend, self).__init__() + self.account_settings = dict() self.clusters = {} self.task_definitions = {} self.tasks = {} @@ -879,9 +897,10 @@ class EC2ContainerServiceBackend(BaseBackend): task_definition, container_instance_arn, resource_requirements, - overrides or {}, - started_by or "", - tags or [], + backend=self, + overrides=overrides or {}, + started_by=started_by or "", + tags=tags or [], ) self.update_container_instance_resources( container_instance, resource_requirements @@ -978,7 +997,7 @@ class EC2ContainerServiceBackend(BaseBackend): self.tasks[cluster.name] = {} tasks = [] if not container_instances: - raise InvalidParameterException("Container Instances cannot be empty.") + raise EcsClientException("Container Instances cannot be empty.") container_instance_ids = [x.split("/")[-1] for x in container_instances] resource_requirements = self._calculate_task_resource_requirements( @@ -993,8 +1012,9 @@ class EC2ContainerServiceBackend(BaseBackend): task_definition, container_instance.container_instance_arn, resource_requirements, - overrides or {}, - started_by or "", + backend=self, + overrides=overrides or {}, + started_by=started_by or "", ) tasks.append(task) self.update_container_instance_resources( @@ -1121,9 +1141,7 @@ class EC2ContainerServiceBackend(BaseBackend): launch_type = launch_type if launch_type is not None else "EC2" if launch_type not in ["EC2", "FARGATE"]: - raise InvalidParameterException( - "launch type should be one of [EC2,FARGATE]" - ) + raise EcsClientException("launch type should be one of [EC2,FARGATE]") service = Service( cluster, @@ -1135,6 +1153,7 @@ class EC2ContainerServiceBackend(BaseBackend): tags, deployment_controller, launch_type, + backend=self, service_registries=service_registries, ) cluster_service_pair = "{0}:{1}".format(cluster.name, service_name) @@ -1225,7 +1244,7 @@ class EC2ContainerServiceBackend(BaseBackend): 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, cluster_name + ec2_instance_id, self.region_name, cluster_name, backend=self ) if not self.container_instances.get(cluster_name): self.container_instances[cluster_name] = {} @@ -1250,7 +1269,7 @@ class EC2ContainerServiceBackend(BaseBackend): cluster = self._get_cluster(cluster_str) if not list_container_instance_ids: - raise InvalidParameterException("Container Instances cannot be empty.") + raise EcsClientException("Container Instances cannot be empty.") failures = [] container_instance_objects = [] for container_instance_id in list_container_instance_ids: @@ -1590,9 +1609,7 @@ class EC2ContainerServiceBackend(BaseBackend): ): launch_type = launch_type if launch_type is not None else "EC2" if launch_type not in ["EC2", "FARGATE"]: - raise InvalidParameterException( - "launch type should be one of [EC2,FARGATE]" - ) + raise EcsClientException("launch type should be one of [EC2,FARGATE]") task_set = TaskSet( service, @@ -1702,6 +1719,41 @@ class EC2ContainerServiceBackend(BaseBackend): task_set.status = "ACTIVE" return task_set_obj + def list_account_settings(self, name=None, value=None): + expected_names = [ + "serviceLongArnFormat", + "taskLongArnFormat", + "containerInstanceLongArnFormat", + "containerLongArnFormat", + "awsvpcTrunking", + "containerInsights", + "dualStackIPv6", + ] + if name and name not in expected_names: + raise UnknownAccountSettingException() + all_settings = self.account_settings.values() + return [ + s + for s in all_settings + if (not name or s.name == name) and (not value or s.value == value) + ] + + def put_account_setting(self, name, value): + account_setting = AccountSetting(name, value) + self.account_settings[name] = account_setting + return account_setting + + def delete_account_setting(self, name): + self.account_settings.pop(name, None) + + def enable_long_arn_for_name(self, name): + if settings.ecs_new_arn_format(): + return True + account = self.account_settings.get(name, None) + if account and account.value == "enabled": + return True + return False + ecs_backends = {} for region in Session().get_available_regions("ecs"): diff --git a/moto/ecs/responses.py b/moto/ecs/responses.py index 578a55538..b7b35c6d6 100644 --- a/moto/ecs/responses.py +++ b/moto/ecs/responses.py @@ -449,3 +449,20 @@ class EC2ContainerServiceResponse(BaseResponse): cluster_str, service_str, primary_task_set ) return json.dumps({"taskSet": task_set.response_object}) + + def put_account_setting(self): + name = self._get_param("name") + value = self._get_param("value") + account_setting = self.ecs_backend.put_account_setting(name, value) + return json.dumps({"setting": account_setting.response_object}) + + def list_account_settings(self): + name = self._get_param("name") + value = self._get_param("value") + account_settings = self.ecs_backend.list_account_settings(name, value) + return json.dumps({"settings": [s.response_object for s in account_settings]}) + + def delete_account_setting(self): + name = self._get_param("name") + self.ecs_backend.delete_account_setting(name) + return "{}" diff --git a/tests/test_ecs/test_ecs_account_settings.py b/tests/test_ecs/test_ecs_account_settings.py new file mode 100644 index 000000000..76056ffce --- /dev/null +++ b/tests/test_ecs/test_ecs_account_settings.py @@ -0,0 +1,244 @@ +from botocore.exceptions import ClientError +import boto3 +import sure # noqa # pylint: disable=unused-import +import json + +from moto.core import ACCOUNT_ID +from moto.ec2 import utils as ec2_utils + +from moto import mock_ecs, mock_ec2 +import pytest +from tests import EXAMPLE_AMI_ID + + +@mock_ecs +def test_list_account_settings_initial(): + client = boto3.client("ecs", region_name="eu-west-1") + + resp = client.list_account_settings() + resp.should.have.key("settings").equal([]) + + +@mock_ecs +@pytest.mark.parametrize( + "name", + ["containerInstanceLongArnFormat", "serviceLongArnFormat", "taskLongArnFormat"], +) +@pytest.mark.parametrize("value", ["enabled", "disabled"]) +def test_put_account_setting(name, value): + client = boto3.client("ecs", region_name="eu-west-1") + + resp = client.put_account_setting(name=name, value=value) + resp.should.have.key("setting") + resp["setting"].should.equal({"name": name, "value": value}) + + +@mock_ecs +def test_list_account_setting(): + client = boto3.client("ecs", region_name="eu-west-1") + + client.put_account_setting(name="containerInstanceLongArnFormat", value="enabled") + client.put_account_setting(name="serviceLongArnFormat", value="disabled") + client.put_account_setting(name="taskLongArnFormat", value="enabled") + + resp = client.list_account_settings() + resp.should.have.key("settings").length_of(3) + resp["settings"].should.contain( + {"name": "containerInstanceLongArnFormat", "value": "enabled"} + ) + resp["settings"].should.contain( + {"name": "serviceLongArnFormat", "value": "disabled"} + ) + resp["settings"].should.contain({"name": "taskLongArnFormat", "value": "enabled"}) + + resp = client.list_account_settings(name="serviceLongArnFormat") + resp.should.have.key("settings").length_of(1) + resp["settings"].should.contain( + {"name": "serviceLongArnFormat", "value": "disabled"} + ) + + resp = client.list_account_settings(value="enabled") + resp.should.have.key("settings").length_of(2) + resp["settings"].should.contain( + {"name": "containerInstanceLongArnFormat", "value": "enabled"} + ) + resp["settings"].should.contain({"name": "taskLongArnFormat", "value": "enabled"}) + + +@mock_ecs +def test_list_account_settings_wrong_name(): + client = boto3.client("ecs", region_name="eu-west-1") + + with pytest.raises(ClientError) as exc: + client.list_account_settings(name="unknown") + err = exc.value.response["Error"] + err["Code"].should.equal("InvalidParameterException") + err["Message"].should.equal( + "unknown should be one of [serviceLongArnFormat,taskLongArnFormat,containerInstanceLongArnFormat,containerLongArnFormat,awsvpcTrunking,containerInsights,dualStackIPv6]" + ) + + +@mock_ecs +def test_delete_account_setting(): + client = boto3.client("ecs", region_name="eu-west-1") + + client.put_account_setting(name="containerInstanceLongArnFormat", value="enabled") + client.put_account_setting(name="serviceLongArnFormat", value="enabled") + client.put_account_setting(name="taskLongArnFormat", value="enabled") + + resp = client.list_account_settings() + resp.should.have.key("settings").length_of(3) + + client.delete_account_setting(name="serviceLongArnFormat") + + resp = client.list_account_settings() + resp.should.have.key("settings").length_of(2) + resp["settings"].should.contain( + {"name": "containerInstanceLongArnFormat", "value": "enabled"} + ) + resp["settings"].should.contain({"name": "taskLongArnFormat", "value": "enabled"}) + + +@mock_ec2 +@mock_ecs +def test_put_account_setting_changes_service_arn(): + client = boto3.client("ecs", region_name="eu-west-1") + client.put_account_setting(name="serviceLongArnFormat", value="enabled") + + _ = client.create_cluster(clusterName="dummy-cluster") + _ = client.register_task_definition( + family="test_ecs_task", + containerDefinitions=[ + { + "name": "hello_world", + "image": "docker/hello-world:latest", + "cpu": 1024, + "memory": 400, + } + ], + ) + client.create_service( + cluster="dummy-cluster", + serviceName="test-ecs-service", + taskDefinition="test_ecs_task", + desiredCount=2, + launchType="FARGATE", + tags=[{"key": "ResourceOwner", "value": "Dummy"}], + ) + + # Initial response is long + response = client.list_services(cluster="dummy-cluster", launchType="FARGATE") + service_arn = response["serviceArns"][0] + service_arn.should.equal( + "arn:aws:ecs:eu-west-1:{}:service/dummy-cluster/test-ecs-service".format( + ACCOUNT_ID + ) + ) + + # Second invocation returns short ARN's, after deleting the longArn-preference + client.delete_account_setting(name="serviceLongArnFormat") + response = client.list_services(cluster="dummy-cluster", launchType="FARGATE") + service_arn = response["serviceArns"][0] + service_arn.should.equal( + "arn:aws:ecs:eu-west-1:{}:service/test-ecs-service".format(ACCOUNT_ID) + ) + + +@mock_ec2 +@mock_ecs +def test_put_account_setting_changes_containerinstance_arn(): + 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) + ) + + # Initial ARN should be short + 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/[a-z0-9-]+$" + ) + + # Now enable long-format + ecs_client.put_account_setting( + name="containerInstanceLongArnFormat", value="enabled" + ) + 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_run_task_default_cluster_new_arn_format(): + 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, + } + ], + ) + # Initial ARN is short-format + client.put_account_setting(name="taskLongArnFormat", value="disabled") + 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/[a-z0-9-]+$" + ) + + # Enable long-format for the next task + client.put_account_setting(name="taskLongArnFormat", value="enabled") + 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-]+$" + )