2231 lines
85 KiB
Python
2231 lines
85 KiB
Python
import re
|
|
from copy import copy
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
|
from os import getenv
|
|
|
|
from moto import settings
|
|
from moto.core import BaseBackend, BackendDict, BaseModel, CloudFormationModel
|
|
from moto.core.exceptions import JsonRESTError
|
|
from moto.core.utils import unix_time, pascal_to_camelcase, remap_nested_keys
|
|
|
|
from ..ec2.utils import random_private_ip
|
|
from moto.ec2 import ec2_backends
|
|
from moto.moto_api._internal import mock_random
|
|
from .exceptions import (
|
|
EcsClientException,
|
|
ServiceNotFoundException,
|
|
TaskDefinitionNotFoundException,
|
|
TaskSetNotFoundException,
|
|
ClusterNotFoundException,
|
|
InvalidParameterException,
|
|
RevisionNotFoundException,
|
|
UnknownAccountSettingException,
|
|
)
|
|
|
|
|
|
class BaseObject(BaseModel):
|
|
def camelCase(self, key: str) -> str:
|
|
words = []
|
|
for i, word in enumerate(key.split("_")):
|
|
if i > 0:
|
|
words.append(word.title())
|
|
else:
|
|
words.append(word)
|
|
return "".join(words)
|
|
|
|
def gen_response_object(self) -> Dict[str, Any]:
|
|
response_object = copy(self.__dict__)
|
|
for key, value in self.__dict__.items():
|
|
if key.startswith("_"):
|
|
del response_object[key]
|
|
elif "_" in key:
|
|
response_object[self.camelCase(key)] = value
|
|
del response_object[key]
|
|
return response_object
|
|
|
|
@property
|
|
def response_object(self) -> Dict[str, Any]: # type: ignore[misc]
|
|
return self.gen_response_object()
|
|
|
|
|
|
class AccountSetting(BaseObject):
|
|
def __init__(self, name: str, value: str):
|
|
self.name = name
|
|
self.value = value
|
|
|
|
|
|
class Cluster(BaseObject, CloudFormationModel):
|
|
def __init__(
|
|
self,
|
|
cluster_name: str,
|
|
account_id: str,
|
|
region_name: str,
|
|
cluster_settings: Optional[List[Dict[str, str]]] = None,
|
|
configuration: Optional[Dict[str, Any]] = None,
|
|
capacity_providers: Optional[List[str]] = None,
|
|
default_capacity_provider_strategy: Optional[List[Dict[str, Any]]] = None,
|
|
tags: Optional[List[Dict[str, str]]] = None,
|
|
service_connect_defaults: Optional[Dict[str, str]] = None,
|
|
):
|
|
self.active_services_count = 0
|
|
self.arn = f"arn:aws:ecs:{region_name}:{account_id}:cluster/{cluster_name}"
|
|
self.name = cluster_name
|
|
self.pending_tasks_count = 0
|
|
self.registered_container_instances_count = 0
|
|
self.running_tasks_count = 0
|
|
self.status = "ACTIVE"
|
|
self.region_name = region_name
|
|
self.settings = cluster_settings or [
|
|
{"name": "containerInsights", "value": "disabled"}
|
|
]
|
|
self.configuration = configuration
|
|
self.capacity_providers = capacity_providers
|
|
self.default_capacity_provider_strategy = default_capacity_provider_strategy
|
|
self.tags = tags
|
|
self.service_connect_defaults = service_connect_defaults
|
|
|
|
@property
|
|
def physical_resource_id(self) -> str:
|
|
return self.name
|
|
|
|
@property
|
|
def response_object(self) -> Dict[str, Any]: # type: ignore[misc]
|
|
response_object = self.gen_response_object()
|
|
response_object["clusterArn"] = self.arn
|
|
response_object["clusterName"] = self.name
|
|
response_object["capacityProviders"] = self.capacity_providers
|
|
response_object[
|
|
"defaultCapacityProviderStrategy"
|
|
] = self.default_capacity_provider_strategy
|
|
del response_object["arn"], response_object["name"]
|
|
return response_object
|
|
|
|
@staticmethod
|
|
def cloudformation_name_type() -> str:
|
|
return "ClusterName"
|
|
|
|
@staticmethod
|
|
def cloudformation_type() -> str:
|
|
# https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ecs-cluster.html
|
|
return "AWS::ECS::Cluster"
|
|
|
|
@classmethod
|
|
def create_from_cloudformation_json( # type: ignore[misc]
|
|
cls,
|
|
resource_name: str,
|
|
cloudformation_json: Any,
|
|
account_id: str,
|
|
region_name: str,
|
|
**kwargs: Any,
|
|
) -> "Cluster":
|
|
ecs_backend = ecs_backends[account_id][region_name]
|
|
return ecs_backend.create_cluster(
|
|
# ClusterName is optional in CloudFormation, thus create a random
|
|
# name if necessary
|
|
cluster_name=resource_name
|
|
)
|
|
|
|
@classmethod
|
|
def update_from_cloudformation_json( # type: ignore[misc]
|
|
cls,
|
|
original_resource: Any,
|
|
new_resource_name: str,
|
|
cloudformation_json: Any,
|
|
account_id: str,
|
|
region_name: str,
|
|
) -> "Cluster":
|
|
if original_resource.name != new_resource_name:
|
|
ecs_backend = ecs_backends[account_id][region_name]
|
|
ecs_backend.delete_cluster(original_resource.arn)
|
|
return ecs_backend.create_cluster(
|
|
# ClusterName is optional in CloudFormation, thus create a
|
|
# random name if necessary
|
|
cluster_name=new_resource_name
|
|
)
|
|
else:
|
|
# no-op when nothing changed between old and new resources
|
|
return original_resource
|
|
|
|
@classmethod
|
|
def has_cfn_attr(cls, attr: str) -> bool:
|
|
return attr in ["Arn"]
|
|
|
|
def get_cfn_attribute(self, attribute_name: str) -> str:
|
|
from moto.cloudformation.exceptions import UnformattedGetAttTemplateException
|
|
|
|
if attribute_name == "Arn":
|
|
return self.arn
|
|
raise UnformattedGetAttTemplateException()
|
|
|
|
|
|
class TaskDefinition(BaseObject, CloudFormationModel):
|
|
def __init__(
|
|
self,
|
|
family: str,
|
|
revision: int,
|
|
container_definitions: List[Dict[str, Any]],
|
|
account_id: str,
|
|
region_name: str,
|
|
network_mode: Optional[str] = None,
|
|
volumes: Optional[List[Dict[str, Any]]] = None,
|
|
tags: Optional[List[Dict[str, str]]] = None,
|
|
placement_constraints: Optional[List[Dict[str, str]]] = None,
|
|
requires_compatibilities: Optional[List[str]] = None,
|
|
cpu: Optional[str] = None,
|
|
memory: Optional[str] = None,
|
|
task_role_arn: Optional[str] = None,
|
|
execution_role_arn: Optional[str] = None,
|
|
proxy_configuration: Optional[Dict[str, Any]] = None,
|
|
inference_accelerators: Optional[List[Dict[str, str]]] = None,
|
|
runtime_platform: Optional[Dict[str, str]] = None,
|
|
ipc_mode: Optional[str] = None,
|
|
pid_mode: Optional[str] = None,
|
|
ephemeral_storage: Optional[Dict[str, int]] = None,
|
|
):
|
|
self.family = family
|
|
self.revision = revision
|
|
self.arn = f"arn:aws:ecs:{region_name}:{account_id}:task-definition/{family}:{revision}"
|
|
|
|
default_container_definition = {
|
|
"cpu": 0,
|
|
"portMappings": [],
|
|
"essential": True,
|
|
"environment": [],
|
|
"mountPoints": [],
|
|
"volumesFrom": [],
|
|
}
|
|
self.container_definitions = []
|
|
for container_definition in container_definitions:
|
|
full_definition = default_container_definition.copy()
|
|
full_definition.update(container_definition)
|
|
self.container_definitions.append(full_definition)
|
|
|
|
self.tags = tags if tags is not None else []
|
|
|
|
if volumes is None:
|
|
self.volumes = []
|
|
else:
|
|
self.volumes = volumes
|
|
for volume in volumes:
|
|
if "efsVolumeConfiguration" in volume:
|
|
# We should reach into EFS to verify this volume exists
|
|
efs_config = volume["efsVolumeConfiguration"]
|
|
if "rootDirectory" not in efs_config:
|
|
efs_config["rootDirectory"] = "/"
|
|
|
|
if not requires_compatibilities or requires_compatibilities == ["EC2"]:
|
|
self.compatibilities = ["EC2"]
|
|
else:
|
|
self.compatibilities = ["EC2", "FARGATE"]
|
|
|
|
if network_mode is None and "FARGATE" not in self.compatibilities:
|
|
self.network_mode: Optional[str] = "bridge"
|
|
elif "FARGATE" in self.compatibilities:
|
|
self.network_mode: Optional[str] = "awsvpc" # type: ignore[no-redef]
|
|
else:
|
|
self.network_mode = network_mode
|
|
|
|
if task_role_arn is not None:
|
|
self.task_role_arn = task_role_arn
|
|
if execution_role_arn is not None:
|
|
self.execution_role_arn = execution_role_arn
|
|
|
|
self.placement_constraints = (
|
|
placement_constraints if placement_constraints is not None else []
|
|
)
|
|
|
|
self.requires_compatibilities = requires_compatibilities
|
|
self.proxy_configuration = proxy_configuration
|
|
self.inference_accelerators = inference_accelerators
|
|
self.runtime_platform = runtime_platform
|
|
self.ipc_mode = ipc_mode
|
|
self.pid_mode = pid_mode
|
|
self.ephemeral_storage = ephemeral_storage
|
|
|
|
self.cpu = cpu
|
|
self.memory = memory
|
|
self.status = "ACTIVE"
|
|
|
|
@property
|
|
def response_object(self) -> Dict[str, Any]: # type: ignore[misc]
|
|
response_object = self.gen_response_object()
|
|
response_object["taskDefinitionArn"] = response_object["arn"]
|
|
del response_object["arn"]
|
|
del response_object["tags"]
|
|
|
|
if not response_object["requiresCompatibilities"]:
|
|
del response_object["requiresCompatibilities"]
|
|
if not response_object["cpu"]:
|
|
del response_object["cpu"]
|
|
if not response_object["memory"]:
|
|
del response_object["memory"]
|
|
|
|
return response_object
|
|
|
|
@property
|
|
def physical_resource_id(self) -> str:
|
|
return self.arn
|
|
|
|
@staticmethod
|
|
def cloudformation_name_type() -> str:
|
|
return ""
|
|
|
|
@staticmethod
|
|
def cloudformation_type() -> str:
|
|
# https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ecs-taskdefinition.html
|
|
return "AWS::ECS::TaskDefinition"
|
|
|
|
@classmethod
|
|
def create_from_cloudformation_json( # type: ignore[misc]
|
|
cls,
|
|
resource_name: str,
|
|
cloudformation_json: Any,
|
|
account_id: str,
|
|
region_name: str,
|
|
**kwargs: Any,
|
|
) -> "TaskDefinition":
|
|
properties = cloudformation_json["Properties"]
|
|
|
|
family = properties.get(
|
|
"Family", f"task-definition-{int(mock_random.random() * 10**6)}"
|
|
)
|
|
container_definitions = remap_nested_keys(
|
|
properties.get("ContainerDefinitions", []), pascal_to_camelcase
|
|
)
|
|
volumes = remap_nested_keys(properties.get("Volumes", []), pascal_to_camelcase)
|
|
|
|
ecs_backend = ecs_backends[account_id][region_name]
|
|
return ecs_backend.register_task_definition(
|
|
family=family, container_definitions=container_definitions, volumes=volumes
|
|
)
|
|
|
|
@classmethod
|
|
def update_from_cloudformation_json( # type: ignore[misc]
|
|
cls,
|
|
original_resource: Any,
|
|
new_resource_name: str,
|
|
cloudformation_json: Any,
|
|
account_id: str,
|
|
region_name: str,
|
|
) -> "TaskDefinition":
|
|
properties = cloudformation_json["Properties"]
|
|
family = properties.get(
|
|
"Family", f"task-definition-{int(mock_random.random() * 10**6)}"
|
|
)
|
|
container_definitions = properties["ContainerDefinitions"]
|
|
volumes = properties.get("Volumes")
|
|
if (
|
|
original_resource.family != family
|
|
or original_resource.container_definitions != container_definitions
|
|
or original_resource.volumes != volumes
|
|
):
|
|
# currently TaskRoleArn isn't stored at TaskDefinition
|
|
# instances
|
|
ecs_backend = ecs_backends[account_id][region_name]
|
|
ecs_backend.deregister_task_definition(original_resource.arn)
|
|
return ecs_backend.register_task_definition(
|
|
family=family,
|
|
container_definitions=container_definitions,
|
|
volumes=volumes,
|
|
)
|
|
else:
|
|
# no-op when nothing changed between old and new resources
|
|
return original_resource
|
|
|
|
|
|
class Task(BaseObject):
|
|
def __init__(
|
|
self,
|
|
cluster: Cluster,
|
|
task_definition: TaskDefinition,
|
|
container_instance_arn: Optional[str],
|
|
resource_requirements: Optional[Dict[str, str]],
|
|
backend: "EC2ContainerServiceBackend",
|
|
launch_type: str = "",
|
|
overrides: Optional[Dict[str, Any]] = None,
|
|
started_by: str = "",
|
|
tags: Optional[List[Dict[str, str]]] = None,
|
|
networking_configuration: Optional[Dict[str, Any]] = None,
|
|
):
|
|
self.id = str(mock_random.uuid4())
|
|
self.cluster_name = cluster.name
|
|
self.cluster_arn = cluster.arn
|
|
self.container_instance_arn = container_instance_arn
|
|
self.last_status = "RUNNING"
|
|
self.desired_status = "RUNNING"
|
|
self.task_definition_arn = task_definition.arn
|
|
self.overrides = overrides or {}
|
|
self.containers: List[Dict[str, Any]] = []
|
|
self.started_by = started_by
|
|
self.tags = tags or []
|
|
self.launch_type = launch_type
|
|
self.stopped_reason = ""
|
|
self.resource_requirements = resource_requirements
|
|
self.region_name = cluster.region_name
|
|
self._account_id = backend.account_id
|
|
self._backend = backend
|
|
self.attachments = []
|
|
|
|
if task_definition.network_mode == "awsvpc":
|
|
if not networking_configuration:
|
|
raise InvalidParameterException(
|
|
"Network Configuration must be provided when networkMode 'awsvpc' is specified."
|
|
)
|
|
|
|
self.network_configuration = networking_configuration
|
|
net_conf = networking_configuration["awsvpcConfiguration"]
|
|
ec2_backend = ec2_backends[self._account_id][self.region_name]
|
|
|
|
eni = ec2_backend.create_network_interface(
|
|
subnet=net_conf["subnets"][0],
|
|
private_ip_address=random_private_ip(),
|
|
group_ids=net_conf["securityGroups"],
|
|
description="moto ECS",
|
|
)
|
|
eni.status = "in-use"
|
|
eni.device_index = 0
|
|
|
|
self.attachments.append(
|
|
{
|
|
"id": str(mock_random.uuid4()),
|
|
"type": "ElasticNetworkInterface",
|
|
"status": "ATTACHED",
|
|
"details": [
|
|
{"name": "subnetId", "value": net_conf["subnets"][0]},
|
|
{"name": "networkInterfaceId", "value": eni.id},
|
|
{"name": "macAddress", "value": eni.mac_address},
|
|
{"name": "privateDnsName", "value": eni.private_dns_name},
|
|
{"name": "privateIPv4Address", "value": eni.private_ip_address},
|
|
],
|
|
}
|
|
)
|
|
|
|
@property
|
|
def task_arn(self) -> str:
|
|
if self._backend.enable_long_arn_for_name(name="taskLongArnFormat"):
|
|
return f"arn:aws:ecs:{self.region_name}:{self._account_id}:task/{self.cluster_name}/{self.id}"
|
|
return f"arn:aws:ecs:{self.region_name}:{self._account_id}:task/{self.id}"
|
|
|
|
@property
|
|
def response_object(self) -> Dict[str, Any]: # type: ignore[misc]
|
|
response_object = self.gen_response_object()
|
|
response_object["taskArn"] = self.task_arn
|
|
return response_object
|
|
|
|
|
|
class CapacityProvider(BaseObject):
|
|
def __init__(
|
|
self,
|
|
account_id: str,
|
|
region_name: str,
|
|
name: str,
|
|
asg_details: Dict[str, Any],
|
|
tags: Optional[List[Dict[str, str]]],
|
|
):
|
|
self._id = str(mock_random.uuid4())
|
|
self.capacity_provider_arn = (
|
|
f"arn:aws:ecs:{region_name}:{account_id}:capacity-provider/{name}"
|
|
)
|
|
self.name = name
|
|
self.status = "ACTIVE"
|
|
self.auto_scaling_group_provider = self._prepare_asg_provider(asg_details)
|
|
self.tags = tags
|
|
|
|
self.update_status: Optional[str] = None
|
|
|
|
def _prepare_asg_provider(self, asg_details: Dict[str, Any]) -> Dict[str, Any]:
|
|
if "managedScaling" not in asg_details:
|
|
asg_details["managedScaling"] = {}
|
|
if asg_details["managedScaling"].get("instanceWarmupPeriod") is None:
|
|
asg_details["managedScaling"]["instanceWarmupPeriod"] = 300
|
|
if not asg_details["managedScaling"].get("minimumScalingStepSize"):
|
|
asg_details["managedScaling"]["minimumScalingStepSize"] = 1
|
|
if not asg_details["managedScaling"].get("maximumScalingStepSize"):
|
|
asg_details["managedScaling"]["maximumScalingStepSize"] = 10000
|
|
if not asg_details["managedScaling"].get("targetCapacity"):
|
|
asg_details["managedScaling"]["targetCapacity"] = 100
|
|
if not asg_details["managedScaling"].get("status"):
|
|
asg_details["managedScaling"]["status"] = "DISABLED"
|
|
if "managedTerminationProtection" not in asg_details:
|
|
asg_details["managedTerminationProtection"] = "DISABLED"
|
|
return asg_details
|
|
|
|
def update(self, asg_details: Dict[str, Any]) -> None:
|
|
if "managedTerminationProtection" in asg_details:
|
|
self.auto_scaling_group_provider[
|
|
"managedTerminationProtection"
|
|
] = asg_details["managedTerminationProtection"]
|
|
if "managedScaling" in asg_details:
|
|
scaling_props = [
|
|
"status",
|
|
"targetCapacity",
|
|
"minimumScalingStepSize",
|
|
"maximumScalingStepSize",
|
|
"instanceWarmupPeriod",
|
|
]
|
|
for prop in scaling_props:
|
|
if prop in asg_details["managedScaling"]:
|
|
self.auto_scaling_group_provider["managedScaling"][
|
|
prop
|
|
] = asg_details["managedScaling"][prop]
|
|
self.auto_scaling_group_provider = self._prepare_asg_provider(
|
|
self.auto_scaling_group_provider
|
|
)
|
|
self.update_status = "UPDATE_COMPLETE"
|
|
|
|
|
|
class CapacityProviderFailure(BaseObject):
|
|
def __init__(self, reason: str, name: str, account_id: str, region_name: str):
|
|
self.reason = reason
|
|
self.arn = f"arn:aws:ecs:{region_name}:{account_id}:capacity_provider/{name}"
|
|
|
|
@property
|
|
def response_object(self) -> Dict[str, Any]: # type: ignore[misc]
|
|
response_object = self.gen_response_object()
|
|
response_object["reason"] = self.reason
|
|
response_object["arn"] = self.arn
|
|
return response_object
|
|
|
|
|
|
class Service(BaseObject, CloudFormationModel):
|
|
"""Set the environment variable MOTO_ECS_SERVICE_RUNNING to a number of running tasks you want
|
|
the service to transition to, ie if set to 2:
|
|
|
|
MOTO_ECS_SERVICE_RUNNING=2
|
|
|
|
then describe_services call to return runningCount of the service AND deployment to 2
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
cluster: Cluster,
|
|
service_name: str,
|
|
desired_count: int,
|
|
backend: "EC2ContainerServiceBackend",
|
|
task_definition: Optional[TaskDefinition] = None,
|
|
load_balancers: Optional[List[Dict[str, Any]]] = None,
|
|
scheduling_strategy: Optional[List[Dict[str, Any]]] = None,
|
|
tags: Optional[List[Dict[str, str]]] = None,
|
|
deployment_controller: Optional[Dict[str, str]] = None,
|
|
launch_type: Optional[str] = None,
|
|
service_registries: Optional[List[Dict[str, Any]]] = None,
|
|
platform_version: Optional[str] = None,
|
|
):
|
|
self.cluster_name = cluster.name
|
|
self.cluster_arn = cluster.arn
|
|
self.name = service_name
|
|
self.status = "ACTIVE"
|
|
self.task_definition = task_definition.arn if task_definition else None
|
|
self.desired_count = desired_count
|
|
self.task_sets: List[TaskSet] = []
|
|
self.deployment_controller = deployment_controller or {"type": "ECS"}
|
|
self.events: List[Dict[str, Any]] = []
|
|
self.launch_type = launch_type
|
|
self.service_registries = service_registries or []
|
|
self.load_balancers = load_balancers if load_balancers is not None else []
|
|
self.scheduling_strategy = (
|
|
scheduling_strategy if scheduling_strategy is not None else "REPLICA"
|
|
)
|
|
self.platform_version = platform_version
|
|
self.tags = tags if tags is not None else []
|
|
self.region_name = cluster.region_name
|
|
self._account_id = backend.account_id
|
|
self._backend = backend
|
|
|
|
try:
|
|
# negative running count not allowed, set to 0 if so
|
|
ecs_running_count = max(int(getenv("MOTO_ECS_SERVICE_RUNNING", 0)), 0)
|
|
except ValueError:
|
|
# Unable to parse value of MOTO_ECS_SERVICE_RUNNING as an integer, set to default 0
|
|
ecs_running_count = 0
|
|
|
|
self.running_count = ecs_running_count
|
|
self.pending_count = desired_count - ecs_running_count
|
|
if self.deployment_controller["type"] == "ECS":
|
|
self.deployments = [
|
|
{
|
|
"createdAt": datetime.now(timezone.utc),
|
|
"desiredCount": self.desired_count,
|
|
"id": f"ecs-svc/{mock_random.randint(0, 32**12)}",
|
|
"launchType": self.launch_type,
|
|
"pendingCount": self.pending_count,
|
|
"runningCount": ecs_running_count,
|
|
"status": "PRIMARY",
|
|
"taskDefinition": self.task_definition,
|
|
"updatedAt": datetime.now(timezone.utc),
|
|
}
|
|
]
|
|
else:
|
|
self.deployments = []
|
|
|
|
@property
|
|
def arn(self) -> str:
|
|
if self._backend.enable_long_arn_for_name(name="serviceLongArnFormat"):
|
|
return f"arn:aws:ecs:{self.region_name}:{self._account_id}:service/{self.cluster_name}/{self.name}"
|
|
return f"arn:aws:ecs:{self.region_name}:{self._account_id}:service/{self.name}"
|
|
|
|
@property
|
|
def physical_resource_id(self) -> str:
|
|
return self.arn
|
|
|
|
@property
|
|
def response_object(self) -> Dict[str, Any]: # type: ignore[misc]
|
|
response_object = self.gen_response_object()
|
|
del response_object["name"], response_object["tags"]
|
|
response_object["serviceName"] = self.name
|
|
response_object["serviceArn"] = self.arn
|
|
response_object["schedulingStrategy"] = self.scheduling_strategy
|
|
response_object["platformVersion"] = self.platform_version
|
|
if response_object["deploymentController"]["type"] == "ECS":
|
|
del response_object["deploymentController"]
|
|
del response_object["taskSets"]
|
|
else:
|
|
response_object["taskSets"] = [
|
|
t.response_object for t in response_object["taskSets"]
|
|
]
|
|
|
|
for deployment in response_object["deployments"]:
|
|
if isinstance(deployment["createdAt"], datetime):
|
|
deployment["createdAt"] = unix_time(
|
|
deployment["createdAt"].replace(tzinfo=None)
|
|
)
|
|
if isinstance(deployment["updatedAt"], datetime):
|
|
deployment["updatedAt"] = unix_time(
|
|
deployment["updatedAt"].replace(tzinfo=None)
|
|
)
|
|
|
|
return response_object
|
|
|
|
@staticmethod
|
|
def cloudformation_name_type() -> str:
|
|
return "ServiceName"
|
|
|
|
@staticmethod
|
|
def cloudformation_type() -> str:
|
|
# https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ecs-service.html
|
|
return "AWS::ECS::Service"
|
|
|
|
@classmethod
|
|
def create_from_cloudformation_json( # type: ignore[misc]
|
|
cls,
|
|
resource_name: str,
|
|
cloudformation_json: Any,
|
|
account_id: str,
|
|
region_name: str,
|
|
**kwargs: Any,
|
|
) -> "Service":
|
|
properties = cloudformation_json["Properties"]
|
|
if isinstance(properties["Cluster"], Cluster):
|
|
cluster = properties["Cluster"].name
|
|
else:
|
|
cluster = properties["Cluster"]
|
|
if isinstance(properties["TaskDefinition"], TaskDefinition):
|
|
task_definition = properties["TaskDefinition"].family
|
|
else:
|
|
task_definition = properties["TaskDefinition"]
|
|
desired_count = properties.get("DesiredCount", None)
|
|
# TODO: LoadBalancers
|
|
# TODO: Role
|
|
|
|
ecs_backend = ecs_backends[account_id][region_name]
|
|
return ecs_backend.create_service(
|
|
cluster, resource_name, desired_count, task_definition_str=task_definition
|
|
)
|
|
|
|
@classmethod
|
|
def update_from_cloudformation_json( # type: ignore[misc]
|
|
cls,
|
|
original_resource: Any,
|
|
new_resource_name: str,
|
|
cloudformation_json: Any,
|
|
account_id: str,
|
|
region_name: str,
|
|
) -> "Service":
|
|
properties = cloudformation_json["Properties"]
|
|
if isinstance(properties["Cluster"], Cluster):
|
|
cluster_name = properties["Cluster"].name
|
|
else:
|
|
cluster_name = properties["Cluster"]
|
|
if isinstance(properties["TaskDefinition"], TaskDefinition):
|
|
task_definition = properties["TaskDefinition"].family
|
|
else:
|
|
task_definition = properties["TaskDefinition"]
|
|
desired_count = properties.get("DesiredCount", None)
|
|
|
|
ecs_backend = ecs_backends[account_id][region_name]
|
|
service_name = original_resource.name
|
|
if (
|
|
original_resource.cluster_arn
|
|
!= Cluster(cluster_name, account_id, region_name).arn
|
|
):
|
|
# TODO: LoadBalancers
|
|
# TODO: Role
|
|
ecs_backend.delete_service(cluster_name, service_name)
|
|
return ecs_backend.create_service(
|
|
cluster_name,
|
|
new_resource_name,
|
|
desired_count,
|
|
task_definition_str=task_definition,
|
|
)
|
|
else:
|
|
return ecs_backend.update_service(
|
|
cluster_name, service_name, task_definition, desired_count
|
|
)
|
|
|
|
@classmethod
|
|
def has_cfn_attr(cls, attr: str) -> bool:
|
|
return attr in ["Name"]
|
|
|
|
def get_cfn_attribute(self, attribute_name: str) -> str:
|
|
from moto.cloudformation.exceptions import UnformattedGetAttTemplateException
|
|
|
|
if attribute_name == "Name":
|
|
return self.name
|
|
raise UnformattedGetAttTemplateException()
|
|
|
|
|
|
class ContainerInstance(BaseObject):
|
|
def __init__(
|
|
self,
|
|
ec2_instance_id: str,
|
|
account_id: str,
|
|
region_name: str,
|
|
cluster_name: str,
|
|
backend: "EC2ContainerServiceBackend",
|
|
):
|
|
self.ec2_instance_id = ec2_instance_id
|
|
self.agent_connected = True
|
|
self.status = "ACTIVE"
|
|
self.registered_resources: List[Dict[str, Any]] = [
|
|
{
|
|
"doubleValue": 0.0,
|
|
"integerValue": 4096,
|
|
"longValue": 0,
|
|
"name": "CPU",
|
|
"type": "INTEGER",
|
|
},
|
|
{
|
|
"doubleValue": 0.0,
|
|
"integerValue": 7482,
|
|
"longValue": 0,
|
|
"name": "MEMORY",
|
|
"type": "INTEGER",
|
|
},
|
|
{
|
|
"doubleValue": 0.0,
|
|
"integerValue": 0,
|
|
"longValue": 0,
|
|
"name": "PORTS",
|
|
"stringSetValue": ["22", "2376", "2375", "51678", "51679"],
|
|
"type": "STRINGSET",
|
|
},
|
|
{
|
|
"doubleValue": 0.0,
|
|
"integerValue": 0,
|
|
"longValue": 0,
|
|
"name": "PORTS_UDP",
|
|
"stringSetValue": [],
|
|
"type": "STRINGSET",
|
|
},
|
|
]
|
|
self.pending_tasks_count = 0
|
|
self.remaining_resources: List[Dict[str, Any]] = [
|
|
{
|
|
"doubleValue": 0.0,
|
|
"integerValue": 4096,
|
|
"longValue": 0,
|
|
"name": "CPU",
|
|
"type": "INTEGER",
|
|
},
|
|
{
|
|
"doubleValue": 0.0,
|
|
"integerValue": 7482,
|
|
"longValue": 0,
|
|
"name": "MEMORY",
|
|
"type": "INTEGER",
|
|
},
|
|
{
|
|
"doubleValue": 0.0,
|
|
"integerValue": 0,
|
|
"longValue": 0,
|
|
"name": "PORTS",
|
|
"stringSetValue": ["22", "2376", "2375", "51678", "51679"],
|
|
"type": "STRINGSET",
|
|
},
|
|
{
|
|
"doubleValue": 0.0,
|
|
"integerValue": 0,
|
|
"longValue": 0,
|
|
"name": "PORTS_UDP",
|
|
"stringSetValue": [],
|
|
"type": "STRINGSET",
|
|
},
|
|
]
|
|
self.running_tasks_count = 0
|
|
self.version_info = {
|
|
"agentVersion": "1.0.0",
|
|
"agentHash": "4023248",
|
|
"dockerVersion": "DockerVersion: 1.5.0",
|
|
}
|
|
ec2_backend = ec2_backends[account_id][region_name]
|
|
ec2_instance = ec2_backend.get_instance(ec2_instance_id)
|
|
self.attributes = {
|
|
"ecs.ami-id": ec2_instance.image_id,
|
|
"ecs.availability-zone": ec2_instance.placement,
|
|
"ecs.instance-type": ec2_instance.instance_type,
|
|
"ecs.os-type": ec2_instance.platform
|
|
if ec2_instance.platform == "windows"
|
|
else "linux", # options are windows and linux, linux is default
|
|
}
|
|
self.registered_at = datetime.now(timezone.utc)
|
|
self.region_name = region_name
|
|
self.id = str(mock_random.uuid4())
|
|
self.cluster_name = cluster_name
|
|
self._account_id = backend.account_id
|
|
self._backend = backend
|
|
|
|
@property
|
|
def container_instance_arn(self) -> str:
|
|
if self._backend.enable_long_arn_for_name(
|
|
name="containerInstanceLongArnFormat"
|
|
):
|
|
return f"arn:aws:ecs:{self.region_name}:{self._account_id}:container-instance/{self.cluster_name}/{self.id}"
|
|
return f"arn:aws:ecs:{self.region_name}:{self._account_id}:container-instance/{self.id}"
|
|
|
|
@property
|
|
def response_object(self) -> Dict[str, Any]: # type: ignore[misc]
|
|
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()
|
|
]
|
|
if isinstance(response_object["registeredAt"], datetime):
|
|
response_object["registeredAt"] = unix_time(
|
|
response_object["registeredAt"].replace(tzinfo=None)
|
|
)
|
|
return response_object
|
|
|
|
def _format_attribute(self, name: str, value: Optional[str]) -> Dict[str, str]:
|
|
formatted_attr = {"name": name}
|
|
if value is not None:
|
|
formatted_attr["value"] = value
|
|
return formatted_attr
|
|
|
|
|
|
class ClusterFailure(BaseObject):
|
|
def __init__(
|
|
self, reason: str, cluster_name: str, account_id: str, region_name: str
|
|
):
|
|
self.reason = reason
|
|
self.arn = f"arn:aws:ecs:{region_name}:{account_id}:cluster/{cluster_name}"
|
|
|
|
@property
|
|
def response_object(self) -> Dict[str, Any]: # type: ignore[misc]
|
|
response_object = self.gen_response_object()
|
|
response_object["reason"] = self.reason
|
|
response_object["arn"] = self.arn
|
|
return response_object
|
|
|
|
|
|
class ContainerInstanceFailure(BaseObject):
|
|
def __init__(
|
|
self, reason: str, container_instance_id: str, account_id: str, region_name: str
|
|
):
|
|
self.reason = reason
|
|
self.arn = f"arn:aws:ecs:{region_name}:{account_id}:container-instance/{container_instance_id}"
|
|
|
|
@property
|
|
def response_object(self) -> Dict[str, Any]: # type: ignore[misc]
|
|
response_object = self.gen_response_object()
|
|
response_object["reason"] = self.reason
|
|
response_object["arn"] = self.arn
|
|
return response_object
|
|
|
|
|
|
class TaskSet(BaseObject):
|
|
def __init__(
|
|
self,
|
|
service: str,
|
|
cluster: str,
|
|
task_definition: str,
|
|
account_id: str,
|
|
region_name: str,
|
|
external_id: Optional[str] = None,
|
|
network_configuration: Optional[Dict[str, Any]] = None,
|
|
load_balancers: Optional[List[Dict[str, Any]]] = None,
|
|
service_registries: Optional[List[Dict[str, Any]]] = None,
|
|
launch_type: Optional[str] = None,
|
|
capacity_provider_strategy: Optional[List[Dict[str, Any]]] = None,
|
|
platform_version: Optional[str] = None,
|
|
scale: Optional[Dict[str, Any]] = None,
|
|
client_token: Optional[str] = None,
|
|
tags: Optional[List[Dict[str, str]]] = None,
|
|
):
|
|
self.service = service
|
|
self.cluster = cluster
|
|
self.status = "ACTIVE"
|
|
self.task_definition = task_definition or ""
|
|
self.region_name = region_name
|
|
self.external_id = external_id or ""
|
|
self.network_configuration = network_configuration or None
|
|
self.load_balancers = load_balancers or []
|
|
self.service_registries = service_registries or []
|
|
self.launch_type = launch_type
|
|
self.capacity_provider_strategy = capacity_provider_strategy or []
|
|
self.platform_version = platform_version or "LATEST"
|
|
self.scale = scale or {"value": 100.0, "unit": "PERCENT"}
|
|
self.client_token = client_token or ""
|
|
self.tags = tags or []
|
|
self.stabilityStatus = "STEADY_STATE"
|
|
self.createdAt = datetime.now(timezone.utc)
|
|
self.updatedAt = datetime.now(timezone.utc)
|
|
self.stabilityStatusAt = datetime.now(timezone.utc)
|
|
self.id = f"ecs-svc/{mock_random.randint(0, 32**12)}"
|
|
self.service_arn = ""
|
|
self.cluster_arn = ""
|
|
|
|
cluster_name = self.cluster.split("/")[-1]
|
|
service_name = self.service.split("/")[-1]
|
|
self.task_set_arn = f"arn:aws:ecs:{region_name}:{account_id}:task-set/{cluster_name}/{service_name}/{self.id}"
|
|
|
|
@property
|
|
def response_object(self) -> Dict[str, Any]: # type: ignore[misc]
|
|
response_object = self.gen_response_object()
|
|
if isinstance(response_object["createdAt"], datetime):
|
|
response_object["createdAt"] = unix_time(
|
|
self.createdAt.replace(tzinfo=None)
|
|
)
|
|
if isinstance(response_object["updatedAt"], datetime):
|
|
response_object["updatedAt"] = unix_time(
|
|
self.updatedAt.replace(tzinfo=None)
|
|
)
|
|
if isinstance(response_object["stabilityStatusAt"], datetime):
|
|
response_object["stabilityStatusAt"] = unix_time(
|
|
self.stabilityStatusAt.replace(tzinfo=None)
|
|
)
|
|
del response_object["service"]
|
|
del response_object["cluster"]
|
|
return response_object
|
|
|
|
|
|
class EC2ContainerServiceBackend(BaseBackend):
|
|
"""
|
|
ECS resources use the new ARN format by default.
|
|
Use the following environment variable to revert back to the old/short ARN format:
|
|
`MOTO_ECS_NEW_ARN=false`
|
|
|
|
AWS reference: https://aws.amazon.com/blogs/compute/migrating-your-amazon-ecs-deployment-to-the-new-arn-and-resource-id-format-2/
|
|
"""
|
|
|
|
def __init__(self, region_name: str, account_id: str):
|
|
super().__init__(region_name, account_id)
|
|
self.account_settings: Dict[str, AccountSetting] = dict()
|
|
self.capacity_providers: Dict[str, CapacityProvider] = dict()
|
|
self.clusters: Dict[str, Cluster] = {}
|
|
self.task_definitions: Dict[str, Dict[int, TaskDefinition]] = {}
|
|
self.tasks: Dict[str, Dict[str, Task]] = {}
|
|
self.services: Dict[str, Service] = {}
|
|
self.container_instances: Dict[str, Dict[str, ContainerInstance]] = {}
|
|
|
|
@staticmethod
|
|
def default_vpc_endpoint_service(service_region: str, zones: List[str]) -> List[Dict[str, Any]]: # type: ignore[misc]
|
|
"""Default VPC endpoint service."""
|
|
return BaseBackend.default_vpc_endpoint_service_factory(
|
|
service_region, zones, "ecs"
|
|
)
|
|
|
|
def _get_cluster(self, name: str) -> Cluster:
|
|
# short name or full ARN of the cluster
|
|
cluster_name = name.split("/")[-1]
|
|
|
|
cluster = self.clusters.get(cluster_name)
|
|
if not cluster:
|
|
raise ClusterNotFoundException
|
|
|
|
return cluster
|
|
|
|
def create_capacity_provider(
|
|
self,
|
|
name: str,
|
|
asg_details: Dict[str, Any],
|
|
tags: Optional[List[Dict[str, str]]],
|
|
) -> CapacityProvider:
|
|
capacity_provider = CapacityProvider(
|
|
self.account_id, self.region_name, name, asg_details, tags
|
|
)
|
|
self.capacity_providers[name] = capacity_provider
|
|
return capacity_provider
|
|
|
|
def describe_task_definition(self, task_definition_str: str) -> TaskDefinition:
|
|
task_definition_name = task_definition_str.split("/")[-1]
|
|
if ":" in task_definition_name:
|
|
family, rev = task_definition_name.split(":")
|
|
revision = int(rev)
|
|
else:
|
|
family = task_definition_name
|
|
revision = self._get_last_task_definition_revision_id(family)
|
|
|
|
if (
|
|
family in self.task_definitions
|
|
and revision in self.task_definitions[family]
|
|
):
|
|
return self.task_definitions[family][revision]
|
|
else:
|
|
raise TaskDefinitionNotFoundException()
|
|
|
|
def create_cluster(
|
|
self,
|
|
cluster_name: str,
|
|
tags: Any = None,
|
|
cluster_settings: Any = None,
|
|
configuration: Optional[Dict[str, Any]] = None,
|
|
capacity_providers: Optional[List[str]] = None,
|
|
default_capacity_provider_strategy: Optional[List[Dict[str, Any]]] = None,
|
|
service_connect_defaults: Optional[Dict[str, str]] = None,
|
|
) -> Cluster:
|
|
cluster = Cluster(
|
|
cluster_name,
|
|
self.account_id,
|
|
self.region_name,
|
|
cluster_settings,
|
|
configuration,
|
|
capacity_providers,
|
|
default_capacity_provider_strategy,
|
|
tags,
|
|
service_connect_defaults=service_connect_defaults,
|
|
)
|
|
self.clusters[cluster_name] = cluster
|
|
return cluster
|
|
|
|
def update_cluster(
|
|
self,
|
|
cluster_name: str,
|
|
cluster_settings: Optional[List[Dict[str, str]]],
|
|
configuration: Optional[Dict[str, Any]],
|
|
service_connect_defaults: Optional[Dict[str, str]],
|
|
) -> Cluster:
|
|
"""
|
|
The serviceConnectDefaults-parameter is not yet implemented
|
|
"""
|
|
cluster = self._get_cluster(cluster_name)
|
|
if cluster_settings is not None:
|
|
cluster.settings = cluster_settings
|
|
if configuration is not None:
|
|
cluster.configuration = configuration
|
|
if service_connect_defaults is not None:
|
|
cluster.service_connect_defaults = service_connect_defaults
|
|
return cluster
|
|
|
|
def put_cluster_capacity_providers(
|
|
self,
|
|
cluster_name: str,
|
|
capacity_providers: Optional[List[str]],
|
|
default_capacity_provider_strategy: Optional[List[Dict[str, Any]]],
|
|
) -> Cluster:
|
|
cluster = self._get_cluster(cluster_name)
|
|
if capacity_providers is not None:
|
|
cluster.capacity_providers = capacity_providers
|
|
if default_capacity_provider_strategy is not None:
|
|
cluster.default_capacity_provider_strategy = (
|
|
default_capacity_provider_strategy
|
|
)
|
|
return cluster
|
|
|
|
def _get_provider(self, name_or_arn: str) -> Optional[CapacityProvider]:
|
|
for provider in self.capacity_providers.values():
|
|
if (
|
|
provider.name == name_or_arn
|
|
or provider.capacity_provider_arn == name_or_arn
|
|
):
|
|
return provider
|
|
return None
|
|
|
|
def describe_capacity_providers(
|
|
self, names: List[str]
|
|
) -> Tuple[List[CapacityProvider], List[CapacityProviderFailure]]:
|
|
providers = []
|
|
failures = []
|
|
for name in names:
|
|
provider = self._get_provider(name)
|
|
if provider:
|
|
providers.append(provider)
|
|
else:
|
|
failures.append(
|
|
CapacityProviderFailure(
|
|
"MISSING", name, self.account_id, self.region_name
|
|
)
|
|
)
|
|
return providers, failures
|
|
|
|
def delete_capacity_provider(self, name_or_arn: str) -> CapacityProvider:
|
|
provider: CapacityProvider = self._get_provider(name_or_arn) # type: ignore[assignment]
|
|
self.capacity_providers.pop(provider.name)
|
|
return provider
|
|
|
|
def update_capacity_provider(
|
|
self, name_or_arn: str, asg_provider: Dict[str, Any]
|
|
) -> CapacityProvider:
|
|
provider: CapacityProvider = self._get_provider(name_or_arn) # type: ignore[assignment]
|
|
provider.update(asg_provider)
|
|
return provider
|
|
|
|
def list_clusters(self) -> List[str]:
|
|
"""
|
|
maxSize and pagination not implemented
|
|
"""
|
|
return [cluster.arn for cluster in self.clusters.values()]
|
|
|
|
def describe_clusters(
|
|
self,
|
|
list_clusters_name: Optional[List[str]] = None,
|
|
include: Optional[List[str]] = None,
|
|
) -> Tuple[List[Dict[str, Any]], List[ClusterFailure]]:
|
|
"""
|
|
Only include=TAGS is currently supported.
|
|
"""
|
|
list_clusters = []
|
|
failures = []
|
|
if list_clusters_name is None:
|
|
if "default" in self.clusters:
|
|
list_clusters.append(self.clusters["default"].response_object)
|
|
else:
|
|
for cluster_name in list_clusters_name:
|
|
cluster_name = cluster_name.split("/")[-1]
|
|
if cluster_name in self.clusters:
|
|
list_clusters.append(self.clusters[cluster_name].response_object)
|
|
else:
|
|
failures.append(
|
|
ClusterFailure(
|
|
"MISSING", cluster_name, self.account_id, self.region_name
|
|
)
|
|
)
|
|
|
|
if not include or "TAGS" not in (include):
|
|
for cluster in list_clusters:
|
|
cluster["tags"] = None
|
|
|
|
return list_clusters, failures
|
|
|
|
def delete_cluster(self, cluster_str: str) -> Cluster:
|
|
cluster = self._get_cluster(cluster_str)
|
|
|
|
# A cluster is not immediately removed - just marked as inactive
|
|
# It is only deleted later on
|
|
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ecs.html#ECS.Client.delete_cluster
|
|
cluster.status = "INACTIVE"
|
|
|
|
return cluster
|
|
|
|
def register_task_definition(
|
|
self,
|
|
family: str,
|
|
container_definitions: List[Dict[str, Any]],
|
|
volumes: Optional[List[Dict[str, Any]]] = None,
|
|
network_mode: Optional[str] = None,
|
|
tags: Optional[List[Dict[str, str]]] = None,
|
|
placement_constraints: Optional[List[Dict[str, str]]] = None,
|
|
requires_compatibilities: Optional[List[str]] = None,
|
|
cpu: Optional[str] = None,
|
|
memory: Optional[str] = None,
|
|
task_role_arn: Optional[str] = None,
|
|
execution_role_arn: Optional[str] = None,
|
|
proxy_configuration: Optional[Dict[str, Any]] = None,
|
|
inference_accelerators: Optional[List[Dict[str, str]]] = None,
|
|
runtime_platform: Optional[Dict[str, str]] = None,
|
|
ipc_mode: Optional[str] = None,
|
|
pid_mode: Optional[str] = None,
|
|
ephemeral_storage: Optional[Dict[str, int]] = None,
|
|
) -> TaskDefinition:
|
|
if family in self.task_definitions:
|
|
last_id = self._get_last_task_definition_revision_id(family)
|
|
revision = (last_id or 0) + 1
|
|
else:
|
|
self.task_definitions[family] = {}
|
|
revision = 1
|
|
task_definition = TaskDefinition(
|
|
family,
|
|
revision,
|
|
container_definitions,
|
|
self.account_id,
|
|
self.region_name,
|
|
volumes=volumes,
|
|
network_mode=network_mode,
|
|
tags=tags,
|
|
placement_constraints=placement_constraints,
|
|
requires_compatibilities=requires_compatibilities,
|
|
cpu=cpu,
|
|
memory=memory,
|
|
task_role_arn=task_role_arn,
|
|
execution_role_arn=execution_role_arn,
|
|
proxy_configuration=proxy_configuration,
|
|
inference_accelerators=inference_accelerators,
|
|
runtime_platform=runtime_platform,
|
|
ipc_mode=ipc_mode,
|
|
pid_mode=pid_mode,
|
|
ephemeral_storage=ephemeral_storage,
|
|
)
|
|
self.task_definitions[family][revision] = task_definition
|
|
|
|
return task_definition
|
|
|
|
def list_task_definitions(self, family_prefix: str) -> List[str]:
|
|
task_arns = []
|
|
for task_definition_list in self.task_definitions.values():
|
|
task_arns.extend(
|
|
[
|
|
task_definition.arn
|
|
for task_definition in task_definition_list.values()
|
|
if family_prefix is None or task_definition.family == family_prefix
|
|
]
|
|
)
|
|
return task_arns
|
|
|
|
def deregister_task_definition(self, task_definition_str: str) -> TaskDefinition:
|
|
task_definition_name = task_definition_str.split("/")[-1]
|
|
try:
|
|
family, rev = task_definition_name.split(":")
|
|
except ValueError:
|
|
raise RevisionNotFoundException
|
|
try:
|
|
revision = int(rev)
|
|
except ValueError:
|
|
raise InvalidParameterException("Invalid revision number. Number: " + rev)
|
|
if (
|
|
family in self.task_definitions
|
|
and revision in self.task_definitions[family]
|
|
):
|
|
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ecs.html#ECS.Client.deregister_task_definition
|
|
# At this time, INACTIVE task definitions remain discoverable in your account indefinitely.
|
|
task_definition = self.task_definitions[family][revision]
|
|
task_definition.status = "INACTIVE"
|
|
return task_definition
|
|
else:
|
|
raise TaskDefinitionNotFoundException
|
|
|
|
def run_task(
|
|
self,
|
|
cluster_str: str,
|
|
task_definition_str: str,
|
|
count: int,
|
|
overrides: Optional[Dict[str, Any]],
|
|
started_by: str,
|
|
tags: Optional[List[Dict[str, str]]],
|
|
launch_type: Optional[str],
|
|
networking_configuration: Optional[Dict[str, Any]] = None,
|
|
) -> List[Task]:
|
|
if launch_type and launch_type not in ["EC2", "FARGATE", "EXTERNAL"]:
|
|
raise InvalidParameterException(
|
|
"launch type should be one of [EC2,FARGATE,EXTERNAL]"
|
|
)
|
|
|
|
cluster = self._get_cluster(cluster_str)
|
|
|
|
task_definition = self.describe_task_definition(task_definition_str)
|
|
resource_requirements = self._calculate_task_resource_requirements(
|
|
task_definition
|
|
)
|
|
if cluster.name not in self.tasks:
|
|
self.tasks[cluster.name] = {}
|
|
tasks = []
|
|
if launch_type == "FARGATE":
|
|
for _ in range(count):
|
|
task = Task(
|
|
cluster=cluster,
|
|
task_definition=task_definition,
|
|
container_instance_arn=None,
|
|
resource_requirements=resource_requirements,
|
|
backend=self,
|
|
overrides=overrides or {},
|
|
started_by=started_by or "",
|
|
tags=tags or [],
|
|
launch_type=launch_type or "",
|
|
networking_configuration=networking_configuration,
|
|
)
|
|
tasks.append(task)
|
|
self.tasks[cluster.name][task.task_arn] = task
|
|
return tasks
|
|
|
|
container_instances = list(
|
|
self.container_instances.get(cluster.name, {}).keys()
|
|
)
|
|
if not container_instances:
|
|
raise Exception(f"No instances found in cluster {cluster.name}")
|
|
active_container_instances = [
|
|
x
|
|
for x in container_instances
|
|
if self.container_instances[cluster.name][x].status == "ACTIVE"
|
|
]
|
|
# TODO: return event about unable to place task if not able to place enough tasks to meet count
|
|
placed_count = 0
|
|
for name in active_container_instances:
|
|
container_instance = self.container_instances[cluster.name][name]
|
|
container_instance_arn = container_instance.container_instance_arn
|
|
try_to_place = True
|
|
while try_to_place:
|
|
can_be_placed = self._can_be_placed(
|
|
container_instance, resource_requirements
|
|
)
|
|
if can_be_placed:
|
|
task = Task(
|
|
cluster,
|
|
task_definition,
|
|
container_instance_arn,
|
|
resource_requirements,
|
|
backend=self,
|
|
overrides=overrides or {},
|
|
started_by=started_by or "",
|
|
tags=tags or [],
|
|
launch_type=launch_type or "",
|
|
networking_configuration=networking_configuration,
|
|
)
|
|
self.update_container_instance_resources(
|
|
container_instance, resource_requirements
|
|
)
|
|
tasks.append(task)
|
|
self.tasks[cluster.name][task.task_arn] = task
|
|
placed_count += 1
|
|
if placed_count == count:
|
|
return tasks
|
|
else:
|
|
try_to_place = False
|
|
return tasks
|
|
|
|
@staticmethod
|
|
def _calculate_task_resource_requirements(task_definition: TaskDefinition) -> Dict[str, Any]: # type: ignore[misc]
|
|
resource_requirements: Dict[str, Any] = {
|
|
"CPU": 0,
|
|
"MEMORY": 0,
|
|
"PORTS": [],
|
|
"PORTS_UDP": [],
|
|
}
|
|
for container_definition in task_definition.container_definitions:
|
|
# cloudformation uses capitalized properties, while boto uses all lower case
|
|
|
|
# CPU is optional
|
|
resource_requirements["CPU"] += container_definition.get(
|
|
"cpu", container_definition.get("Cpu", 0)
|
|
)
|
|
|
|
# either memory or memory reservation must be provided
|
|
if (
|
|
"Memory" in container_definition
|
|
or "MemoryReservation" in container_definition
|
|
):
|
|
resource_requirements["MEMORY"] += container_definition.get(
|
|
"Memory", container_definition.get("MemoryReservation")
|
|
)
|
|
else:
|
|
resource_requirements["MEMORY"] += container_definition.get(
|
|
"memory", container_definition.get("memoryReservation")
|
|
)
|
|
|
|
port_mapping_key = (
|
|
"PortMappings"
|
|
if "PortMappings" in container_definition
|
|
else "portMappings"
|
|
)
|
|
for port_mapping in container_definition.get(port_mapping_key, []): # type: ignore[attr-defined]
|
|
if "hostPort" in port_mapping:
|
|
resource_requirements["PORTS"].append(port_mapping.get("hostPort"))
|
|
elif "HostPort" in port_mapping:
|
|
resource_requirements["PORTS"].append(port_mapping.get("HostPort"))
|
|
|
|
return resource_requirements
|
|
|
|
@staticmethod
|
|
def _can_be_placed(container_instance: ContainerInstance, task_resource_requirements: Dict[str, Any]) -> bool: # type: ignore[misc]
|
|
"""
|
|
|
|
:param container_instance: The container instance trying to be placed onto
|
|
:param task_resource_requirements: The calculated resource requirements of the task in the form of a dict
|
|
:return: A boolean stating whether the given container instance has enough resources to have the task placed on
|
|
it as well as a description, if it cannot be placed this will describe why.
|
|
"""
|
|
# TODO: Implement default and other placement strategies as well as constraints:
|
|
# docs.aws.amazon.com/AmazonECS/latest/developerguide/task-placement.html
|
|
remaining_cpu = 0
|
|
remaining_memory = 0
|
|
reserved_ports: List[str] = []
|
|
for resource in container_instance.remaining_resources:
|
|
if resource.get("name") == "CPU":
|
|
remaining_cpu = resource.get("integerValue") # type: ignore[assignment]
|
|
elif resource.get("name") == "MEMORY":
|
|
remaining_memory = resource.get("integerValue") # type: ignore[assignment]
|
|
elif resource.get("name") == "PORTS":
|
|
reserved_ports = resource.get("stringSetValue") # type: ignore[assignment]
|
|
if task_resource_requirements.get("CPU") > remaining_cpu: # type: ignore[operator]
|
|
return False
|
|
if task_resource_requirements.get("MEMORY") > remaining_memory: # type: ignore[operator]
|
|
return False
|
|
ports_needed = task_resource_requirements.get("PORTS")
|
|
for port in ports_needed: # type: ignore[union-attr]
|
|
if str(port) in reserved_ports:
|
|
return False
|
|
return True
|
|
|
|
def start_task(
|
|
self,
|
|
cluster_str: str,
|
|
task_definition_str: str,
|
|
container_instances: List[str],
|
|
overrides: Dict[str, Any],
|
|
started_by: str,
|
|
tags: Optional[List[Dict[str, str]]] = None,
|
|
) -> List[Task]:
|
|
cluster = self._get_cluster(cluster_str)
|
|
|
|
task_definition = self.describe_task_definition(task_definition_str)
|
|
if cluster.name not in self.tasks:
|
|
self.tasks[cluster.name] = {}
|
|
tasks = []
|
|
if not container_instances:
|
|
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(
|
|
task_definition
|
|
)
|
|
for container_instance_id in container_instance_ids:
|
|
container_instance = self.container_instances[cluster.name][
|
|
container_instance_id
|
|
]
|
|
task = Task(
|
|
cluster,
|
|
task_definition,
|
|
container_instance.container_instance_arn,
|
|
resource_requirements,
|
|
backend=self,
|
|
overrides=overrides or {},
|
|
started_by=started_by or "",
|
|
tags=tags,
|
|
)
|
|
tasks.append(task)
|
|
self.update_container_instance_resources(
|
|
container_instance, resource_requirements
|
|
)
|
|
self.tasks[cluster.name][task.task_arn] = task
|
|
return tasks
|
|
|
|
def describe_tasks(
|
|
self,
|
|
cluster_str: str,
|
|
tasks: Optional[str],
|
|
include: Optional[List[str]] = None,
|
|
) -> List[Task]:
|
|
"""
|
|
Only include=TAGS is currently supported.
|
|
"""
|
|
self._get_cluster(cluster_str)
|
|
|
|
if not tasks:
|
|
raise InvalidParameterException("Tasks cannot be empty.")
|
|
response = []
|
|
for cluster_tasks in self.tasks.values():
|
|
for task_arn, task in cluster_tasks.items():
|
|
task_id = task_arn.split("/")[-1]
|
|
if (
|
|
task_arn in tasks
|
|
or task.task_arn in tasks
|
|
or any(task_id in task for task in tasks)
|
|
):
|
|
response.append(task)
|
|
if "TAGS" in (include or []):
|
|
return response
|
|
|
|
for task in response:
|
|
task.tags = []
|
|
return response
|
|
|
|
def list_tasks(
|
|
self,
|
|
cluster_str: str,
|
|
container_instance: Optional[str],
|
|
family: str,
|
|
started_by: str,
|
|
service_name: str,
|
|
desiredStatus: str,
|
|
) -> List[str]:
|
|
filtered_tasks = []
|
|
for tasks in self.tasks.values():
|
|
for task in tasks.values():
|
|
filtered_tasks.append(task)
|
|
if cluster_str:
|
|
cluster = self._get_cluster(cluster_str)
|
|
|
|
filtered_tasks = list(
|
|
filter(lambda t: cluster.name in t.cluster_arn, filtered_tasks)
|
|
)
|
|
|
|
if container_instance:
|
|
filtered_tasks = list(
|
|
filter(
|
|
lambda t: container_instance in t.container_instance_arn, # type: ignore
|
|
filtered_tasks,
|
|
)
|
|
)
|
|
|
|
if family:
|
|
task_definition_arns = self.list_task_definitions(family)
|
|
filtered_tasks = list(
|
|
filter(
|
|
lambda t: t.task_definition_arn in task_definition_arns,
|
|
filtered_tasks,
|
|
)
|
|
)
|
|
|
|
if started_by:
|
|
filtered_tasks = list(
|
|
filter(lambda t: started_by == t.started_by, filtered_tasks)
|
|
)
|
|
|
|
if service_name:
|
|
# TODO: We can't filter on `service_name` until the backend actually
|
|
# launches tasks as part of the service creation process.
|
|
pass
|
|
|
|
if desiredStatus:
|
|
filtered_tasks = list(
|
|
filter(lambda t: t.desired_status == desiredStatus, filtered_tasks)
|
|
)
|
|
|
|
return [t.task_arn for t in filtered_tasks]
|
|
|
|
def stop_task(self, cluster_str: str, task_str: str, reason: str) -> Task:
|
|
cluster = self._get_cluster(cluster_str)
|
|
|
|
task_id = task_str.split("/")[-1]
|
|
tasks = self.tasks.get(cluster.name, None)
|
|
if not tasks:
|
|
raise Exception(f"Cluster {cluster.name} has no registered tasks")
|
|
for task in tasks.keys():
|
|
if task.endswith(task_id):
|
|
container_instance_arn = tasks[task].container_instance_arn
|
|
if container_instance_arn:
|
|
container_instance = self.container_instances[cluster.name][
|
|
container_instance_arn.split("/")[-1]
|
|
]
|
|
self.update_container_instance_resources(
|
|
container_instance,
|
|
tasks[task].resource_requirements, # type: ignore[arg-type]
|
|
removing=True,
|
|
)
|
|
tasks[task].last_status = "STOPPED"
|
|
tasks[task].desired_status = "STOPPED"
|
|
tasks[task].stopped_reason = reason
|
|
return tasks[task]
|
|
raise Exception(f"Could not find task {task_str} on cluster {cluster.name}")
|
|
|
|
def _get_service(self, cluster_str: str, service_str: str) -> Service:
|
|
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: str,
|
|
service_name: str,
|
|
desired_count: int,
|
|
task_definition_str: Optional[str] = None,
|
|
load_balancers: Optional[List[Dict[str, Any]]] = None,
|
|
scheduling_strategy: Optional[List[Dict[str, Any]]] = None,
|
|
tags: Optional[List[Dict[str, str]]] = None,
|
|
deployment_controller: Optional[Dict[str, str]] = None,
|
|
launch_type: Optional[str] = None,
|
|
service_registries: Optional[List[Dict[str, Any]]] = None,
|
|
platform_version: Optional[str] = None,
|
|
) -> Service:
|
|
cluster = self._get_cluster(cluster_str)
|
|
|
|
if task_definition_str:
|
|
task_definition = self.describe_task_definition(task_definition_str)
|
|
else:
|
|
task_definition = None
|
|
desired_count = desired_count if desired_count is not None else 0
|
|
|
|
launch_type = launch_type if launch_type is not None else "EC2"
|
|
if launch_type not in ["EC2", "FARGATE"]:
|
|
raise EcsClientException("launch type should be one of [EC2,FARGATE]")
|
|
|
|
service = Service(
|
|
cluster=cluster,
|
|
service_name=service_name,
|
|
desired_count=desired_count,
|
|
task_definition=task_definition,
|
|
load_balancers=load_balancers,
|
|
scheduling_strategy=scheduling_strategy,
|
|
tags=tags,
|
|
deployment_controller=deployment_controller,
|
|
launch_type=launch_type,
|
|
backend=self,
|
|
service_registries=service_registries,
|
|
platform_version=platform_version,
|
|
)
|
|
cluster_service_pair = f"{cluster.name}:{service_name}"
|
|
self.services[cluster_service_pair] = service
|
|
|
|
return service
|
|
|
|
def list_services(
|
|
self,
|
|
cluster_str: str,
|
|
scheduling_strategy: Optional[str] = None,
|
|
launch_type: Optional[str] = None,
|
|
) -> List[str]:
|
|
cluster = self._get_cluster(cluster_str)
|
|
service_arns = []
|
|
for key, service in self.services.items():
|
|
if cluster.name + ":" not in key:
|
|
continue
|
|
|
|
if (
|
|
scheduling_strategy is not None
|
|
and service.scheduling_strategy != scheduling_strategy
|
|
):
|
|
continue
|
|
|
|
if launch_type is not None and service.launch_type != launch_type:
|
|
continue
|
|
|
|
service_arns.append(service.arn)
|
|
|
|
return sorted(service_arns)
|
|
|
|
def describe_services(
|
|
self, cluster_str: str, service_names_or_arns: List[str]
|
|
) -> Tuple[List[Service], List[Dict[str, str]]]:
|
|
cluster = self._get_cluster(cluster_str)
|
|
|
|
result = []
|
|
failures = []
|
|
for name_or_arn in service_names_or_arns:
|
|
name = name_or_arn.split("/")[-1]
|
|
cluster_service_pair = f"{cluster.name}:{name}"
|
|
if cluster_service_pair in self.services:
|
|
result.append(self.services[cluster_service_pair])
|
|
else:
|
|
if name_or_arn.startswith("arn:aws:ecs"):
|
|
missing_arn = name_or_arn
|
|
else:
|
|
missing_arn = f"arn:aws:ecs:{self.region_name}:{self.account_id}:service/{name}"
|
|
failures.append({"arn": missing_arn, "reason": "MISSING"})
|
|
|
|
return result, failures
|
|
|
|
def update_service(
|
|
self,
|
|
cluster_str: str,
|
|
service_str: str,
|
|
task_definition_str: str,
|
|
desired_count: Optional[int],
|
|
) -> Service:
|
|
cluster = self._get_cluster(cluster_str)
|
|
|
|
service_name = service_str.split("/")[-1]
|
|
cluster_service_pair = f"{cluster.name}:{service_name}"
|
|
if cluster_service_pair in self.services:
|
|
if task_definition_str is not None:
|
|
self.describe_task_definition(task_definition_str)
|
|
self.services[
|
|
cluster_service_pair
|
|
].task_definition = task_definition_str
|
|
if desired_count is not None:
|
|
self.services[cluster_service_pair].desired_count = desired_count
|
|
return self.services[cluster_service_pair]
|
|
else:
|
|
raise ServiceNotFoundException
|
|
|
|
def delete_service(
|
|
self, cluster_name: str, service_name: str, force: bool
|
|
) -> Service:
|
|
cluster = self._get_cluster(cluster_name)
|
|
service = self._get_service(cluster_name, service_name)
|
|
|
|
cluster_service_pair = f"{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:
|
|
# A service is not immediately removed - just marked as inactive
|
|
# It is only deleted later on
|
|
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ecs.html#ECS.Client.delete_service
|
|
service.status = "INACTIVE"
|
|
service.pending_count = 0
|
|
return service
|
|
|
|
def register_container_instance(
|
|
self, cluster_str: str, ec2_instance_id: str
|
|
) -> ContainerInstance:
|
|
cluster_name = cluster_str.split("/")[-1]
|
|
if cluster_name not in self.clusters:
|
|
raise Exception(f"{cluster_name} is not a cluster")
|
|
container_instance = ContainerInstance(
|
|
ec2_instance_id,
|
|
self.account_id,
|
|
self.region_name,
|
|
cluster_name,
|
|
backend=self,
|
|
)
|
|
if not self.container_instances.get(cluster_name):
|
|
self.container_instances[cluster_name] = {}
|
|
container_instance_id = container_instance.container_instance_arn.split("/")[-1]
|
|
self.container_instances[cluster_name][
|
|
container_instance_id
|
|
] = container_instance
|
|
self.clusters[cluster_name].registered_container_instances_count += 1
|
|
return container_instance
|
|
|
|
def list_container_instances(self, cluster_str: str) -> List[str]:
|
|
cluster_name = cluster_str.split("/")[-1]
|
|
container_instances_values = self.container_instances.get(
|
|
cluster_name, {}
|
|
).values()
|
|
container_instances = [
|
|
ci.container_instance_arn for ci in container_instances_values
|
|
]
|
|
return sorted(container_instances)
|
|
|
|
def describe_container_instances(
|
|
self, cluster_str: str, list_container_instance_ids: List[str]
|
|
) -> Tuple[List[ContainerInstance], List[ContainerInstanceFailure]]:
|
|
cluster = self._get_cluster(cluster_str)
|
|
|
|
if not list_container_instance_ids:
|
|
raise EcsClientException("Container Instances cannot be empty.")
|
|
failures = []
|
|
container_instance_objects = []
|
|
for container_instance_id in list_container_instance_ids:
|
|
container_instance_id = container_instance_id.split("/")[-1]
|
|
container_instance = self.container_instances[cluster.name].get(
|
|
container_instance_id, None
|
|
)
|
|
if container_instance is not None:
|
|
container_instance_objects.append(container_instance)
|
|
else:
|
|
failures.append(
|
|
ContainerInstanceFailure(
|
|
"MISSING",
|
|
container_instance_id,
|
|
self.account_id,
|
|
self.region_name,
|
|
)
|
|
)
|
|
|
|
return container_instance_objects, failures
|
|
|
|
def update_container_instances_state(
|
|
self, cluster_str: str, list_container_instance_ids: List[str], status: str
|
|
) -> Tuple[List[ContainerInstance], List[ContainerInstanceFailure]]:
|
|
cluster = self._get_cluster(cluster_str)
|
|
|
|
status = status.upper()
|
|
if status not in ["ACTIVE", "DRAINING"]:
|
|
raise InvalidParameterException(
|
|
"Container instance status should be one of [ACTIVE, DRAINING]"
|
|
)
|
|
failures = []
|
|
container_instance_objects = []
|
|
list_container_instance_ids = [
|
|
x.split("/")[-1] for x in list_container_instance_ids
|
|
]
|
|
for container_instance_id in list_container_instance_ids:
|
|
container_instance = self.container_instances[cluster.name].get(
|
|
container_instance_id, None
|
|
)
|
|
if container_instance is not None:
|
|
container_instance.status = status
|
|
container_instance_objects.append(container_instance)
|
|
else:
|
|
failures.append(
|
|
ContainerInstanceFailure(
|
|
"MISSING",
|
|
container_instance_id,
|
|
self.account_id,
|
|
self.region_name,
|
|
)
|
|
)
|
|
|
|
return container_instance_objects, failures
|
|
|
|
def update_container_instance_resources(
|
|
self,
|
|
container_instance: ContainerInstance,
|
|
task_resources: Dict[str, Any],
|
|
removing: bool = False,
|
|
) -> None:
|
|
resource_multiplier = 1
|
|
if removing:
|
|
resource_multiplier = -1
|
|
for resource in container_instance.remaining_resources:
|
|
if resource.get("name") == "CPU":
|
|
resource["integerValue"] -= (
|
|
task_resources.get("CPU") * resource_multiplier # type: ignore[operator]
|
|
)
|
|
elif resource.get("name") == "MEMORY":
|
|
resource["integerValue"] -= (
|
|
task_resources.get("MEMORY") * resource_multiplier # type: ignore[operator]
|
|
)
|
|
elif resource.get("name") == "PORTS":
|
|
for port in task_resources.get("PORTS"): # type: ignore[union-attr]
|
|
if removing:
|
|
resource["stringSetValue"].remove(str(port))
|
|
else:
|
|
resource["stringSetValue"].append(str(port))
|
|
container_instance.running_tasks_count += resource_multiplier * 1
|
|
|
|
def deregister_container_instance(
|
|
self, cluster_str: str, container_instance_str: str, force: bool
|
|
) -> ContainerInstance:
|
|
cluster = self._get_cluster(cluster_str)
|
|
|
|
container_instance_id = container_instance_str.split("/")[-1]
|
|
container_instance = self.container_instances[cluster.name].get(
|
|
container_instance_id
|
|
)
|
|
if container_instance is None:
|
|
raise Exception("{0} is not a container id in the cluster")
|
|
if not force and container_instance.running_tasks_count > 0:
|
|
raise Exception("Found running tasks on the instance.")
|
|
# Currently assume that people might want to do something based around deregistered instances
|
|
# with tasks left running on them - but nothing if no tasks were running already
|
|
elif force and container_instance.running_tasks_count > 0:
|
|
if not self.container_instances.get("orphaned"):
|
|
self.container_instances["orphaned"] = {}
|
|
self.container_instances["orphaned"][
|
|
container_instance_id
|
|
] = container_instance
|
|
del self.container_instances[cluster.name][container_instance_id]
|
|
self._respond_to_cluster_state_update(cluster_str)
|
|
return container_instance
|
|
|
|
def _respond_to_cluster_state_update(self, cluster_str: str) -> None:
|
|
self._get_cluster(cluster_str)
|
|
|
|
pass
|
|
|
|
def put_attributes(
|
|
self, cluster_name: str, attributes: Optional[List[Dict[str, Any]]] = None
|
|
) -> None:
|
|
cluster = self._get_cluster(cluster_name)
|
|
|
|
if attributes is None:
|
|
raise InvalidParameterException("attributes can not be empty")
|
|
|
|
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: str,
|
|
name: str,
|
|
value: Optional[str] = None,
|
|
target_id: Optional[str] = None,
|
|
target_type: Optional[str] = None,
|
|
) -> 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] # type: ignore[union-attr]
|
|
self.container_instances[cluster_name][arn].attributes[name] = value
|
|
except KeyError:
|
|
raise JsonRESTError(
|
|
"TargetNotFoundException", f"Could not find {target_id}"
|
|
)
|
|
else:
|
|
# targetId is container uuid, targetType must be container-instance
|
|
try:
|
|
if target_type != "container-instance":
|
|
raise JsonRESTError(
|
|
"TargetNotFoundException", f"Could not find {target_id}"
|
|
)
|
|
|
|
self.container_instances[cluster_name][target_id].attributes[ # type: ignore[index]
|
|
name
|
|
] = value
|
|
except KeyError:
|
|
raise JsonRESTError(
|
|
"TargetNotFoundException", f"Could not find {target_id}"
|
|
)
|
|
|
|
def list_attributes(
|
|
self,
|
|
target_type: str,
|
|
cluster_name: Optional[str] = None,
|
|
attr_name: Optional[str] = None,
|
|
attr_value: Optional[str] = None,
|
|
) -> Any:
|
|
"""
|
|
Pagination is not yet implemented
|
|
"""
|
|
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) # type: ignore
|
|
|
|
def delete_attributes(
|
|
self, cluster_name: str, attributes: Optional[List[Dict[str, Any]]] = None
|
|
) -> None:
|
|
cluster = self._get_cluster(cluster_name)
|
|
|
|
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: str,
|
|
name: str,
|
|
value: Optional[str] = None,
|
|
target_id: Optional[str] = None,
|
|
target_type: Optional[str] = None,
|
|
) -> 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] # type: ignore[union-attr]
|
|
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", f"Could not find {target_id}"
|
|
)
|
|
else:
|
|
# targetId is container uuid, targetType must be container-instance
|
|
try:
|
|
if target_type != "container-instance":
|
|
raise JsonRESTError(
|
|
"TargetNotFoundException", f"Could not find {target_id}"
|
|
)
|
|
|
|
instance = self.container_instances[cluster_name][target_id] # type: ignore[index]
|
|
if name in instance.attributes and instance.attributes[name] == value:
|
|
del instance.attributes[name]
|
|
except KeyError:
|
|
raise JsonRESTError(
|
|
"TargetNotFoundException", f"Could not find {target_id}"
|
|
)
|
|
|
|
def list_task_definition_families(
|
|
self, family_prefix: Optional[str] = None
|
|
) -> Iterator[str]:
|
|
"""
|
|
The Status and pagination parameters are not yet implemented
|
|
"""
|
|
for task_fam in self.task_definitions:
|
|
if family_prefix is not None and not task_fam.startswith(family_prefix):
|
|
continue
|
|
|
|
yield task_fam
|
|
|
|
@staticmethod
|
|
def _parse_resource_arn(resource_arn: str) -> Dict[str, str]:
|
|
regexes = [
|
|
"^arn:aws:ecs:(?P<region>[^:]+):(?P<account_id>[^:]+):(?P<service>[^:]+)/(?P<cluster_id>[^:]+)/(?P<service_id>[^:]+)/ecs-svc/(?P<id>.*)$",
|
|
"^arn:aws:ecs:(?P<region>[^:]+):(?P<account_id>[^:]+):(?P<service>[^:]+)/(?P<cluster_id>[^:]+)/(?P<id>.*)$",
|
|
"^arn:aws:ecs:(?P<region>[^:]+):(?P<account_id>[^:]+):(?P<service>[^:]+)/(?P<id>.*)$",
|
|
]
|
|
for regex in regexes:
|
|
match = re.match(regex, resource_arn)
|
|
if match:
|
|
return match.groupdict()
|
|
raise JsonRESTError("InvalidParameterException", "The ARN provided is invalid.")
|
|
|
|
def _get_resource(self, resource_arn: str, parsed_arn: Dict[str, str]) -> Any:
|
|
if parsed_arn["service"] == "cluster":
|
|
return self._get_cluster(parsed_arn["id"])
|
|
if parsed_arn["service"] == "service":
|
|
for service in self.services.values():
|
|
if service.arn == resource_arn:
|
|
return service
|
|
raise ServiceNotFoundException
|
|
elif parsed_arn["service"] == "task-set":
|
|
c_id = parsed_arn["cluster_id"]
|
|
s_id = parsed_arn["service_id"]
|
|
services, _ = self.describe_services(
|
|
cluster_str=c_id, service_names_or_arns=[s_id]
|
|
)
|
|
for service in services:
|
|
for task_set in service.task_sets:
|
|
if task_set.task_set_arn == resource_arn:
|
|
return task_set
|
|
raise ServiceNotFoundException
|
|
elif parsed_arn["service"] == "task-definition":
|
|
task_def = self.describe_task_definition(
|
|
task_definition_str=parsed_arn["id"]
|
|
)
|
|
return task_def
|
|
elif parsed_arn["service"] == "capacity-provider":
|
|
return self._get_provider(parsed_arn["id"])
|
|
raise NotImplementedError()
|
|
|
|
def list_tags_for_resource(self, resource_arn: str) -> List[Dict[str, str]]:
|
|
"""Currently implemented only for task definitions and services"""
|
|
parsed_arn = self._parse_resource_arn(resource_arn)
|
|
resource = self._get_resource(resource_arn, parsed_arn)
|
|
return resource.tags
|
|
|
|
def _get_last_task_definition_revision_id(self, family: str) -> int: # type: ignore[return]
|
|
definitions = self.task_definitions.get(family)
|
|
if definitions:
|
|
return max(definitions.keys())
|
|
|
|
def tag_resource(self, resource_arn: str, tags: List[Dict[str, str]]) -> None:
|
|
parsed_arn = self._parse_resource_arn(resource_arn)
|
|
resource = self._get_resource(resource_arn, parsed_arn)
|
|
resource.tags = self._merge_tags(resource.tags or [], tags)
|
|
|
|
def _merge_tags(
|
|
self, existing_tags: List[Dict[str, str]], new_tags: List[Dict[str, str]]
|
|
) -> List[Dict[str, str]]:
|
|
merged_tags = new_tags
|
|
new_keys = self._get_keys(new_tags)
|
|
for existing_tag in existing_tags:
|
|
if existing_tag["key"] not in new_keys:
|
|
merged_tags.append(existing_tag)
|
|
return merged_tags
|
|
|
|
@staticmethod
|
|
def _get_keys(tags: List[Dict[str, str]]) -> List[str]:
|
|
return [tag["key"] for tag in tags]
|
|
|
|
def untag_resource(self, resource_arn: str, tag_keys: List[str]) -> None:
|
|
parsed_arn = self._parse_resource_arn(resource_arn)
|
|
resource = self._get_resource(resource_arn, parsed_arn)
|
|
resource.tags = [tag for tag in resource.tags if tag["key"] not in tag_keys]
|
|
|
|
def create_task_set(
|
|
self,
|
|
service: str,
|
|
cluster_str: str,
|
|
task_definition: str,
|
|
external_id: Optional[str] = None,
|
|
network_configuration: Optional[Dict[str, Any]] = None,
|
|
load_balancers: Optional[List[Dict[str, Any]]] = None,
|
|
service_registries: Optional[List[Dict[str, Any]]] = None,
|
|
launch_type: Optional[str] = None,
|
|
capacity_provider_strategy: Optional[List[Dict[str, Any]]] = None,
|
|
platform_version: Optional[str] = None,
|
|
scale: Optional[Dict[str, Any]] = None,
|
|
client_token: Optional[str] = None,
|
|
tags: Optional[List[Dict[str, str]]] = None,
|
|
) -> TaskSet:
|
|
launch_type = launch_type if launch_type is not None else "EC2"
|
|
if launch_type not in ["EC2", "FARGATE"]:
|
|
raise EcsClientException("launch type should be one of [EC2,FARGATE]")
|
|
|
|
task_set = TaskSet(
|
|
service,
|
|
cluster_str,
|
|
task_definition,
|
|
self.account_id,
|
|
self.region_name,
|
|
external_id=external_id,
|
|
network_configuration=network_configuration,
|
|
load_balancers=load_balancers,
|
|
service_registries=service_registries,
|
|
launch_type=launch_type,
|
|
capacity_provider_strategy=capacity_provider_strategy,
|
|
platform_version=platform_version,
|
|
scale=scale,
|
|
client_token=client_token,
|
|
tags=tags,
|
|
)
|
|
|
|
service_name = service.split("/")[-1]
|
|
|
|
cluster_obj = self._get_cluster(cluster_str)
|
|
service_obj = self.services.get(f"{cluster_obj.name}:{service_name}")
|
|
if not service_obj:
|
|
raise ServiceNotFoundException
|
|
|
|
task_set.task_definition = self.describe_task_definition(task_definition).arn
|
|
task_set.service_arn = service_obj.arn
|
|
task_set.cluster_arn = cluster_obj.arn
|
|
|
|
service_obj.task_sets.append(task_set)
|
|
# TODO: validate load balancers
|
|
|
|
return task_set
|
|
|
|
def describe_task_sets(
|
|
self, cluster_str: str, service: str, task_sets: Optional[List[str]] = None
|
|
) -> List[TaskSet]:
|
|
task_sets = task_sets or []
|
|
|
|
cluster_obj = self._get_cluster(cluster_str)
|
|
|
|
service_name = service.split("/")[-1]
|
|
service_key = f"{cluster_obj.name}:{service_name}"
|
|
|
|
service_obj = self.services.get(service_key)
|
|
if not service_obj:
|
|
raise ServiceNotFoundException
|
|
|
|
task_set_results = []
|
|
if task_sets:
|
|
for task_set in service_obj.task_sets:
|
|
# Match full ARN
|
|
if task_set.task_set_arn in task_sets:
|
|
task_set_results.append(task_set)
|
|
# Match partial ARN if only the taskset ID is provided
|
|
elif "/".join(task_set.task_set_arn.split("/")[-2:]) in task_sets:
|
|
task_set_results.append(task_set)
|
|
else:
|
|
task_set_results = service_obj.task_sets
|
|
|
|
return task_set_results
|
|
|
|
def delete_task_set(self, cluster: str, service: str, task_set: str) -> TaskSet:
|
|
"""
|
|
The Force-parameter is not yet implemented
|
|
"""
|
|
cluster_name = cluster.split("/")[-1]
|
|
service_name = service.split("/")[-1]
|
|
|
|
service_key = f"{cluster_name}:{service_name}"
|
|
task_set_element = None
|
|
for i, ts in enumerate(self.services[service_key].task_sets):
|
|
if task_set == ts.task_set_arn or task_set == "/".join(
|
|
ts.task_set_arn.split("/")[-2:]
|
|
):
|
|
task_set_element = i
|
|
|
|
if task_set_element is not None:
|
|
deleted_task_set = self.services[service_key].task_sets.pop(
|
|
task_set_element
|
|
)
|
|
else:
|
|
raise TaskSetNotFoundException
|
|
|
|
# TODO: add logic for `force` to raise an exception if `PRIMARY` task has not been scaled to 0.
|
|
|
|
return deleted_task_set
|
|
|
|
def update_task_set(
|
|
self, cluster: str, service: str, task_set: str, scale: Dict[str, Any]
|
|
) -> TaskSet:
|
|
cluster_name = cluster.split("/")[-1]
|
|
service_name = service.split("/")[-1]
|
|
task_set_obj = self.describe_task_sets(
|
|
cluster_name, service_name, task_sets=[task_set]
|
|
)[0]
|
|
task_set_obj.scale = scale
|
|
return task_set_obj
|
|
|
|
def update_service_primary_task_set(
|
|
self, cluster: str, service: str, primary_task_set: str
|
|
) -> TaskSet:
|
|
"""Updates task sets be PRIMARY or ACTIVE for given cluster:service task sets"""
|
|
cluster_name = cluster.split("/")[-1]
|
|
service_name = service.split("/")[-1]
|
|
task_set_obj = self.describe_task_sets(
|
|
cluster_name, service_name, task_sets=[primary_task_set]
|
|
)[0]
|
|
|
|
services, _ = self.describe_services(cluster, [service])
|
|
service_obj = services[0]
|
|
service_obj.load_balancers = task_set_obj.load_balancers
|
|
service_obj.task_definition = task_set_obj.task_definition
|
|
|
|
for task_set in service_obj.task_sets:
|
|
if task_set.task_set_arn == primary_task_set:
|
|
task_set.status = "PRIMARY"
|
|
else:
|
|
task_set.status = "ACTIVE"
|
|
return task_set_obj
|
|
|
|
def list_account_settings(
|
|
self, name: Optional[str] = None, value: Optional[str] = None
|
|
) -> List[AccountSetting]:
|
|
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: str, value: str) -> AccountSetting:
|
|
account_setting = AccountSetting(name, value)
|
|
self.account_settings[name] = account_setting
|
|
return account_setting
|
|
|
|
def delete_account_setting(self, name: str) -> None:
|
|
self.account_settings.pop(name, None)
|
|
|
|
def enable_long_arn_for_name(self, name: str) -> bool:
|
|
account = self.account_settings.get(name, None)
|
|
if account and account.value == "disabled":
|
|
return False
|
|
return settings.ecs_new_arn_format()
|
|
|
|
|
|
ecs_backends = BackendDict(EC2ContainerServiceBackend, "ecs")
|