1import * as AWS from "@/AWS";
2import { ScalableTarget, ScalingPolicy } from "@/AWS/ApplicationAutoScaling";
3import { Cluster } from "@/AWS/ECS/Cluster.ts";
4import { Service } from "@/AWS/ECS/Service.ts";
5import * as Output from "@/Output";
6import * as Test from "@/Test/Alchemy";
7import * as aas from "@distilled.cloud/aws/application-auto-scaling";
8import * as ecs from "@distilled.cloud/aws/ecs";
9import { expect } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import * as Schedule from "effect/Schedule";
12import { reclaimTaskDefinitionFamily } from "../ECS/reclaimTaskDefinitionFamily.ts";
13
14const { test } = Test.make({ providers: AWS.providers() });
15
16const clusterName = "alchemy-test-aas-ecs";
17
18// The flagship ECS-service scaling flow: register the service's DesiredCount
19// dimension (min 1 / max 3), attach a CPU target-tracking policy, verify both
20// out-of-band, update the policy target in place, and destroy in dependency
21// order (policy -> target -> service -> cluster).
22//
23// To stay inside the speed budget we avoid building/pushing a Docker image
24// and register a minimal task definition against a public image out of band
25// (the same pattern as test/AWS/ECS/Service.test.ts). The Service provider
26// waits for the deployment to stabilize, so the task must actually be able
27// to start: run in the account's default VPC (public subnets +
28// `assignPublicIp`, the provider's fallback) so Fargate can pull the public
29// nginx image without a NAT.
30test.provider(
31 "ecs service DesiredCount target + CPU target tracking policy",
32 (stack) =>
33 Effect.gen(function* () {
34 yield* stack.destroy();
35
36 // Pre-clean: reclaim revisions a previously killed run may have left,
37 // then register this run's revision.
38 yield* reclaimTaskDefinitionFamily(clusterName);
39
40 const registered = yield* ecs.registerTaskDefinition({
41 family: "alchemy-test-aas-ecs",
42 networkMode: "awsvpc",
43 requiresCompatibilities: ["FARGATE"],
44 cpu: "256",
45 memory: "512",
46 containerDefinitions: [
47 {
48 name: "app",
49 image: "public.ecr.aws/nginx/nginx:stable",
50 essential: true,
51 portMappings: [{ containerPort: 80, protocol: "tcp" }],
52 },
53 ],
54 });
55 const taskDefinitionArn = registered.taskDefinition?.taskDefinitionArn!;
56 // Safety net: fully reclaim the out-of-band family on scope close even
57 // if the body fails — deregister + hard-delete so no INACTIVE revision
58 // survives the run.
59 yield* Effect.addFinalizer(() =>
60 reclaimTaskDefinitionFamily(clusterName).pipe(Effect.ignore),
61 );
62
63 const deploy = (targetValue: number) =>
64 stack.deploy(
65 Effect.gen(function* () {
66 const cluster = yield* Cluster("AasEcsCluster", { clusterName });
67 // No vpcId/subnets: fall back to the default VPC's public
68 // subnets with assignPublicIp so the task can pull its image.
69 const service = yield* Service("AasEcsService", {
70 cluster,
71 task: { taskDefinitionArn, containerName: "app", port: 80 },
72 desiredCount: 1,
73 });
74 const target = yield* ScalableTarget("AasEcsTarget", {
75 serviceNamespace: "ecs",
76 resourceId: Output.interpolate`service/${clusterName}/${service.serviceName}`,
77 scalableDimension: "ecs:service:DesiredCount",
78 minCapacity: 1,
79 maxCapacity: 3,
80 });
81 const policy = yield* ScalingPolicy("AasEcsCpuPolicy", {
82 serviceNamespace: target.serviceNamespace,
83 resourceId: target.resourceId,
84 scalableDimension: target.scalableDimension,
85 targetTracking: {
86 TargetValue: targetValue,
87 PredefinedMetricSpecification: {
88 PredefinedMetricType: "ECSServiceAverageCPUUtilization",
89 },
90 ScaleOutCooldown: 60,
91 ScaleInCooldown: 60,
92 },
93 });
94 // ECS also supports StepScaling — cover that policy branch here
95 // (DynamoDB targets reject it, so the DynamoDB suites cannot).
96 const stepPolicy = yield* ScalingPolicy("AasEcsStepPolicy", {
97 serviceNamespace: target.serviceNamespace,
98 resourceId: target.resourceId,
99 scalableDimension: target.scalableDimension,
100 stepScaling: {
101 AdjustmentType: "ChangeInCapacity",
102 Cooldown: 60,
103 MetricAggregationType: "Average",
104 StepAdjustments: [
105 { MetricIntervalLowerBound: 0, ScalingAdjustment: 1 },
106 ],
107 },
108 });
109 return {
110 serviceName: service.serviceName.as<string>(),
111 resourceId: target.resourceId.as<string>(),
112 scalableTargetArn: target.scalableTargetArn.as<string>(),
113 policyName: policy.policyName.as<string>(),
114 policyArn: policy.policyArn.as<string>(),
115 stepPolicyName: stepPolicy.policyName.as<string>(),
116 stepPolicyType: stepPolicy.policyType.as<string>(),
117 };
118 }),
119 );
120
121 const created = yield* deploy(50);
122 expect(created.resourceId).toEqual(
123 `service/${clusterName}/${created.serviceName}`,
124 );
125 expect(created.scalableTargetArn).toContain("scalable-target/");
126
127 // Out-of-band: the DesiredCount dimension is registered min 1 / max 3.
128 const targets = yield* aas.describeScalableTargets({
129 ServiceNamespace: "ecs",
130 ResourceIds: [created.resourceId],
131 ScalableDimension: "ecs:service:DesiredCount",
132 });
133 const observedTarget = targets.ScalableTargets?.[0];
134 expect(observedTarget?.MinCapacity).toBe(1);
135 expect(observedTarget?.MaxCapacity).toBe(3);
136
137 // Out-of-band: the tracking policy exists with its managed alarms.
138 const policies = yield* aas.describeScalingPolicies({
139 ServiceNamespace: "ecs",
140 PolicyNames: [created.policyName],
141 ResourceId: created.resourceId,
142 });
143 const observedPolicy = policies.ScalingPolicies?.[0];
144 expect(observedPolicy?.PolicyType).toBe("TargetTrackingScaling");
145 expect(
146 observedPolicy?.TargetTrackingScalingPolicyConfiguration?.TargetValue,
147 ).toBe(50);
148 expect((observedPolicy?.Alarms ?? []).length).toBeGreaterThan(0);
149
150 // Out-of-band: the step scaling policy exists with its adjustments.
151 expect(created.stepPolicyType).toBe("StepScaling");
152 const stepPolicies = yield* aas.describeScalingPolicies({
153 ServiceNamespace: "ecs",
154 PolicyNames: [created.stepPolicyName],
155 ResourceId: created.resourceId,
156 });
157 const observedStepPolicy = stepPolicies.ScalingPolicies?.[0];
158 expect(observedStepPolicy?.PolicyType).toBe("StepScaling");
159 expect(
160 observedStepPolicy?.StepScalingPolicyConfiguration?.StepAdjustments,
161 ).toEqual([{ MetricIntervalLowerBound: 0, ScalingAdjustment: 1 }]);
162
163 // Update the policy target in place — same policy ARN.
164 const updated = yield* deploy(30);
165 expect(updated.policyArn).toEqual(created.policyArn);
166 const policiesAfterUpdate = yield* aas.describeScalingPolicies({
167 ServiceNamespace: "ecs",
168 PolicyNames: [created.policyName],
169 ResourceId: created.resourceId,
170 });
171 expect(
172 policiesAfterUpdate.ScalingPolicies?.[0]
173 ?.TargetTrackingScalingPolicyConfiguration?.TargetValue,
174 ).toBe(30);
175
176 // Destroy in dependency order and verify deregistration out-of-band.
177 yield* stack.destroy();
178 const targetsAfterDestroy = yield* aas
179 .describeScalableTargets({
180 ServiceNamespace: "ecs",
181 ResourceIds: [created.resourceId],
182 ScalableDimension: "ecs:service:DesiredCount",
183 })
184 .pipe(
185 Effect.repeat({
186 schedule: Schedule.spaced("2 seconds"),
187 until: (res) => (res.ScalableTargets ?? []).length === 0,
188 times: 10,
189 }),
190 );
191 expect(targetsAfterDestroy.ScalableTargets ?? []).toHaveLength(0);
192 const policiesAfterDestroy = yield* aas.describeScalingPolicies({
193 ServiceNamespace: "ecs",
194 PolicyNames: [created.policyName],
195 ResourceId: created.resourceId,
196 });
197 expect(policiesAfterDestroy.ScalingPolicies ?? []).toHaveLength(0);
198
199 yield* reclaimTaskDefinitionFamily(clusterName);
200 }),
201 {
202 tags: [
203 "provider:aws",
204 "provider:aws:applicationautoscaling",
205 "provider:aws:ecs",
206 "live",
207 ],
208 timeout: 240_000,
209 },
210);