Repository: getmoto/moto

Issue:
Implement state transitions for ECS tasks
![task-lifecycle](https://github.com/getmoto/moto/assets/16805946/4ae02338-375e-4a82-99bd-77ebe48f3e4e)

* https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-lifecycle.html
* https://boto3.amazonaws.com/v1/documentation/api/1.28.1/reference/services/ecs/waiter/TasksStopped.html

Seems straightforward so I'll give it a shot 😅.



Discussion:

Patch:
diff --git a/moto/ecs/models.py b/moto/ecs/models.py
--- a/moto/ecs/models.py
+++ b/moto/ecs/models.py
@@ -11,7 +11,10 @@
 
 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 @@ def update_from_cloudformation_json(  # type: ignore[misc]
             return original_resource
 
 
-class Task(BaseObject):
+class Task(BaseObject, ManagedState):
     def __init__(
         self,
         cluster: Cluster,
@@ -348,11 +351,28 @@ def __init__(
         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 @@ def __init__(
                 }
             )
 
+    @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 @@ def task_arn(self) -> str:
     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 @@ def __init__(self, region_name: str, account_id: str):
         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 @@ def describe_tasks(
                     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


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