Repository: cdklabs/cdk-enterprise-iac (language: python)

Issue:
feat: Autoscaling mechanism for regions without ECS task autoscaling
### Description

In some AWS regions, [ECS service autoscaling](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-auto-scaling.html) is not supported.

As a result, ECS cluster deployments have a fixed number of tasks requiring manual updating

### Use Case

As an ECS service gets increased utilization and hits resource limits such as CPU and memory, additional tasks can't be automatically started to handle the load without manual intervention.

Conversely, as utilization decreases and fewer ECS tasks are needed to handle the workload, tasks aren't removed from the ECS service, resulting in over provisioning of resources and added costs.



### Proposed Solution

A construct to track Cloudwatch Metrics for an ECS service, and set the desired task count for the service based on those metrics.


### Other information

_No response_

### Acknowledge

- [ ] I may be able to implement this feature request
- [ ] This feature might incur a breaking change


Discussion / hints:
Feel free to assign me. 

Fix patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 36c53b8..febe7d1 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -50,6 +50,7 @@ jobs:
           path: dist
     container:
       image: jsii/superchain:1-buster-slim-node14
+      options: --group-add 121
   self-mutation:
     needs: build
     runs-on: ubuntu-latest
diff --git a/.projenrc.js b/.projenrc.js
index 587d7f8..4ad06e3 100644
--- a/.projenrc.js
+++ b/.projenrc.js
@@ -2,7 +2,7 @@
 Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 SPDX-License-Identifier: Apache-2.0
 */
-const { awscdk } = require('projen');
+const { awscdk, JsonPatch } = require('projen');
 const { GitlabConfiguration } = require('projen/lib/gitlab');
 const project = new awscdk.AwsCdkConstructLibrary({
   author: 'Taylor Ondrey',
@@ -58,4 +58,8 @@ project.eslint.addRules({
   ],
 });
 project.eslint.addExtends('plugin:security/recommended');
+const buildWorkflow = project.tryFindObjectFile('.github/workflows/build.yml');
+buildWorkflow.patch(
+  JsonPatch.add('/jobs/build/container/options', '--group-add 121')
+);
 project.synth();
diff --git a/API.md b/API.md
index 0605533..6bd72b1 100644
--- a/API.md
+++ b/API.md
@@ -1,5 +1,110 @@
 # API Reference <a name="API Reference" id="api-reference"></a>
 
+## Constructs <a name="Constructs" id="Constructs"></a>
+
+### EcsIsoServiceAutoscaler <a name="EcsIsoServiceAutoscaler" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler"></a>
+
+Creates a EcsIsoServiceAutoscaler construct.
+
+This construct allows you to scale an ECS service in an ISO
+region where classic ECS Autoscaling may not be available.
+
+#### Initializers <a name="Initializers" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.Initializer"></a>
+
+```typescript
+import { EcsIsoServiceAutoscaler } from '@cdklabs/cdk-enterprise-iac'
+
+new EcsIsoServiceAutoscaler(scope: Construct, id: string, props: EcsIsoServiceAutoscalerProps)
+```
+
+| **Name** | **Type** | **Description** |
+| --- | --- | --- |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.Initializer.parameter.scope">scope</a></code> | <code>constructs.Construct</code> | *No description.* |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.Initializer.parameter.id">id</a></code> | <code>string</code> | *No description.* |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.Initializer.parameter.props">props</a></code> | <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps">EcsIsoServiceAutoscalerProps</a></code> | *No description.* |
+
+---
+
+##### `scope`<sup>Required</sup> <a name="scope" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.Initializer.parameter.scope"></a>
+
+- *Type:* constructs.Construct
+
+---
+
+##### `id`<sup>Required</sup> <a name="id" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.Initializer.parameter.id"></a>
+
+- *Type:* string
+
+---
+
+##### `props`<sup>Required</sup> <a name="props" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.Initializer.parameter.props"></a>
+
+- *Type:* <a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps">EcsIsoServiceAutoscalerProps</a>
+
+---
+
+#### Methods <a name="Methods" id="Methods"></a>
+
+| **Name** | **Description** |
+| --- | --- |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.toString">toString</a></code> | Returns a string representation of this construct. |
+
+---
+
+##### `toString` <a name="toString" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.toString"></a>
+
+```typescript
+public toString(): string
+```
+
+Returns a string representation of this construct.
+
+#### Static Functions <a name="Static Functions" id="Static Functions"></a>
+
+| **Name** | **Description** |
+| --- | --- |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.isConstruct">isConstruct</a></code> | Checks if `x` is a construct. |
+
+---
+
+##### ~~`isConstruct`~~ <a name="isConstruct" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.isConstruct"></a>
+
+```typescript
+import { EcsIsoServiceAutoscaler } from '@cdklabs/cdk-enterprise-iac'
+
+EcsIsoServiceAutoscaler.isConstruct(x: any)
+```
+
+Checks if `x` is a construct.
+
+###### `x`<sup>Required</sup> <a name="x" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.isConstruct.parameter.x"></a>
+
+- *Type:* any
+
+Any object.
+
+---
+
+#### Properties <a name="Properties" id="Properties"></a>
+
+| **Name** | **Type** | **Description** |
+| --- | --- | --- |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.property.node">node</a></code> | <code>constructs.Node</code> | The tree node. |
+
+---
+
+##### `node`<sup>Required</sup> <a name="node" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.property.node"></a>
+
+```typescript
+public readonly node: Node;
+```
+
+- *Type:* constructs.Node
+
+The tree node.
+
+---
+
 
 ## Structs <a name="Structs" id="Structs"></a>
 
@@ -74,6 +179,163 @@ A prefix to prepend to the name of IAM Roles (Default: '').
 
 ---
 
+### EcsIsoServiceAutoscalerProps <a name="EcsIsoServiceAutoscalerProps" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps"></a>
+
+#### Initializer <a name="Initializer" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.Initializer"></a>
+
+```typescript
+import { EcsIsoServiceAutoscalerProps } from '@cdklabs/cdk-enterprise-iac'
+
+const ecsIsoServiceAutoscalerProps: EcsIsoServiceAutoscalerProps = { ... }
+```
+
+#### Properties <a name="Properties" id="Properties"></a>
+
+| **Name** | **Type** | **Description** |
+| --- | --- | --- |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.ecsCluster">ecsCluster</a></code> | <code>aws-cdk-lib.aws_ecs.Cluster</code> | The cluster the service you wish to scale resides in. |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.ecsService">ecsService</a></code> | <code>aws-cdk-lib.aws_ecs.IService</code> | The ECS service you wish to scale. |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.role">role</a></code> | <code>aws-cdk-lib.aws_iam.IRole</code> | The IAM role that allows the created lambda to adjust the desired count on the ECS Service . |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.scaleAlarm">scaleAlarm</a></code> | <code>aws-cdk-lib.aws_cloudwatch.Alarm</code> | The Cloudwatch Alarm that will cause scaling actions to be invoked, whether it's in or not in alarm will determine scale up and down actions. |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.maximumTaskCount">maximumTaskCount</a></code> | <code>number</code> | The maximum number of tasks that the service will scale out to. |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.minimumTaskCount">minimumTaskCount</a></code> | <code>number</code> | The minimum number of tasks the service will have. |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.scaleInCooldowwn">scaleInCooldowwn</a></code> | <code>aws-cdk-lib.Duration</code> | How long will the application wait before performing another scale in action. |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.scaleInIncrement">scaleInIncrement</a></code> | <code>number</code> | The number of tasks that will scale out on scal out alarm status. |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.scaleOutCooldown">scaleOutCooldown</a></code> | <code>aws-cdk-lib.Duration</code> | How long will a the application wait before performing another scale out action. |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.scaleOutIncrement">scaleOutIncrement</a></code> | <code>number</code> | The number of tasks that will scale out on scal out alarm status. |
+
+---
+
+##### `ecsCluster`<sup>Required</sup> <a name="ecsCluster" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.ecsCluster"></a>
+
+```typescript
+public readonly ecsCluster: Cluster;
+```
+
+- *Type:* aws-cdk-lib.aws_ecs.Cluster
+
+The cluster the service you wish to scale resides in.
+
+---
+
+##### `ecsService`<sup>Required</sup> <a name="ecsService" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.ecsService"></a>
+
+```typescript
+public readonly ecsService: IService;
+```
+
+- *Type:* aws-cdk-lib.aws_ecs.IService
+
+The ECS service you wish to scale.
+
+---
+
+##### `role`<sup>Required</sup> <a name="role" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.role"></a>
+
+```typescript
+public readonly role: IRole;
+```
+
+- *Type:* aws-cdk-lib.aws_iam.IRole
+
+The IAM role that allows the created lambda to adjust the desired count on the ECS Service .
+
+TODO: Make optional and add function for auto generation
+
+---
+
+##### `scaleAlarm`<sup>Required</sup> <a name="scaleAlarm" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.scaleAlarm"></a>
+
+```typescript
+public readonly scaleAlarm: Alarm;
+```
+
+- *Type:* aws-cdk-lib.aws_cloudwatch.Alarm
+
+The Cloudwatch Alarm that will cause scaling actions to be invoked, whether it's in or not in alarm will determine scale up and down actions.
+
+---
+
+##### `maximumTaskCount`<sup>Optional</sup> <a name="maximumTaskCount" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.maximumTaskCount"></a>
+
+```typescript
+public readonly maximumTaskCount: number;
+```
+
+- *Type:* number
+- *Default:* 10
+
+The maximum number of tasks that the service will scale out to.
+
+Note: This does not provide any protection from scaling out above the maximum allowed in your account, set this variable and manage account quotas appropriately.
+
+---
+
+##### `minimumTaskCount`<sup>Optional</sup> <a name="minimumTaskCount" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.minimumTaskCount"></a>
+
+```typescript
+public readonly minimumTaskCount: number;
+```
+
+- *Type:* number
+- *Default:* 1
+
+The minimum number of tasks the service will have.
+
+---
+
+##### `scaleInCooldowwn`<sup>Optional</sup> <a name="scaleInCooldowwn" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.scaleInCooldowwn"></a>
+
+```typescript
+public readonly scaleInCooldowwn: Duration;
+```
+
+- *Type:* aws-cdk-lib.Duration
+- *Default:* 60 seconds
+
+How long will the application wait before performing another scale in action.
+
+---
+
+##### `scaleInIncrement`<sup>Optional</sup> <a name="scaleInIncrement" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.scaleInIncrement"></a>
+
+```typescript
+public readonly scaleInIncrement: number;
+```
+
+- *Type:* number
+- *Default:* 1
+
+The number of tasks that will scale out on scal out alarm status.
+
+---
+
+##### `scaleOutCooldown`<sup>Optional</sup> <a name="scaleOutCooldown" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.scaleOutCooldown"></a>
+
+```typescript
+public readonly scaleOutCooldown: Duration;
+```
+
+- *Type:* aws-cdk-lib.Duration
+- *Default:* 60 seconds
+
+How long will a the application wait before performing another scale out action.
+
+---
+
+##### `scaleOutIncrement`<sup>Optional</sup> <a name="scaleOutIncrement" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.scaleOutIncrement"></a>
+
+```typescript
+public readonly scaleOutIncrement: number;
+```
+
+- *Type:* number
+- *Default:* 1
+
+The number of tasks that will scale out on scal out alarm status.
+
+---
+
 ### RemoveTagsProps <a name="RemoveTagsProps" id="@cdklabs/cdk-enterprise-iac.RemoveTagsProps"></a>
 
 #### Initializer <a name="Initializer" id="@cdklabs/cdk-enterprise-iac.RemoveTagsProps.Initializer"></a>
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index dd0a64c..0cf3821 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -51,7 +51,17 @@ If there isn't one already, open an issue describing what you intend to contribu
 
 GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/). Make sure you are working against the latest source on the _main_ branch.
 
-### Step 3: Develop
+### Step 3: Setup
+
+The following tools need to be installed on your system prior to developing
+
+- [Node.js >= 14.15.0](https://nodejs.org/download/release/latest-v14.x/)
+  - We recommend using a version in [Active LTS](https://nodejs.org/en/about/releases/)
+- [Yarn >= 1.19.1, < 2](https://yarnpkg.com/lang/en/docs/install)
+- [Docker >= 19.03](https://docs.docker.com/get-docker/)
+  - the Docker daemon must also be running
+
+### Step 4: Develop
 
 This repository uses [pre-commit](https://pre-commit.com/) hooks for linting and [projen](https://github.com/projen/projen) for building and testing
 
@@ -71,7 +81,7 @@ This repository uses [pre-commit](https://pre-commit.com/) hooks for linting and
 8. Create the commit with relevant files
    - Note: you may need to update the commit if `pre-commit` changes/suggests changes to files
 
-### Step 4: Make the pull request
+### Step 5: Make the pull request
 
 Send us a [pull request](https://help.github.com/articles/creating-a-pull-request/), answering any default questions in the pull request interface. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation.
 
diff --git a/resources/constructs/ecsIsoServiceAutoscaler/ecs_scaling_manager.py b/resources/constructs/ecsIsoServiceAutoscaler/ecs_scaling_manager.py
new file mode 100644
index 0000000..a6662cc
--- /dev/null
+++ b/resources/constructs/ecsIsoServiceAutoscaler/ecs_scaling_manager.py
@@ -0,0 +1,160 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+import logging
+import os
+from datetime import datetime
+from typing import Any, Dict, List, Union, cast
+
+import boto3
+
+# Environment
+ECS_CLUSTER_NAME = os.environ.get("ECS_CLUSTER_NAME", "")
+ECS_SERVICE_NAME = os.environ.get("ECS_SERVICE_NAME", "")
+SCALE_ALARM_NAME = os.environ.get("SCALE_ALARM_NAME", "")
+SCALE_OUT_INCREMENT = int(os.environ.get("SCALE_OUT_INCREMENT", 1))
+SCALE_IN_INCREMENT = int(os.environ.get("SCALE_IN_INCREMENT", 1))
+SCALE_OUT_COOLDOWN = int(os.environ.get("SCALE_OUT_COOLDOWN", 60))
+SCALE_IN_COOLDOWN = int(os.environ.get("SCALE_IN_COOLDOWN", 60))
+MINIMUM_TASK_COUNT = int(os.environ.get("MINIMUM_TASK_COUNT", 1))
+MAXIMUM_TASK_COUNT = int(os.environ.get("MAXIMUM_TASK_COUNT", 10))
+
+# Logging
+logger = logging.getLogger()
+logger.setLevel(logging.INFO)
+
+# Clients
+cw_client = boto3.client("cloudwatch")
+ecs_client = boto3.client("ecs")
+
+
+def _get_alarm_states(alarm_name: str) -> List[Union[str, None]]:
+    """
+    Gets a list of alarm states for a given alarm
+
+    :param alarm_name: the alarm name to find states for
+    :returns: list of alarm states, ex: ["OK", "ALARM"]
+    """
+    alarm = cw_client.describe_alarms(AlarmNames=[alarm_name])
+
+    alarm_states = []
+    metric_alarms = alarm.get("MetricAlarms", None)
+    if metric_alarms is not None:
+        alarm_states = [x.get("StateValue") for x in alarm["MetricAlarms"]]
+
+    return alarm_states
+
+
+def _get_ecs_service(cluster_name: str, service_name: str) -> Dict[str, Any]:
+    """
+    Gets the ECS Service boto3 response object
+
+    :param cluster_name: name of ECS Cluster
+    :param service_name: name of ECS Service
+    :returns: boto3 dictionary response object for service
+    """
+    services = ecs_client.describe_services(
+        cluster=cluster_name, services=[service_name]
+    )
+    service: Dict[str, Any] = cast(list, services["services"])[0]
+    return service
+
+
+def _get_time_since_last_ecs_update(
+    service: Dict[str, Any]
+) -> float:
+    """
+    Retrieve the time since the ECS Service was last updated
+
+    :param service: the boto3 service response object
+    :returns: time in seconds since ECS Service was last updated
+    """
+    last_update = 0.0
+    deployments = cast(list, service.get("deployments", None))
+    if len(deployments) == 1:
+        current_deployment: Dict[str, Any] = deployments[0]
+        last_updated = cast(datetime, current_deployment.get("updatedAt"))
+        last_update = datetime.now().timestamp() - last_updated.timestamp()
+    return last_update
+
+
+def _trigger_scaling_action(
+    type_: str,
+    increment: int,
+    current_count: int,
+    end_count: int,
+    cooldown: int,
+    last_update: float
+) -> None:
+    """
+    Triggers a scaling action for ECS
+
+    :param type_: the type of scale to perform, should be "in" or "out"
+    :param increment: the scaling increase or decrease increment to take
+    :param end: the minimum or maximum task count to stop at
+    :param cooldown: time elapsed from previous scaling action where no scaling
+    action should be performed
+    :param last_update: time the last update was performed on the service
+    """
+    new_desired_count = current_count
+    proposed_count = current_count
+
+    if last_update >= cooldown:
+        if current_count is not None:
+            if type_.lower() == "out":
+                proposed_count = current_count + increment
+                new_desired_count = proposed_count if proposed_count <= end_count else end_count
+            elif type_.lower() == "in":
+                proposed_count = current_count - increment
+                new_desired_count = proposed_count if proposed_count >= end_count else end_count
+
+        if new_desired_count != current_count:
+            ecs_client.update_service(
+                cluster=ECS_CLUSTER_NAME,
+                service=ECS_SERVICE_NAME,
+                desiredCount=new_desired_count
+            )
+            logger.info(f"Changed desired count from {current_count} to {new_desired_count}")
+        else:
+            logger.info(
+                "Service is already at expected capacity, no action taken"
+            )
+    else:
+        logger.info(
+            "Cooldown period has not been exceeded, no action taken"
+        )
+
+
+def handler(event, context):
+    alarm_states = _get_alarm_states(SCALE_ALARM_NAME)
+    service = _get_ecs_service(ECS_CLUSTER_NAME, ECS_SERVICE_NAME)
+
+    desired_count = service.get("desiredCount")
+    running_count = service.get("runningCount")
+
+    if desired_count != running_count:
+        logger.info(
+            "Task count has not caught up with desired count, no action taken"
+        )
+        return
+
+    last_updated = _get_time_since_last_ecs_update(service)
+
+    if alarm_states is not None and "ALARM" in alarm_states:
+        _trigger_scaling_action(
+            type_="OUT",
+            increment=SCALE_OUT_INCREMENT,
+            current_count=desired_count,
+            end_count=MAXIMUM_TASK_COUNT,
+            cooldown=SCALE_OUT_COOLDOWN,
+            last_update=last_updated
+        )
+    elif alarm_states is not None and all(x == "OK" for x in alarm_states):
+        _trigger_scaling_action(
+            type_="IN",
+            increment=SCALE_IN_INCREMENT,
+            current_count=desired_count,
+            end_count=MINIMUM_TASK_COUNT,
+            cooldown=SCALE_IN_COOLDOWN,
+            last_update=last_updated
+        )
+
diff --git a/src/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.ts b/src/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.ts
new file mode 100644
index 0000000..326dd76
--- /dev/null
+++ b/src/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.ts
@@ -0,0 +1,126 @@
+/*
+Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+SPDX-License-Identifier: Apache-2.0
+*/
+import { Duration } from 'aws-cdk-lib';
+import { Alarm } from 'aws-cdk-lib/aws-cloudwatch';
+import { Cluster, IService } from 'aws-cdk-lib/aws-ecs';
+import { Rule, Schedule } from 'aws-cdk-lib/aws-events';
+import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets';
+import { IRole } from 'aws-cdk-lib/aws-iam';
+import { Code, Function, Runtime } from 'aws-cdk-lib/aws-lambda';
+import { Construct } from 'constructs';
+
+export interface EcsIsoServiceAutoscalerProps {
+  /**
+   * The IAM role that allows the created lambda to adjust the desired count on the ECS Service .
+   * TODO: Make optional and add function for auto generation
+   */
+  readonly role: IRole;
+  /**
+   * The cluster the service you wish to scale resides in.
+   */
+  readonly ecsCluster: Cluster;
+  /**
+   * The ECS service you wish to scale.
+   */
+  readonly ecsService: IService;
+  /**
+   * The minimum number of tasks the service will have.
+   *
+   * @default 1
+   */
+  readonly minimumTaskCount?: number;
+  /**
+   * The maximum number of tasks that the service will scale out to.
+   * Note: This does not provide any protection from scaling out above the maximum allowed in your account, set this variable and manage account quotas appropriately.
+   *
+   * @default 10
+   */
+  readonly maximumTaskCount?: number;
+  /**
+   * The Cloudwatch Alarm that will cause scaling actions to be invoked, whether it's in or not in alarm will determine scale up and down actions.
+   */
+  readonly scaleAlarm: Alarm;
+  /**
+   * The number of tasks that will scale out on scal out alarm status
+   *
+   * @default 1
+   */
+  readonly scaleOutIncrement?: number;
+  /**
+   * The number of tasks that will scale out on scal out alarm status
+   *
+   * @default 1
+   */
+  readonly scaleInIncrement?: number;
+  /**
+   * How long will a the application wait before performing another scale out action.
+   *
+   * @default 60 seconds
+   */
+  readonly scaleOutCooldown?: Duration;
+  /**
+   * How long will the application wait before performing another scale in action
+   *
+   * @default 60 seconds
+   */
+  readonly scaleInCooldowwn?: Duration;
+}
+
+/**
+ * Creates a EcsIsoServiceAutoscaler construct. This construct allows you to scale an ECS service in an ISO
+ * region where classic ECS Autoscaling may not be available.
+ *
+ * @param scope The parent creating construct (usually `this`).
+ * @param id The construct's name.
+ * @param this A `EcsIsoServiceAutoscalerthis` interface.
+ */
+
+export class EcsIsoServiceAutoscaler extends Construct {
+  constructor(
+    scope: Construct,
+    id: string,
+    props: EcsIsoServiceAutoscalerProps
+  ) {
+    super(scope, id);
+
+    const {
+      minimumTaskCount = 1,
+      maximumTaskCount = 10,
+      scaleOutIncrement = 1,
+      scaleInIncrement = 1,
+      scaleOutCooldown = Duration.seconds(60),
+      scaleInCooldowwn = Duration.seconds(60),
+    } = props;
+
+    const ecsScalingManager = new Function(
+      this,
+      `${id}-EcsServiceScalingManager`,
+      {
+        code: Code.fromAsset('./resources/constructs/ecsIsoServiceAutoscaler/'),
+        handler: 'ecs_scaling_manager.handler',
+        runtime: Runtime.PYTHON_3_7,
+        role: props.role,
+        environment: {
+          ECS_CLUSTER_NAME: props.ecsCluster.clusterName,
+          ECS_SERVICE_NAME: props.ecsService.serviceName,
+          MINIMUM_TASK_COUNT: minimumTaskCount.toString(),
+          MAXIMUM_TASK_COUNT: maximumTaskCount.toString(),
+          SCALE_ALARM_NAME: props.scaleAlarm.alarmName,
+          SCALE_OUT_INCREMENT: scaleOutIncrement.toString(),
+          SCALE_OUT_COOLDOWN: scaleOutCooldown.toSeconds().toString(),
+          SCALE_IN_INCREMENT: scaleInIncrement.toString(),
+          SCALE_IN_COOLDOWN: scaleInCooldowwn.toSeconds().toString(),
+        },
+      }
+    );
+
+    new Rule(this, `${id}-EcsScalingManagerSchedule`, {
+      description: `Kicks off Lambda to adjust ECS scaling for service: ${props.ecsService.serviceName}`,
+      enabled: true,
+      schedule: Schedule.rate(Duration.minutes(1)),
+      targets: [new LambdaFunction(ecsScalingManager)],
+    });
+  }
+}
diff --git a/src/index.ts b/src/index.ts
index 9c33035..a584251 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,3 +7,4 @@ export * from './patches/removeTags';
 export * from './patches/addLambdaEnvironmentVariables';
 export * from './patches/removePublicAccessBlockConfiguration';
 export * from './patches/setApiGatewayEndpointConfiguration';
+export * from './constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler';


Test patch:
diff --git a/test/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.test.ts b/test/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.test.ts
new file mode 100644
index 0000000..c91a48d
--- /dev/null
+++ b/test/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.test.ts
@@ -0,0 +1,124 @@
+/*
+Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+SPDX-License-Identifier: Apache-2.0
+*/
+import { spawnSync } from 'child_process';
+import * as path from 'path';
+import { Duration, Stack } from 'aws-cdk-lib';
+import { Match, Template } from 'aws-cdk-lib/assertions';
+import { Alarm } from 'aws-cdk-lib/aws-cloudwatch';
+import {
+  Cluster,
+  ContainerImage,
+  FargateService,
+  FargateTaskDefinition,
+} from 'aws-cdk-lib/aws-ecs';
+import { Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
+import { EcsIsoServiceAutoscaler } from '../../../src/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler';
+
+let stack: Stack;
+
+describe('Python Lambda function tests', () => {
+  test('lambda python pytest', () => {
+    const result = spawnSync(path.join(__dirname, 'resources', 'test.sh'), {
+      stdio: 'inherit',
+    });
+    expect(result.status).toBe(0);
+  });
+});
+
+describe('EcsIsoServiceAutoscaler construct', () => {
+  let cluster: Cluster;
+  let service: FargateService;
+  let alarm: Alarm;
+  let role: Role;
+  beforeEach(() => {
+    stack = new Stack();
+    cluster = new Cluster(stack, 'TestCluster');
+    const taskDefinition = new FargateTaskDefinition(
+      stack,
+      'TestTaskDefinition'
+    );
+    taskDefinition.addContainer('SomeContainer', {
+      image: ContainerImage.fromRegistry(
+        'public.ecr.aws/ecs-sample-image/amazon-ecs-sample:latest'
+      ),
+    });
+    service = new FargateService(stack, 'TestService', {
+      cluster,
+      taskDefinition,
+      desiredCount: 5,
+    });
+    role = new Role(stack, 'TestTaskRole', {
+      assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
+    });
+    alarm = new Alarm(stack, 'TestAlarm', {
+      metric: cluster.metricCpuUtilization(),
+      threshold: 10,
+      evaluationPeriods: 2,
+    });
+  });
+
+  test('Expected number of resources are created with defaults', () => {
+    new EcsIsoServiceAutoscaler(stack, 'TestEcsIsoServiceAutoscaler', {
+      ecsCluster: cluster,
+      ecsService: service,
+      scaleAlarm: alarm,
+      role,
+    });
+
+    const template = Template.fromStack(stack);
+
+    // Lambda with default Env vars
+    template.resourcePropertiesCountIs(
+      'AWS::Lambda::Function',
+      {
+        Environment: {
+          Variables: Match.objectLike({
+            MINIMUM_TASK_COUNT: '1',
+            MAXIMUM_TASK_COUNT: '10',
+            SCALE_OUT_INCREMENT: '1',
+            SCALE_OUT_COOLDOWN: '60',
+            SCALE_IN_INCREMENT: '1',
+            SCALE_IN_COOLDOWN: '60',
+          }),
+        },
+      },
+      1
+    );
+  });
+  test('Autoscaling props provided', () => {
+    new EcsIsoServiceAutoscaler(stack, 'TestEcsIsoServiceAutoscaler', {
+      ecsCluster: cluster,
+      ecsService: service,
+      scaleAlarm: alarm,
+      role,
+      minimumTaskCount: 5,
+      maximumTaskCount: 15,
+      scaleInIncrement: 2,
+      scaleOutIncrement: 2,
+      scaleInCooldowwn: Duration.seconds(120),
+      scaleOutCooldown: Duration.seconds(120),
+    });
+
+    const template = Template.fromStack(stack);
+
+    // Lambda with default Env vars
+    template.resourcePropertiesCountIs(
+      'AWS::Lambda::Function',
+      {
+        Environment: {
+          Variables: Match.objectLike({
+            MINIMUM_TASK_COUNT: '5',
+            MAXIMUM_TASK_COUNT: '15',
+            SCALE_OUT_INCREMENT: '2',
+            SCALE_OUT_COOLDOWN: '120',
+            SCALE_IN_INCREMENT: '2',
+            SCALE_IN_COOLDOWN: '120',
+          }),
+        },
+      },
+      1
+    );
+  });
+});
diff --git a/test/constructs/ecsIsoServiceAutoscaler/resources/Dockerfile b/test/constructs/ecsIsoServiceAutoscaler/resources/Dockerfile
new file mode 100644
index 0000000..28a31d1
--- /dev/null
+++ b/test/constructs/ecsIsoServiceAutoscaler/resources/Dockerfile
@@ -0,0 +1,14 @@
+FROM public.ecr.aws/lambda/python:3.7
+
+# add everything to /opt/awscli (this is where `aws` is executed from)
+ADD . ${LAMBDA_TASK_ROOT}
+
+# install boto3, which is available on Lambda, and pytest
+RUN pip3 install boto3 pytest
+
+# run tests
+WORKDIR ${LAMBDA_TASK_ROOT}
+RUN ["pytest"]
+
+ENTRYPOINT [ "/bin/bash" ]
+
diff --git a/test/constructs/ecsIsoServiceAutoscaler/resources/conftest.py b/test/constructs/ecsIsoServiceAutoscaler/resources/conftest.py
new file mode 100644
index 0000000..34ca439
--- /dev/null
+++ b/test/constructs/ecsIsoServiceAutoscaler/resources/conftest.py
@@ -0,0 +1,159 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+import datetime
+from typing import Any, Dict
+
+import pytest
+from dateutil.tz import tzlocal, tzutc
+
+
+@pytest.fixture
+def boto3_ecs_service_response(request) -> Dict[str, Any]:
+    try:
+        time_diff = request.param
+    except:
+        time_diff = 300
+
+    return {
+        "services": [
+            {
+                "serviceArn": "arn:aws:ecs:us-east-1:123456789012:service/Infra-Cluster/Task-Service",
+                "serviceName": "Task-Service",
+                "clusterArn": "arn:aws:ecs:us-east-1:123456789012:cluster/Infra-Cluster",
+                "loadBalancers": [],
+                "serviceRegistries": [],
+                "status": "ACTIVE",
+                "desiredCount": 3,
+                "runningCount": 3,
+                "pendingCount": 0,
+                "launchType": "EC2",
+                "taskDefinition": "arn:aws:ecs:us-east-1:123456789012:task-definition/TaskDefinition:N",
+                "deploymentConfiguration": {
+                    "deploymentCircuitBreaker": {
+                        "enable": False,
+                        "rollback": False
+                    },
+                    "maximumPercent": 200,
+                    "minimumHealthyPercent": 50
+                },
+                "deployments": [
+                    {
+                        "id": "ecs-svc/3665271676139587657",
+                        "status": "PRIMARY",
+                        "taskDefinition": "arn:aws:ecs:us-east-1:123456789012:task-definition/TaskDefinition:N",
+                        "desiredCount": 3,
+                        "pendingCount": 0,
+                        "runningCount": 3,
+                        "failedTasks": 0,
+                        "createdAt": datetime.datetime(2022, 4, 15, 11, 0, 32, 707000, tzinfo=tzlocal()),
+                        "updatedAt": datetime.datetime.now(tz=tzlocal()) - datetime.timedelta(seconds=time_diff),
+                        "launchType": "EC2",
+                        "rolloutState": "COMPLETED",
+                        "rolloutStateReason": "ECS deployment ecs-svc/3665271676139587657 completed."
+                    }
+                ],
+                "events": [
+                    {
+                        "id": "b1dc4b42-5562-471e-93d0-e4683e18b5b3",
+                        "createdAt": datetime.datetime(2022, 4, 21, 4, 0, 17, 762000, tzinfo=tzlocal()),
+                        "message": "(service Task-Service) has reached a steady state."
+                    },
+                ],
+                "createdAt": datetime.datetime(2022, 4, 15, 11, 0, 32, 707000, tzinfo=tzlocal()),
+                "placementConstraints": [],
+                "placementStrategy": [],
+                "schedulingStrategy": "REPLICA",
+                "createdBy": "arn:aws:iam::123456789012:role/cdk-hnb659fds-cfn-exec-role-123456789012-us-east-1",
+                "enableECSManagedTags": False,
+                "propagateTags": "NONE",
+                "enableExecuteCommand": False
+            }
+        ],
+        "failures": [],
+        "ResponseMetadata": 
+        {
+            "RequestId": "fb847398-dba4-4df9-b3a2-5864b1937277",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "fb847398-dba4-4df9-b3a2-5864b1937277",
+                "content-type": "application/x-amz-json-1.1",
+                "content-length": "18494",
+                "date": "Thu, 21 Apr 2022 13:44:05 GMT"
+            },
+        "RetryAttempts": 0}
+    }
+
+@pytest.fixture
+def boto3_cw_alarm_response() -> Dict[str, Any]:
+    return {
+        "CompositeAlarms": [],
+        "MetricAlarms": [
+            {
+                "AlarmName": "Task-EcsScalingAlarm",
+                "AlarmArn": "arn:aws:cloudwatch:us-east-1:123456789012:alarm:Task-EcsScalingAlarm",
+                "AlarmConfigurationUpdatedTimestamp": datetime.datetime(2022,
+                    4,
+                    15,
+                    16,
+                    0,
+                    6,
+                    652000, tzinfo=tzutc()
+                ),
+            "ActionsEnabled": True,
+            "OKActions": [],
+            "AlarmActions": [],
+            "InsufficientDataActions": [],
+            "StateValue": "OK",
+            "StateReason": "Threshold Crossed: 1 datapoint [1.0 (18/04/22 14: 27: 00)] was not greater than or equal to the threshold (5.0).",
+            "StateReasonData": """
+            {
+                    "version": "1.0",
+                    "queryDate": "2022-04-18T14:32:36.846+0000",
+                    "startDate": "2022-04-18T14:27:00.000+0000",
+                    "statistic": "Maximum",
+                    "period": 300,
+                    "recentDatapoints": [
+                        1.0
+                    ],
+                    "threshold": 5.0,
+                    "evaluatedDatapoints": [
+                        {
+                            "timestamp": "2022-04-18T14:27:00.000+0000",
+                            "sampleCount": 5.0,
+                            "value": 1.0
+                        }
+                    ]
+                }""",
+            "StateUpdatedTimestamp": datetime.datetime(
+                2022,
+                4,
+                18,
+                14,
+                32,
+                36,
+                850000, tzinfo=tzutc()
+            ),
+            "MetricName": "ApproximateNumberOfMessagesVisible",
+            "Namespace": "AWS/SQS",
+            "Statistic": "Maximum",
+            "Dimensions": [
+                    {"Name": "QueueName", "Value": "ImageQueue"
+                    }
+                ],
+            "Period": 300,
+            "EvaluationPeriods": 1,
+            "Threshold": 5.0,
+            "ComparisonOperator": "GreaterThanOrEqualToThreshold"
+            }
+        ],
+        "ResponseMetadata": {
+            "RequestId": "4eaabf61-2dea-42d8-a3db-9d42df6d0b3d",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {"x-amzn-requestid": "4eaabf61-2dea-42d8-a3db-9d42df6d0b3d",
+            "content-type": "text/xml",
+            "content-length": "2109",
+            "date": "Thu,21 Apr 2022 13: 36: 56 GMT"
+        },
+        "RetryAttempts": 1
+        }
+    }
\ No newline at end of file
diff --git a/test/constructs/ecsIsoServiceAutoscaler/resources/test.sh b/test/constructs/ecsIsoServiceAutoscaler/resources/test.sh
new file mode 100755
index 0000000..4b39338
--- /dev/null
+++ b/test/constructs/ecsIsoServiceAutoscaler/resources/test.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+#---------------------------------------------------------------------------------------------------
+# exeuctes unit tests
+#
+# prepares a staging directory with the requirements
+set -e
+scriptdir=$(cd $(dirname $0) && pwd)
+
+rm -fr ${scriptdir}/__pycache__
+
+# prepare staging directory
+staging=$(mktemp -d)
+mkdir -p ${staging}
+cd ${staging}
+
+# copy src and overlay with test
+cp -f ${scriptdir}/../../../../resources/constructs/ecsIsoServiceAutoscaler/* $PWD
+cp -f ${scriptdir}/* $PWD
+
+# this will run our tests inside the right environment
+docker build .
\ No newline at end of file
diff --git a/test/constructs/ecsIsoServiceAutoscaler/resources/test_ecs_scaling_manager.py b/test/constructs/ecsIsoServiceAutoscaler/resources/test_ecs_scaling_manager.py
new file mode 100644
index 0000000..d2b9b6b
--- /dev/null
+++ b/test/constructs/ecsIsoServiceAutoscaler/resources/test_ecs_scaling_manager.py
@@ -0,0 +1,186 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+from typing import Any, Dict
+from unittest.mock import patch
+
+import pytest
+
+with patch("boto3.client"):
+    from ecs_scaling_manager import ( #type: ignore 
+        _get_alarm_states,
+        _get_ecs_service,
+        _get_time_since_last_ecs_update,
+        _trigger_scaling_action,
+    )
+
+
+def test_get_alarm_states(boto3_cw_alarm_response):
+    """
+    Tests getting alarm state data from a mocked boto3 response. The expected
+    response should be a list in the format of ["OK", "ALARM"].
+    """
+    with patch("ecs_scaling_manager.cw_client.describe_alarms") as mock:
+        mock.return_value = boto3_cw_alarm_response
+        alarm_states = _get_alarm_states(alarm_name="Alarm")
+
+    assert mock.called
+    assert isinstance(alarm_states, list)
+    assert "OK" in alarm_states
+    assert "ALARM" not in alarm_states
+
+
+def test_get_ecs_service(boto3_ecs_service_response):
+    """
+    Tests getting ECS Service data from a mocked boto3 response. The expected
+    response should be in a dictionary format {...} that contains the single
+    ECS service.
+    """
+    with patch("ecs_scaling_manager.ecs_client.describe_services") as mock:
+        mock.return_value = boto3_ecs_service_response
+        service = _get_ecs_service(
+            cluster_name="Cluster", service_name="Service"
+        )
+
+    assert mock.called
+    assert isinstance(service, dict)
+    assert "deployments" in service
+
+
+@pytest.mark.parametrize('boto3_ecs_service_response', [180], indirect=True)
+def test_get_time_since_last_ecs_update(boto3_ecs_service_response):
+    """
+    Tests getting the time since the ECS Service was last updated by pulling
+    the value out of the ECS Service response data, which is mocked.
+    """
+    with patch("ecs_scaling_manager.ecs_client.describe_services") as mock:
+        mock.return_value = boto3_ecs_service_response
+        service = _get_ecs_service(
+            cluster_name="Cluster", service_name="Service"
+        )
+
+        time_since = _get_time_since_last_ecs_update(service)
+
+    assert mock.called == True
+    assert isinstance(time_since, float)
+    assert int(time_since) > 178 and int(time_since) < 182
+
+
+def test_trigger_scaling_action_out_outside_cooldown():
+    """
+    Tests logic to perform an ECS Scaling OUT Action outside of the cooldown
+    window, which should correctly call the ECS Service to update the service.
+    """
+    with patch("ecs_scaling_manager.ecs_client.update_service") as mock:
+        _trigger_scaling_action(
+            type_="OUT",
+            increment=8,
+            current_count=3,
+            end_count=50,
+            cooldown=300,
+            last_update=301
+        )
+        call_args: Dict[str, Any] = mock.call_args[1]
+
+    assert mock.called == True
+    assert call_args.get("desiredCount") == 11
+
+
+def test_trigger_scaling_action_out_inside_cooldown():
+    """
+    Tests logic to perform an ECS Scaling OUT Action inside of the cooldown
+    window, which should not actually scale out since we are still in the
+    cooldown period.
+    """
+    with patch("ecs_scaling_manager.ecs_client.update_service") as mock:
+        _trigger_scaling_action(
+            type_="out",
+            increment=8,
+            current_count=3,
+            end_count=50,
+            cooldown=300,
+            last_update=250
+        )
+
+    assert mock.called == False
+
+
+def test_trigger_scaling_action_in_outside_cooldown():
+    """
+    Tests logic to perform an ECS Scaling IN Action outside of the cooldown
+    window, which should correctly call the ECS Service to update the service.
+    """
+    with patch("ecs_scaling_manager.ecs_client.update_service") as mock:
+        _trigger_scaling_action(
+            type_="IN",
+            increment=8,
+            current_count=20,
+            end_count=3,
+            cooldown=300,
+            last_update=301
+        )
+        call_args: Dict[str, Any] = mock.call_args[1]
+
+    assert mock.called == True
+    assert call_args.get("desiredCount") == 12
+
+
+def test_trigger_scaling_action_in_inside_cooldown():
+    """
+    Tests logic to perform an ECS Scaling IN Action inside of the cooldown
+    window, which should not actually scale in since we are still in the
+    cooldown period.
+    """
+    with patch("ecs_scaling_manager.ecs_client.update_service") as mock:
+        _trigger_scaling_action(
+            type_="in",
+            increment=8,
+            current_count=20,
+            end_count=3,
+            cooldown=300,
+            last_update=250
+        )
+
+    assert mock.called == False
+
+
+def test_trigger_scaling_action_in_to_limit():
+    """
+    Tests logic to scale in to the limit defined. The function should see that
+    the end count is at the limit and perform the correct math to only scale in
+    to the end_count defined and not beyond that.
+    """
+    with patch("ecs_scaling_manager.ecs_client.update_service") as mock:
+        _trigger_scaling_action(
+            type_="in",
+            increment=8,
+            current_count=8,
+            end_count=3,
+            cooldown=300,
+            last_update=301
+        )
+        call_args: Dict[str, Any] = mock.call_args[1]
+
+    assert mock.called == True
+    assert call_args.get("desiredCount") == 3
+
+
+def test_trigger_scaling_action_out_to_limit():
+    """
+    Tests logic to scale out to the limit defined. The function should see that
+    the end count is at the limit and perform the correct math to only scale
+    out to the end_count defined and not beyond that.
+    """
+    with patch("ecs_scaling_manager.ecs_client.update_service") as mock:
+        _trigger_scaling_action(
+            type_="Out",
+            increment=8,
+            current_count=45,
+            end_count=50,
+            cooldown=300,
+            last_update=301
+        )
+        call_args: Dict[str, Any] = mock.call_args[1]
+
+    assert mock.called == True
+    assert call_args.get("desiredCount") == 50
+