1import * as aas from "@distilled.cloud/aws/application-auto-scaling";
2import * as cloudwatch from "@distilled.cloud/aws/cloudwatch";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import type { Providers } from "../Providers.ts";
11import { SERVICE_NAMESPACES, retryWhileTargetPropagates } from "./internal.ts";
12
13export interface ScalingPolicyProps {
14 /**
15 * Policy name. If omitted, a deterministic name is generated.
16 *
17 * Changing this triggers a replacement.
18 */
19 policyName?: string;
20 /**
21 * The namespace of the AWS service that provides the scalable target,
22 * e.g. `ecs`, `dynamodb`.
23 *
24 * Changing this triggers a replacement.
25 */
26 serviceNamespace: aas.ServiceNamespace;
27 /**
28 * The identifier of the resource the policy's scalable target scales,
29 * e.g. `service/{clusterName}/{serviceName}` or `table/{tableName}`.
30 *
31 * Changing this triggers a replacement.
32 */
33 resourceId: string;
34 /**
35 * The scalable dimension of the scalable target,
36 * e.g. `ecs:service:DesiredCount` or `dynamodb:table:ReadCapacityUnits`.
37 *
38 * Changing this triggers a replacement.
39 */
40 scalableDimension: aas.ScalableDimension;
41 /**
42 * Target tracking configuration — track a predefined metric
43 * (e.g. `ECSServiceAverageCPUUtilization`) or a customized CloudWatch
44 * metric specification against a target value.
45 *
46 * Exactly one of `targetTracking` or `stepScaling` must be provided.
47 */
48 targetTracking?: aas.TargetTrackingScalingPolicyConfiguration;
49 /**
50 * Step scaling configuration — apply step adjustments in response to a
51 * CloudWatch alarm you manage.
52 *
53 * Exactly one of `targetTracking` or `stepScaling` must be provided.
54 */
55 stepScaling?: aas.StepScalingPolicyConfiguration;
56}
57
58export interface ScalingPolicy extends Resource<
59 "AWS.ApplicationAutoScaling.ScalingPolicy",
60 ScalingPolicyProps,
61 {
62 /**
63 * Name of the scaling policy.
64 */
65 policyName: string;
66 /**
67 * ARN of the scaling policy.
68 */
69 policyArn: string;
70 /**
71 * Namespace of the AWS service that provides the scalable target.
72 */
73 serviceNamespace: aas.ServiceNamespace;
74 /**
75 * Identifier of the scaled resource.
76 */
77 resourceId: string;
78 /**
79 * Scalable dimension the policy applies to.
80 */
81 scalableDimension: aas.ScalableDimension;
82 /**
83 * Policy type (`TargetTrackingScaling` or `StepScaling`).
84 */
85 policyType: aas.PolicyType;
86 /**
87 * CloudWatch alarms created and managed by Application Auto Scaling for
88 * a target tracking policy.
89 */
90 alarms: { alarmName: string; alarmArn: string }[];
91 },
92 never,
93 Providers
94> {}
95
96/**
97 * An Application Auto Scaling scaling policy attached to a scalable target.
98 *
99 * Supports `TargetTrackingScaling` (Application Auto Scaling creates and
100 * manages the CloudWatch alarms) and `StepScaling` (you attach the policy to
101 * your own alarm). The policy applies to the scalable target identified by
102 * the (`serviceNamespace`, `resourceId`, `scalableDimension`) triple, which
103 * must be registered (see {@link ScalableTarget}) before the policy is
104 * created — pass the target's outputs so deployment orders correctly.
105 * ### Target Tracking
106 * **Example:** Track ECS Service CPU
107 * ```typescript
108 * const target = yield* ScalableTarget("ApiScaling", {
109 * serviceNamespace: "ecs",
110 * resourceId: Output.interpolate`service/${cluster.clusterName}/${service.serviceName}`,
111 * scalableDimension: "ecs:service:DesiredCount",
112 * minCapacity: 1,
113 * maxCapacity: 3,
114 * });
115 *
116 * yield* ScalingPolicy("ApiCpuPolicy", {
117 * serviceNamespace: target.serviceNamespace,
118 * resourceId: target.resourceId,
119 * scalableDimension: target.scalableDimension,
120 * targetTracking: {
121 * TargetValue: 60,
122 * PredefinedMetricSpecification: {
123 * PredefinedMetricType: "ECSServiceAverageCPUUtilization",
124 * },
125 * ScaleOutCooldown: 60,
126 * ScaleInCooldown: 60,
127 * },
128 * });
129 * ```
130 *
131 * **Example:** Track a Customized Metric
132 * ```typescript
133 * yield* ScalingPolicy("QueueDepthPolicy", {
134 * serviceNamespace: target.serviceNamespace,
135 * resourceId: target.resourceId,
136 * scalableDimension: target.scalableDimension,
137 * targetTracking: {
138 * TargetValue: 100,
139 * CustomizedMetricSpecification: {
140 * MetricName: "ApproximateNumberOfMessagesVisible",
141 * Namespace: "AWS/SQS",
142 * Dimensions: [{ Name: "QueueName", Value: "my-queue" }],
143 * Statistic: "Average",
144 * },
145 * },
146 * });
147 * ```
148 *
149 * ### Step Scaling
150 * **Example:** Step Adjustments
151 * ```typescript
152 * yield* ScalingPolicy("ApiStepPolicy", {
153 * serviceNamespace: target.serviceNamespace,
154 * resourceId: target.resourceId,
155 * scalableDimension: target.scalableDimension,
156 * stepScaling: {
157 * AdjustmentType: "ChangeInCapacity",
158 * Cooldown: 60,
159 * MetricAggregationType: "Average",
160 * StepAdjustments: [
161 * { MetricIntervalLowerBound: 0, ScalingAdjustment: 1 },
162 * ],
163 * },
164 * });
165 * ```
166 *
167 * @resource
168 */
169export const ScalingPolicy = Resource<ScalingPolicy>(
170 "AWS.ApplicationAutoScaling.ScalingPolicy",
171);
172
173/**
174 * Raised before any AWS call when a `ScalingPolicy` declares both or neither
175 * of `targetTracking` / `stepScaling` — the two configurations are mutually
176 * exclusive and exactly one is required.
177 */
178export class ScalingPolicyConfigurationConflict extends Data.TaggedError(
179 "ScalingPolicyConfigurationConflict",
180)<{ message: string }> {}
181
182const validateConfiguration = (props: {
183 targetTracking?: aas.TargetTrackingScalingPolicyConfiguration;
184 stepScaling?: aas.StepScalingPolicyConfiguration;
185}) =>
186 (props.targetTracking === undefined) === (props.stepScaling === undefined)
187 ? Effect.fail(
188 new ScalingPolicyConfigurationConflict({
189 message:
190 "Exactly one of targetTracking or stepScaling must be provided.",
191 }),
192 )
193 : Effect.void;
194
195export const ScalingPolicyProvider = () =>
196 Provider.effect(
197 ScalingPolicy,
198 Effect.gen(function* () {
199 const toName = (id: string, props: { policyName?: string } = {}) =>
200 props.policyName
201 ? Effect.succeed(props.policyName)
202 : createPhysicalName({ id, maxLength: 255 });
203
204 // Narrow by the target triple only when it is complete: the API rejects
205 // a ScalableDimension without a ResourceId ("Scalable dimension cannot
206 // be provided without a resource ID"), and a row persisted by an
207 // interrupted create can carry one without the other — which would
208 // wedge `read` and therefore every destroy of the stack.
209 const describe = (props: {
210 serviceNamespace: aas.ServiceNamespace;
211 policyName: string;
212 resourceId?: string;
213 scalableDimension?: aas.ScalableDimension;
214 }) =>
215 aas
216 .describeScalingPolicies({
217 ServiceNamespace: props.serviceNamespace,
218 PolicyNames: [props.policyName],
219 ...(props.resourceId !== undefined &&
220 props.scalableDimension !== undefined
221 ? {
222 ResourceId: props.resourceId,
223 ScalableDimension: props.scalableDimension,
224 }
225 : {}),
226 })
227 .pipe(
228 Effect.map((res) =>
229 res.ScalingPolicies?.find(
230 (p) => p.PolicyName === props.policyName,
231 ),
232 ),
233 );
234
235 const toAttributes = (
236 policy: aas.ScalingPolicy,
237 ): ScalingPolicy["Attributes"] => ({
238 policyName: policy.PolicyName,
239 policyArn: policy.PolicyARN,
240 serviceNamespace: policy.ServiceNamespace,
241 resourceId: policy.ResourceId,
242 scalableDimension: policy.ScalableDimension,
243 policyType: policy.PolicyType,
244 alarms: (policy.Alarms ?? []).map((alarm) => ({
245 alarmName: alarm.AlarmName,
246 alarmArn: alarm.AlarmARN,
247 })),
248 });
249
250 return {
251 stables: [
252 "policyName",
253 "policyArn",
254 "serviceNamespace",
255 "resourceId",
256 "scalableDimension",
257 ],
258
259 // Account/region-wide enumeration; the describe API requires a
260 // `ServiceNamespace` filter, so union the per-namespace pages.
261 list: () =>
262 Effect.forEach(
263 SERVICE_NAMESPACES,
264 (namespace) =>
265 aas.describeScalingPolicies
266 .pages({ ServiceNamespace: namespace })
267 .pipe(
268 Stream.runCollect,
269 Effect.map((chunk) =>
270 Array.from(chunk).flatMap((page) =>
271 (page.ScalingPolicies ?? []).map(toAttributes),
272 ),
273 ),
274 ),
275 { concurrency: 4 },
276 ).pipe(Effect.map((groups) => groups.flat())),
277
278 diff: Effect.fn(function* ({ id, news, olds }) {
279 if (!isResolved(news)) return undefined;
280 const oldName = yield* toName(id, olds ?? {});
281 const newName = yield* toName(id, news);
282 if (oldName !== newName) {
283 return { action: "replace" } as const;
284 }
285 // The target triple pins the policy to a scalable target — any
286 // change replaces the policy. Only compare sides that are known:
287 // a half-created state row may have lost Output-valued props.
288 for (const key of [
289 "serviceNamespace",
290 "resourceId",
291 "scalableDimension",
292 ] as const) {
293 const oldValue = olds?.[key];
294 if (
295 oldValue !== undefined &&
296 isResolved(oldValue) &&
297 oldValue !== news[key]
298 ) {
299 return { action: "replace" } as const;
300 }
301 }
302 }),
303
304 read: Effect.fn(function* ({ id, olds, output }) {
305 const serviceNamespace =
306 output?.serviceNamespace ?? olds?.serviceNamespace;
307 if (serviceNamespace === undefined) return undefined;
308 const policyName =
309 output?.policyName ?? (yield* toName(id, olds ?? {}));
310 const policy = yield* describe({
311 serviceNamespace,
312 policyName,
313 resourceId: output?.resourceId ?? olds?.resourceId,
314 scalableDimension:
315 output?.scalableDimension ?? olds?.scalableDimension,
316 });
317 return policy ? toAttributes(policy) : undefined;
318 }),
319
320 reconcile: Effect.fn(function* ({ id, news, session }) {
321 yield* validateConfiguration(news);
322 const policyName = yield* toName(id, news);
323 const policyType: aas.PolicyType = news.targetTracking
324 ? "TargetTrackingScaling"
325 : "StepScaling";
326
327 // Ensure + sync — `putScalingPolicy` is the single
328 // create-or-update API, keyed by (name, target triple); it
329 // overwrites the policy configuration on every call, so one
330 // unconditional put converges any starting state. A put issued
331 // immediately after the target registers can race its
332 // propagation — retry the typed ObjectNotFoundException briefly.
333 yield* retryWhileTargetPropagates(
334 aas.putScalingPolicy({
335 PolicyName: policyName,
336 ServiceNamespace: news.serviceNamespace,
337 ResourceId: news.resourceId,
338 ScalableDimension: news.scalableDimension,
339 PolicyType: policyType,
340 TargetTrackingScalingPolicyConfiguration: news.targetTracking,
341 StepScalingPolicyConfiguration: news.stepScaling,
342 }),
343 );
344
345 // Observe final state so attributes carry the generated ARN and
346 // the CloudWatch alarms Application Auto Scaling manages.
347 const policy = yield* describe({
348 serviceNamespace: news.serviceNamespace,
349 policyName,
350 resourceId: news.resourceId,
351 scalableDimension: news.scalableDimension,
352 });
353 if (policy === undefined) {
354 return yield* Effect.fail(
355 new aas.ObjectNotFoundException({
356 message: `Scaling policy '${policyName}' was not readable after PutScalingPolicy`,
357 }),
358 );
359 }
360
361 yield* session.note(policy.PolicyARN);
362 return toAttributes(policy);
363 }),
364
365 // Deleting the parent scalable target implicitly deletes its
366 // policies, so a policy that is already gone (typed
367 // `ObjectNotFoundException`) is success.
368 delete: Effect.fn(function* ({ olds, output }) {
369 // Observe before deleting: resolve the live policy by name (a row
370 // persisted by an interrupted create can lack the target triple)
371 // and delete with ITS triple; a policy that no longer exists is
372 // already gone.
373 const policy = yield* describe({
374 serviceNamespace: output.serviceNamespace,
375 policyName: output.policyName,
376 resourceId: output.resourceId ?? olds?.resourceId,
377 scalableDimension:
378 output.scalableDimension ?? olds?.scalableDimension,
379 });
380 if (policy?.ResourceId && policy.ScalableDimension) {
381 yield* aas
382 .deleteScalingPolicy({
383 PolicyName: output.policyName,
384 ServiceNamespace: output.serviceNamespace,
385 ResourceId: policy.ResourceId,
386 ScalableDimension: policy.ScalableDimension,
387 })
388 .pipe(
389 Effect.catchTag("ObjectNotFoundException", () => Effect.void),
390 );
391 }
392 // Reap the managed CloudWatch alarms of a target tracking policy.
393 // Application Auto Scaling deletes them asynchronously (and not at
394 // all when the policy went away via target deregistration), so an
395 // explicit delete keeps teardown deterministic. DeleteAlarms
396 // ignores names that no longer exist.
397 const alarms = [
398 ...(output.alarms ?? []).map((alarm) => alarm.alarmName),
399 ...(policy?.Alarms ?? []).map((alarm) => alarm.AlarmName),
400 ].filter((name, i, all) => all.indexOf(name) === i);
401 if (alarms.length > 0) {
402 yield* cloudwatch
403 .deleteAlarms({ AlarmNames: alarms })
404 .pipe(Effect.catchTag("ResourceNotFound", () => Effect.void));
405 }
406 }),
407 };
408 }),
409 );