Repository: getmoto/moto
Pull request: ECS: allow ecs service to transition to arbitrary running task count
Closes https://github.com/getmoto/moto/issues/6283

Problem statement:
ECS services never seem to transition into running state
Using Moto 4.1.8. When creating a service in a cluster, I can't seem to figure out how, or if, there's any way to get the services to transition to a running state. I have the following code for fixtures:
```python
@pytest.fixture
def ecs(credentials):
    with moto.mock_ecs():
        yield boto3.client("ecs")

@pytest.fixture
def setup_ecs(ecs):
    ecs.create_cluster(clusterName="derp")
    ecs.register_task_definition(
        family="derp",
        containerDefinitions=[
            {
                "name": "task",
                "image": "123456789012.dkr.ecr.us-west-2.amazonaws.com/derp:live",
                "cpu": 1024,
                "memory": 2048,
                "memoryReservation": 2048,
                "portMappings": [
                    {"containerPort": 8000, "hostPort": 8000, "protocol": "tcp"}
                ],
                "essential": True,
                "mountPoints": [],
                "volumesFrom": [],
                "linuxParameters": {"initProcessEnabled": True},
                "logConfiguration": {"logDriver": "json-file"},
            }
        ],
    )
    ecs.create_service(
        cluster="derp",
        serviceName="derp",
        taskDefinition="derp",
        desiredCount=1,
    )
```
When I introspect the `derp` service, I notice that the service itself has a desired/pending/running count of 1/0/0, and when I introspect the `deployments` specifically, I have a 1/1/0 count of desired/pending/running.

What I'm trying to test is the scaling down and scaling up of the service count, in which I'm hoping to use `runningCount` as a way of knowing whether the service has scaled down to 0 or not. The source code of the tests doesn't seem to handle the scenario where a service transitions to a runningCount.

Is there a way to make the transition to `running` happen?

Hints from issue discussion:
@davido-nw with ☝️ PR you will be able to use a new env var `MOTO_ECS_SERVICE_RUNNING` to set a number of running tasks, after the service is created. If you want to test the `runningCount` going up or down, then you will need to flip that env var accordingly. 

The PR will need to be reviewed by a maintainer but the env var approach is used in other parts of moto, so it fits the commonly adopted pattern.

Commit message: tests: skip new test, the required env var would break other tests

Patch to review:
diff --git a/moto/ecs/models.py b/moto/ecs/models.py
index b89bd8a7c483..aada41a0dd37 100644
--- a/moto/ecs/models.py
+++ b/moto/ecs/models.py
@@ -2,6 +2,7 @@
 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
@@ -488,6 +489,14 @@ def response_object(self) -> Dict[str, Any]:  # type: ignore[misc]
 
 
 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,
@@ -507,7 +516,6 @@ def __init__(
         self.cluster_arn = cluster.arn
         self.name = service_name
         self.status = "ACTIVE"
-        self.running_count = 0
         self.task_definition = task_definition.arn if task_definition else None
         self.desired_count = desired_count
         self.task_sets: List[TaskSet] = []
@@ -515,6 +523,24 @@ def __init__(
         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.pending_count = 0
+        self.region_name = cluster.region_name
+        self._account_id = backend.account_id
+        self._backend = backend
+
+        try:
+            ecs_running_count = int(getenv("MOTO_ECS_SERVICE_RUNNING", 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
         if self.deployment_controller["type"] == "ECS":
             self.deployments = [
                 {
@@ -523,7 +549,7 @@ def __init__(
                     "id": f"ecs-svc/{mock_random.randint(0, 32**12)}",
                     "launchType": self.launch_type,
                     "pendingCount": self.desired_count,
-                    "runningCount": 0,
+                    "runningCount": ecs_running_count,
                     "status": "PRIMARY",
                     "taskDefinition": self.task_definition,
                     "updatedAt": datetime.now(timezone.utc),
@@ -531,16 +557,6 @@ def __init__(
             ]
         else:
             self.deployments = []
-        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.pending_count = 0
-        self.region_name = cluster.region_name
-        self._account_id = backend.account_id
-        self._backend = backend
 
     @property
     def arn(self) -> str:
diff --git a/tests/test_ecs/test_ecs_boto3.py b/tests/test_ecs/test_ecs_boto3.py
index d834162f031f..6b1d35881112 100644
--- a/tests/test_ecs/test_ecs_boto3.py
+++ b/tests/test_ecs/test_ecs_boto3.py
@@ -604,6 +604,54 @@ def test_create_service():
     response["service"]["platformVersion"].should.equal("2")
 
 
+@mock_ecs
+@mock_ec2
+def test_create_running_service():
+    if settings.TEST_SERVER_MODE:
+        raise SkipTest(
+            "Can't set environment variables in server mode for a single test"
+        )
+    running_service_count = 4
+    with mock.patch.dict(
+        os.environ, {"MOTO_ECS_SERVICE_RUNNING": str(running_service_count)}
+    ):
+        client = boto3.client("ecs", region_name="us-east-1")
+        ec2 = boto3.resource("ec2", region_name="us-east-1")
+        setup_ecs(client, ec2)
+
+        response = client.create_service(
+            cluster="test_ecs_cluster",
+            serviceName="test_ecs_service",
+            taskDefinition="test_ecs_task",
+            desiredCount=2,
+            platformVersion="2",
+        )
+
+        assert response["service"]["runningCount"] == running_service_count
+
+
+@mock_ecs
+@mock_ec2
+def test_create_running_service_bad_env_var():
+    running_service_count = "ALSDHLHA;''"
+    with mock.patch.dict(
+        os.environ, {"MOTO_ECS_SERVICE_RUNNING": str(running_service_count)}
+    ):
+        client = boto3.client("ecs", region_name="us-east-1")
+        ec2 = boto3.resource("ec2", region_name="us-east-1")
+        setup_ecs(client, ec2)
+
+        response = client.create_service(
+            cluster="test_ecs_cluster",
+            serviceName="test_ecs_service",
+            taskDefinition="test_ecs_task",
+            desiredCount=2,
+            platformVersion="2",
+        )
+
+        assert response["service"]["runningCount"] == 0
+
+
 @mock_ecs
 def test_create_service_errors():
     # given
@@ -870,7 +918,9 @@ def test_describe_services():
 @mock.patch.dict(os.environ, {"MOTO_ECS_NEW_ARN": "TrUe"})
 def test_describe_services_new_arn():
     if settings.TEST_SERVER_MODE:
-        raise SkipTest("Cant set environment variables in server mode")
+        raise SkipTest(
+            "Can't set environment variables in server mode for a single test"
+        )
     client = boto3.client("ecs", region_name="us-east-1")
     _ = client.create_cluster(clusterName="test_ecs_cluster")
     _ = client.register_task_definition(
@@ -1338,7 +1388,9 @@ def test_register_container_instance():
 @mock.patch.dict(os.environ, {"MOTO_ECS_NEW_ARN": "TrUe"})
 def test_register_container_instance_new_arn_format():
     if settings.TEST_SERVER_MODE:
-        raise SkipTest("Cant set environment variables in server mode")
+        raise SkipTest(
+            "Can't set environment variables in server mode for a single test"
+        )
     ecs_client = boto3.client("ecs", region_name="us-east-1")
     ec2 = boto3.resource("ec2", region_name="us-east-1")
 
@@ -1876,7 +1928,9 @@ def test_run_task_default_cluster():
 @mock.patch.dict(os.environ, {"MOTO_ECS_NEW_ARN": "TrUe"})
 def test_run_task_default_cluster_new_arn_format():
     if settings.TEST_SERVER_MODE:
-        raise SkipTest("Cant set environment variables in server mode")
+        raise SkipTest(
+            "Can't set environment variables in server mode for a single test"
+        )
     client = boto3.client("ecs", region_name="us-east-1")
     ec2 = boto3.resource("ec2", region_name="us-east-1")
 


Code review comments:
diff_hunk: @@ -507,14 +516,31 @@ def __init__(
         self.cluster_arn = cluster.arn
         self.name = service_name
         self.status = "ACTIVE"
-        self.running_count = 0
         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.pending_count = 0
path: moto/ecs/models.py
text: @user1:
This attribute isn't actually used at the moment - `self.deployments` currently uses `"pendingCount": self.desired_count`, which will be invalid with this change

diff_hunk: @@ -507,14 +516,31 @@ def __init__(
         self.cluster_arn = cluster.arn
         self.name = service_name
         self.status = "ACTIVE"
-        self.running_count = 0
         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.pending_count = 0
+        self.region_name = cluster.region_name
+        self._account_id = backend.account_id
+        self._backend = backend
+
+        try:
+            ecs_running_count = int(getenv("MOTO_ECS_SERVICE_RUNNING", 0))
path: moto/ecs/models.py
text: @user1:
Same here - theoretically this can be set to a negative number, so I think we should specify `min(ecs_running_count, 0)`

diff_hunk: @@ -507,14 +516,31 @@ def __init__(
         self.cluster_arn = cluster.arn
         self.name = service_name
         self.status = "ACTIVE"
-        self.running_count = 0
         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.pending_count = 0
path: moto/ecs/models.py
text: @user1:
We can probably improve the logic here as well, as this can now become equal to `desired_count - ecs_running_count` (with a minimum of 0, just in case the user sets the running count larger then the desired count