Repository: getmoto/moto

Issue:
Implement scheduled actions for application autoscaling
Scheduled actions is not yet implemented for the application autoscaling endpoint. I am currently working to add this functionality; this issue is meant to track that work.


Discussion:

Resolving patch:
diff --git a/moto/applicationautoscaling/models.py b/moto/applicationautoscaling/models.py
--- a/moto/applicationautoscaling/models.py
+++ b/moto/applicationautoscaling/models.py
@@ -1,4 +1,4 @@
-from moto.core import BaseBackend, BaseModel
+from moto.core import ACCOUNT_ID, BaseBackend, BaseModel
 from moto.core.utils import BackendDict
 from moto.ecs import ecs_backends
 from .exceptions import AWSValidationException
@@ -65,6 +65,7 @@ def __init__(self, region):
         self.ecs_backend = ecs_backends[region]
         self.targets = OrderedDict()
         self.policies = {}
+        self.scheduled_actions = list()
 
     def reset(self):
         region = self.region
@@ -229,6 +230,87 @@ def delete_scaling_policy(
                 )
             )
 
+    def delete_scheduled_action(
+        self, service_namespace, scheduled_action_name, resource_id, scalable_dimension
+    ):
+        self.scheduled_actions = [
+            a
+            for a in self.scheduled_actions
+            if not (
+                a.service_namespace == service_namespace
+                and a.scheduled_action_name == scheduled_action_name
+                and a.resource_id == resource_id
+                and a.scalable_dimension == scalable_dimension
+            )
+        ]
+
+    def describe_scheduled_actions(
+        self, scheduled_action_names, service_namespace, resource_id, scalable_dimension
+    ):
+        """
+        Pagination is not yet implemented
+        """
+        result = [
+            a
+            for a in self.scheduled_actions
+            if a.service_namespace == service_namespace
+        ]
+        if scheduled_action_names:
+            result = [
+                a for a in result if a.scheduled_action_name in scheduled_action_names
+            ]
+        if resource_id:
+            result = [a for a in result if a.resource_id == resource_id]
+        if scalable_dimension:
+            result = [a for a in result if a.scalable_dimension == scalable_dimension]
+        return result
+
+    def put_scheduled_action(
+        self,
+        service_namespace,
+        schedule,
+        timezone,
+        scheduled_action_name,
+        resource_id,
+        scalable_dimension,
+        start_time,
+        end_time,
+        scalable_target_action,
+    ):
+        existing_action = next(
+            (
+                a
+                for a in self.scheduled_actions
+                if a.service_namespace == service_namespace
+                and a.resource_id == resource_id
+                and a.scalable_dimension == scalable_dimension
+            ),
+            None,
+        )
+        if existing_action:
+            existing_action.update(
+                schedule,
+                timezone,
+                scheduled_action_name,
+                start_time,
+                end_time,
+                scalable_target_action,
+            )
+        else:
+            action = FakeScheduledAction(
+                service_namespace,
+                schedule,
+                timezone,
+                scheduled_action_name,
+                resource_id,
+                scalable_dimension,
+                start_time,
+                end_time,
+                scalable_target_action,
+                self.region,
+            )
+            self.scheduled_actions.append(action)
+
 
 def _target_params_are_valid(namespace, r_id, dimension):
     """Check whether namespace, resource_id and dimension are valid and consistent with each other."""
@@ -354,4 +436,51 @@ def formulate_key(service_namespace, resource_id, scalable_dimension, policy_nam
         )
 
 
+class FakeScheduledAction(BaseModel):
+    def __init__(
+        self,
+        service_namespace,
+        schedule,
+        timezone,
+        scheduled_action_name,
+        resource_id,
+        scalable_dimension,
+        start_time,
+        end_time,
+        scalable_target_action,
+        region,
+    ):
+        self.arn = f"arn:aws:autoscaling:{region}:{ACCOUNT_ID}:scheduledAction:{service_namespace}:scheduledActionName/{scheduled_action_name}"
+        self.service_namespace = service_namespace
+        self.schedule = schedule
+        self.timezone = timezone
+        self.scheduled_action_name = scheduled_action_name
+        self.resource_id = resource_id
+        self.scalable_dimension = scalable_dimension
+        self.start_time = start_time
+        self.end_time = end_time
+        self.scalable_target_action = scalable_target_action
+        self.creation_time = time.time()
+
+    def update(
+        self,
+        schedule,
+        timezone,
+        scheduled_action_name,
+        start_time,
+        end_time,
+        scalable_target_action,
+    ):
+        if scheduled_action_name:
+            self.scheduled_action_name = scheduled_action_name
+        if schedule:
+            self.schedule = schedule
+        if timezone:
+            self.timezone = timezone
+        if scalable_target_action:
+            self.scalable_target_action = scalable_target_action
+        self.start_time = start_time
+        self.end_time = end_time
+
+
 applicationautoscaling_backends = BackendDict(ApplicationAutoscalingBackend, "ec2")
diff --git a/moto/applicationautoscaling/responses.py b/moto/applicationautoscaling/responses.py
--- a/moto/applicationautoscaling/responses.py
+++ b/moto/applicationautoscaling/responses.py
@@ -127,6 +127,63 @@ def _validate_params(self):
         if message:
             raise AWSValidationException(message)
 
+    def delete_scheduled_action(self):
+        params = json.loads(self.body)
+        service_namespace = params.get("ServiceNamespace")
+        scheduled_action_name = params.get("ScheduledActionName")
+        resource_id = params.get("ResourceId")
+        scalable_dimension = params.get("ScalableDimension")
+        self.applicationautoscaling_backend.delete_scheduled_action(
+            service_namespace=service_namespace,
+            scheduled_action_name=scheduled_action_name,
+            resource_id=resource_id,
+            scalable_dimension=scalable_dimension,
+        )
+        return json.dumps(dict())
+
+    def put_scheduled_action(self):
+        params = json.loads(self.body)
+        service_namespace = params.get("ServiceNamespace")
+        schedule = params.get("Schedule")
+        timezone = params.get("Timezone")
+        scheduled_action_name = params.get("ScheduledActionName")
+        resource_id = params.get("ResourceId")
+        scalable_dimension = params.get("ScalableDimension")
+        start_time = params.get("StartTime")
+        end_time = params.get("EndTime")
+        scalable_target_action = params.get("ScalableTargetAction")
+        self.applicationautoscaling_backend.put_scheduled_action(
+            service_namespace=service_namespace,
+            schedule=schedule,
+            timezone=timezone,
+            scheduled_action_name=scheduled_action_name,
+            resource_id=resource_id,
+            scalable_dimension=scalable_dimension,
+            start_time=start_time,
+            end_time=end_time,
+            scalable_target_action=scalable_target_action,
+        )
+        return json.dumps(dict())
+
+    def describe_scheduled_actions(self):
+        params = json.loads(self.body)
+        scheduled_action_names = params.get("ScheduledActionNames")
+        service_namespace = params.get("ServiceNamespace")
+        resource_id = params.get("ResourceId")
+        scalable_dimension = params.get("ScalableDimension")
+        scheduled_actions = (
+            self.applicationautoscaling_backend.describe_scheduled_actions(
+                scheduled_action_names=scheduled_action_names,
+                service_namespace=service_namespace,
+                resource_id=resource_id,
+                scalable_dimension=scalable_dimension,
+            )
+        )
+        response_obj = {
+            "ScheduledActions": [_build_scheduled_action(a) for a in scheduled_actions]
+        }
+        return json.dumps(response_obj)
+
 
 def _build_target(t):
     return {
@@ -158,3 +215,20 @@ def _build_policy(p):
             "TargetTrackingScalingPolicyConfiguration"
         ] = p.target_tracking_scaling_policy_configuration
     return response
+
+
+def _build_scheduled_action(a):
+    response = {
+        "ScheduledActionName": a.scheduled_action_name,
+        "ScheduledActionARN": a.arn,
+        "ServiceNamespace": a.service_namespace,
+        "Schedule": a.schedule,
+        "Timezone": a.timezone,
+        "ResourceId": a.resource_id,
+        "ScalableDimension": a.scalable_dimension,
+        "StartTime": a.start_time,
+        "EndTime": a.end_time,
+        "CreationTime": a.creation_time,
+        "ScalableTargetAction": a.scalable_target_action,
+    }
+    return response


Test patch:
diff --git a/tests/test_applicationautoscaling/test_applicationautoscaling.py b/tests/test_applicationautoscaling/test_applicationautoscaling.py
--- a/tests/test_applicationautoscaling/test_applicationautoscaling.py
+++ b/tests/test_applicationautoscaling/test_applicationautoscaling.py
@@ -2,6 +2,7 @@
 import pytest
 import sure  # noqa # pylint: disable=unused-import
 from moto import mock_applicationautoscaling, mock_ecs
+from moto.core import ACCOUNT_ID
 
 DEFAULT_REGION = "us-east-1"
 DEFAULT_ECS_CLUSTER = "default"
@@ -362,7 +363,7 @@ def test_put_scaling_policy(policy_type, policy_body_kwargs):
             ResourceId=resource_id,
             ScalableDimension=scalable_dimension,
             PolicyType="ABCDEFG",
-            **policy_body_kwargs
+            **policy_body_kwargs,
         )
     e.value.response["Error"]["Message"].should.match(
         r"Unknown policy type .* specified."
@@ -374,7 +375,7 @@ def test_put_scaling_policy(policy_type, policy_body_kwargs):
         ResourceId=resource_id,
         ScalableDimension=scalable_dimension,
         PolicyType=policy_type,
-        **policy_body_kwargs
+        **policy_body_kwargs,
     )
     response["ResponseMetadata"]["HTTPStatusCode"].should.equal(200)
     response["PolicyARN"].should.match(
@@ -535,3 +536,139 @@ def test_deregister_scalable_target():
             ScalableDimension=scalable_dimension,
         )
     e.value.response["Error"]["Message"].should.match(r"No scalable target found .*")
+
+
+@mock_applicationautoscaling
+def test_delete_scheduled_action():
+    client = boto3.client("application-autoscaling", region_name="eu-west-1")
+    resp = client.describe_scheduled_actions(ServiceNamespace="ecs")
+    resp.should.have.key("ScheduledActions").length_of(0)
+
+    for i in range(3):
+        client.put_scheduled_action(
+            ServiceNamespace="ecs",
+            ScheduledActionName=f"ecs_action_{i}",
+            ResourceId=f"ecs:cluster:{i}",
+            ScalableDimension="ecs:service:DesiredCount",
+        )
+
+    resp = client.describe_scheduled_actions(ServiceNamespace="ecs")
+    resp.should.have.key("ScheduledActions").length_of(3)
+
+    client.delete_scheduled_action(
+        ServiceNamespace="ecs",
+        ScheduledActionName="ecs_action_0",
+        ResourceId="ecs:cluster:0",
+        ScalableDimension="ecs:service:DesiredCount",
+    )
+
+    resp = client.describe_scheduled_actions(ServiceNamespace="ecs")
+    resp.should.have.key("ScheduledActions").length_of(2)
+
+
+@mock_applicationautoscaling
+def test_describe_scheduled_actions():
+    client = boto3.client("application-autoscaling", region_name="eu-west-1")
+    resp = client.describe_scheduled_actions(ServiceNamespace="ecs")
+    resp.should.have.key("ScheduledActions").length_of(0)
+
+    for i in range(3):
+        client.put_scheduled_action(
+            ServiceNamespace="ecs",
+            ScheduledActionName=f"ecs_action_{i}",
+            ResourceId=f"ecs:cluster:{i}",
+            ScalableDimension="ecs:service:DesiredCount",
+        )
+        client.put_scheduled_action(
+            ServiceNamespace="dynamodb",
+            ScheduledActionName=f"ddb_action_{i}",
+            ResourceId=f"table/table_{i}",
+            ScalableDimension="dynamodb:table:ReadCapacityUnits",
+        )
+
+    resp = client.describe_scheduled_actions(ServiceNamespace="ecs")
+    resp.should.have.key("ScheduledActions").length_of(3)
+
+    resp = client.describe_scheduled_actions(ServiceNamespace="ec2")
+    resp.should.have.key("ScheduledActions").length_of(0)
+
+    resp = client.describe_scheduled_actions(
+        ServiceNamespace="dynamodb", ScheduledActionNames=["ddb_action_0"]
+    )
+    resp.should.have.key("ScheduledActions").length_of(1)
+
+    resp = client.describe_scheduled_actions(
+        ServiceNamespace="dynamodb",
+        ResourceId="table/table_0",
+        ScalableDimension="dynamodb:table:ReadCapacityUnits",
+    )
+    resp.should.have.key("ScheduledActions").length_of(1)
+
+    resp = client.describe_scheduled_actions(
+        ServiceNamespace="dynamodb",
+        ResourceId="table/table_0",
+        ScalableDimension="dynamodb:table:WriteCapacityUnits",
+    )
+    resp.should.have.key("ScheduledActions").length_of(0)
+
+
+@mock_applicationautoscaling
+def test_put_scheduled_action():
+    client = boto3.client("application-autoscaling", region_name="ap-southeast-1")
+    client.put_scheduled_action(
+        ServiceNamespace="ecs",
+        ScheduledActionName="action_name",
+        ResourceId="ecs:cluster:x",
+        ScalableDimension="ecs:service:DesiredCount",
+    )
+
+    resp = client.describe_scheduled_actions(ServiceNamespace="ecs")
+    resp.should.have.key("ScheduledActions").length_of(1)
+
+    action = resp["ScheduledActions"][0]
+    action.should.have.key("ScheduledActionName").equals("action_name")
+    action.should.have.key("ScheduledActionARN").equals(
+        f"arn:aws:autoscaling:ap-southeast-1:{ACCOUNT_ID}:scheduledAction:ecs:scheduledActionName/action_name"
+    )
+    action.should.have.key("ServiceNamespace").equals("ecs")
+    action.should.have.key("ResourceId").equals("ecs:cluster:x")
+    action.should.have.key("ScalableDimension").equals("ecs:service:DesiredCount")
+    action.should.have.key("CreationTime")
+    action.shouldnt.have.key("ScalableTargetAction")
+
+
+@mock_applicationautoscaling
+def test_put_scheduled_action__use_update():
+    client = boto3.client("application-autoscaling", region_name="ap-southeast-1")
+    client.put_scheduled_action(
+        ServiceNamespace="ecs",
+        ScheduledActionName="action_name",
+        ResourceId="ecs:cluster:x",
+        ScalableDimension="ecs:service:DesiredCount",
+    )
+    client.put_scheduled_action(
+        ServiceNamespace="ecs",
+        ScheduledActionName="action_name_updated",
+        ResourceId="ecs:cluster:x",
+        ScalableDimension="ecs:service:DesiredCount",
+        ScalableTargetAction={
+            "MinCapacity": 12,
+            "MaxCapacity": 23,
+        },
+    )
+
+    resp = client.describe_scheduled_actions(ServiceNamespace="ecs")
+    resp.should.have.key("ScheduledActions").length_of(1)
+
+    action = resp["ScheduledActions"][0]
+    action.should.have.key("ScheduledActionName").equals("action_name_updated")
+    action.should.have.key("ScheduledActionARN").equals(
+        f"arn:aws:autoscaling:ap-southeast-1:{ACCOUNT_ID}:scheduledAction:ecs:scheduledActionName/action_name"
+    )
+    action.should.have.key("ServiceNamespace").equals("ecs")
+    action.should.have.key("ResourceId").equals("ecs:cluster:x")
+    action.should.have.key("ScalableDimension").equals("ecs:service:DesiredCount")
+    action.should.have.key("CreationTime")
+    action.should.have.key("ScalableTargetAction").equals(
+        {"MaxCapacity": 23, "MinCapacity": 12}
+    )