diff --git a/docs/docs/configuration/state_transition/models.rst b/docs/docs/configuration/state_transition/models.rst
index b3f3e8dbf..1cfb4e902 100644
--- a/docs/docs/configuration/state_transition/models.rst
+++ b/docs/docs/configuration/state_transition/models.rst
@@ -134,3 +134,16 @@ Transition type: Manual - describe the resource 1 time before the state advances
Advancement:
Call `boto3.client("transcribe").get_medical_transcription_job(..)`
+
+Service: ECS
+--------------
+
+**Model**: `ecs::task` :raw-html:`
`
+Available states:
+
+ "RUNNING" --> "DEACTIVATING" --> "STOPPING" --> "DEPROVISIONING" --> "STOPPED"
+
+Transition type: Manual - describe the resource 1 time before the state advances :raw-html:`
`
+Advancement:
+
+ Call `boto3.client("ecs").describe_tasks(..)`
diff --git a/moto/ecs/models.py b/moto/ecs/models.py
index b05c3749f..86da09461 100644
--- a/moto/ecs/models.py
+++ b/moto/ecs/models.py
@@ -11,7 +11,10 @@ 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 import state_manager
from moto.moto_api._internal import mock_random
+from moto.moto_api._internal.managed_state_model import ManagedState
+
from .exceptions import (
EcsClientException,
ServiceNotFoundException,
@@ -334,7 +337,7 @@ class TaskDefinition(BaseObject, CloudFormationModel):
return original_resource
-class Task(BaseObject):
+class Task(BaseObject, ManagedState):
def __init__(
self,
cluster: Cluster,
@@ -348,11 +351,28 @@ class Task(BaseObject):
tags: Optional[List[Dict[str, str]]] = None,
networking_configuration: Optional[Dict[str, Any]] = None,
):
+ # Configure ManagedState
+ # https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-lifecycle.html
+ super().__init__(
+ model_name="ecs::task",
+ transitions=[
+ # We start in RUNNING state in order not to break existing tests.
+ # ("PROVISIONING", "PENDING"),
+ # ("PENDING", "ACTIVATING"),
+ # ("ACTIVATING", "RUNNING"),
+ ("RUNNING", "DEACTIVATING"),
+ ("DEACTIVATING", "STOPPING"),
+ ("STOPPING", "DEPROVISIONING"),
+ ("DEPROVISIONING", "STOPPED"),
+ # There seems to be race condition, where the waiter expects the task to be in
+ # STOPPED state, but it is already in DELETED state.
+ # ("STOPPED", "DELETED"),
+ ],
+ )
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 {}
@@ -401,6 +421,14 @@ class Task(BaseObject):
}
)
+ @property
+ def last_status(self) -> Optional[str]:
+ return self.status # managed state
+
+ @last_status.setter
+ def last_status(self, value: Optional[str]) -> None:
+ self.status = value
+
@property
def task_arn(self) -> str:
if self._backend.enable_long_arn_for_name(name="taskLongArnFormat"):
@@ -411,6 +439,7 @@ class Task(BaseObject):
def response_object(self) -> Dict[str, Any]: # type: ignore[misc]
response_object = self.gen_response_object()
response_object["taskArn"] = self.task_arn
+ response_object["lastStatus"] = self.last_status
return response_object
@@ -929,6 +958,11 @@ class EC2ContainerServiceBackend(BaseBackend):
self.services: Dict[str, Service] = {}
self.container_instances: Dict[str, Dict[str, ContainerInstance]] = {}
+ state_manager.register_default_transition(
+ model_name="ecs::task",
+ transition={"progression": "manual", "times": 1},
+ )
+
@staticmethod
def default_vpc_endpoint_service(service_region: str, zones: List[str]) -> List[Dict[str, Any]]: # type: ignore[misc]
"""Default VPC endpoint service."""
@@ -1438,6 +1472,7 @@ class EC2ContainerServiceBackend(BaseBackend):
or task.task_arn in tasks
or any(task_id in task for task in tasks)
):
+ task.advance()
response.append(task)
if "TAGS" in (include or []):
return response
diff --git a/tests/test_ecs/test_ecs_boto3.py b/tests/test_ecs/test_ecs_boto3.py
index 912b7be58..250826183 100644
--- a/tests/test_ecs/test_ecs_boto3.py
+++ b/tests/test_ecs/test_ecs_boto3.py
@@ -11,6 +11,7 @@ from unittest import mock, SkipTest
from uuid import UUID
from moto import mock_ecs, mock_ec2, settings
+from moto.moto_api import state_manager
from tests import EXAMPLE_AMI_ID
@@ -1845,6 +1846,140 @@ def test_run_task():
assert task["tags"][0].get("value") == "tagValue0"
+@mock_ec2
+@mock_ecs
+def test_wait_tasks_stopped():
+ if settings.TEST_SERVER_MODE:
+ raise SkipTest("Can't set transition directly in ServerMode")
+
+ state_manager.set_transition(
+ model_name="ecs::task",
+ transition={"progression": "immediate"},
+ )
+
+ client = boto3.client("ecs", region_name="us-east-1")
+ ec2 = boto3.resource("ec2", region_name="us-east-1")
+
+ test_cluster_name = "test_ecs_cluster"
+
+ client.create_cluster(clusterName=test_cluster_name)
+
+ test_instance = ec2.create_instances(
+ ImageId=EXAMPLE_AMI_ID, MinCount=1, MaxCount=1
+ )[0]
+
+ instance_id_document = json.dumps(
+ ec2_utils.generate_instance_identity_document(test_instance)
+ )
+
+ response = 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,
+ "essential": True,
+ "environment": [
+ {"name": "AWS_ACCESS_KEY_ID", "value": "SOME_ACCESS_KEY"}
+ ],
+ "logConfiguration": {"logDriver": "json-file"},
+ }
+ ],
+ )
+ response = client.run_task(
+ cluster="test_ecs_cluster",
+ overrides={},
+ taskDefinition="test_ecs_task",
+ startedBy="moto",
+ )
+ task_arn = response["tasks"][0]["taskArn"]
+
+ assert len(response["tasks"]) == 1
+ client.get_waiter("tasks_stopped").wait(
+ cluster="test_ecs_cluster",
+ tasks=[task_arn],
+ )
+
+ response = client.describe_tasks(cluster="test_ecs_cluster", tasks=[task_arn])
+ assert response["tasks"][0]["lastStatus"] == "STOPPED"
+
+ state_manager.unset_transition("ecs::task")
+
+
+@mock_ec2
+@mock_ecs
+def test_task_state_transitions():
+ if settings.TEST_SERVER_MODE:
+ raise SkipTest("Can't set transition directly in ServerMode")
+
+ state_manager.set_transition(
+ model_name="ecs::task",
+ transition={"progression": "manual", "times": 1},
+ )
+
+ client = boto3.client("ecs", region_name="us-east-1")
+ ec2 = boto3.resource("ec2", region_name="us-east-1")
+
+ test_cluster_name = "test_ecs_cluster"
+
+ client.create_cluster(clusterName=test_cluster_name)
+
+ test_instance = ec2.create_instances(
+ ImageId=EXAMPLE_AMI_ID, MinCount=1, MaxCount=1
+ )[0]
+
+ instance_id_document = json.dumps(
+ ec2_utils.generate_instance_identity_document(test_instance)
+ )
+
+ response = 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,
+ "essential": True,
+ "environment": [
+ {"name": "AWS_ACCESS_KEY_ID", "value": "SOME_ACCESS_KEY"}
+ ],
+ "logConfiguration": {"logDriver": "json-file"},
+ }
+ ],
+ )
+
+ response = client.run_task(
+ cluster="test_ecs_cluster",
+ overrides={},
+ taskDefinition="test_ecs_task",
+ startedBy="moto",
+ )
+ task_arn = response["tasks"][0]["taskArn"]
+ assert len(response["tasks"]) == 1
+
+ task_status = response["tasks"][0]["lastStatus"]
+ assert task_status == "RUNNING"
+
+ for status in ("DEACTIVATING", "STOPPING", "DEPROVISIONING", "STOPPED"):
+ response = client.describe_tasks(cluster="test_ecs_cluster", tasks=[task_arn])
+ assert response["tasks"][0]["lastStatus"] == status
+
+ state_manager.unset_transition("ecs::task")
+
+
@mock_ec2
@mock_ecs
def test_run_task_awsvpc_network():