1import * as autoscaling from "@distilled.cloud/aws/auto-scaling";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { deepEqual, isResolved } from "../../Diff.ts";
6import type { Input } from "../../Input.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { toSeconds } from "../../Util/Duration.ts";
11import type { Providers } from "../Providers.ts";
12import type { AutoScalingGroup as AutoScalingGroupResource } from "./AutoScalingGroup.ts";
13
14export type ScalingPolicyName = string;
15
16export interface ScalingPolicyProps {
17 /**
18 * Policy name. If omitted, a deterministic name is generated.
19 */
20 policyName?: string;
21 /**
22 * Auto Scaling Group to attach the policy to.
23 */
24 autoScalingGroup: Input<string> | AutoScalingGroupResource;
25 /**
26 * Policy type.
27 * @default "TargetTrackingScaling"
28 */
29 policyType?: "TargetTrackingScaling";
30 /**
31 * Predefined scaling metric to track.
32 */
33 predefinedMetricType:
34 | "ASGAverageCPUUtilization"
35 | "ASGAverageNetworkIn"
36 | "ASGAverageNetworkOut"
37 | "ALBRequestCountPerTarget";
38 /**
39 * Desired target value for the metric.
40 */
41 targetValue: number;
42 /**
43 * Disable scale-in while target tracking is active.
44 */
45 disableScaleIn?: boolean;
46 /**
47 * Estimated warmup time for new instances, e.g. `"5 minutes"` or
48 * `Duration.seconds(300)` (whole seconds on the wire).
49 */
50 estimatedInstanceWarmup?: Duration.Input;
51}
52
53export interface ScalingPolicy extends Resource<
54 "AWS.AutoScaling.ScalingPolicy",
55 ScalingPolicyProps,
56 {
57 /**
58 * ARN of the scaling policy.
59 */
60 policyArn: string;
61 /**
62 * Name of the scaling policy.
63 */
64 policyName: ScalingPolicyName;
65 /**
66 * Name of the Auto Scaling Group the policy is attached to.
67 */
68 autoScalingGroupName: string;
69 /**
70 * Policy type (e.g. `TargetTrackingScaling`).
71 */
72 policyType: string;
73 /**
74 * Target value the tracked metric is held at.
75 */
76 targetValue: number;
77 /**
78 * Predefined metric being tracked.
79 */
80 predefinedMetricType: string;
81 /**
82 * CloudWatch alarms created by EC2 Auto Scaling to drive the policy.
83 */
84 alarms: string[];
85 },
86 never,
87 Providers
88> {}
89
90/**
91 * A target-tracking scaling policy for an Auto Scaling Group. EC2 Auto
92 * Scaling creates and manages the CloudWatch alarms that keep the tracked
93 * metric at `targetValue` by adjusting the group's desired capacity.
94 * ### Creating a Scaling Policy
95 * **Example:** Track average CPU utilization
96 * ```typescript
97 * import { AutoScalingGroup, ScalingPolicy } from "alchemy/AWS/AutoScaling";
98 *
99 * const group = yield* AutoScalingGroup("Fleet", {
100 * launchTemplate: template,
101 * subnetIds: [subnet.subnetId],
102 * minSize: 1,
103 * maxSize: 4,
104 * });
105 *
106 * const policy = yield* ScalingPolicy("CpuPolicy", {
107 * autoScalingGroup: group,
108 * predefinedMetricType: "ASGAverageCPUUtilization",
109 * targetValue: 60,
110 * });
111 * ```
112 *
113 * **Example:** Scale on ALB requests per target without scale-in
114 * ```typescript
115 * const policy = yield* ScalingPolicy("RequestPolicy", {
116 * autoScalingGroup: group,
117 * predefinedMetricType: "ALBRequestCountPerTarget",
118 * targetValue: 1000,
119 * disableScaleIn: true,
120 * estimatedInstanceWarmup: "3 minutes",
121 * });
122 * ```
123 *
124 * @resource
125 */
126export const ScalingPolicy = Resource<ScalingPolicy>(
127 "AWS.AutoScaling.ScalingPolicy",
128);
129
130export const ScalingPolicyProvider = () =>
131 Provider.effect(
132 ScalingPolicy,
133 Effect.gen(function* () {
134 const toName = (id: string, props: { policyName?: string } = {}) =>
135 props.policyName
136 ? Effect.succeed(props.policyName)
137 : createPhysicalName({ id, maxLength: 255, lowercase: true });
138
139 // Derive the group name from either spelling of `autoScalingGroup`. A
140 // whole AutoScalingGroup resource resolves to its bare Attributes
141 // before reaching the provider — the resource `Type` marker does not
142 // survive resolution — so narrow on the attributes shape, never on
143 // `Type`. May also receive `undefined`: an Output-valued
144 // `autoScalingGroup` doesn't survive a `creating`-state round-trip
145 // (it deserializes as `undefined`), and recovery paths hand those
146 // props back as `olds`.
147 const toAutoScalingGroupName = (
148 input: ScalingPolicyProps["autoScalingGroup"] | undefined,
149 ): string | undefined =>
150 typeof input === "string"
151 ? input
152 : typeof (input as { autoScalingGroupName?: unknown } | undefined)
153 ?.autoScalingGroupName === "string"
154 ? (input as unknown as { autoScalingGroupName: string })
155 .autoScalingGroupName
156 : undefined;
157
158 // `describePolicies` searches account-wide when no AutoScalingGroupName
159 // is given; policy names are unique physical names, so a name-only
160 // lookup still identifies our policy during state recovery.
161 const describePolicy = ({
162 autoScalingGroupName,
163 policyName,
164 }: {
165 autoScalingGroupName: string | undefined;
166 policyName: string;
167 }) =>
168 autoscaling
169 .describePolicies({
170 AutoScalingGroupName: autoScalingGroupName,
171 PolicyNames: [policyName],
172 })
173 .pipe(Effect.map((result) => result.ScalingPolicies?.[0]));
174
175 const toAttributes = (
176 policy: autoscaling.ScalingPolicy,
177 ): ScalingPolicy["Attributes"] => ({
178 policyArn: policy.PolicyARN!,
179 policyName: policy.PolicyName!,
180 autoScalingGroupName: policy.AutoScalingGroupName!,
181 policyType: policy.PolicyType!,
182 targetValue:
183 policy.TargetTrackingConfiguration?.TargetValue ??
184 policy.StepAdjustments?.[0]?.MetricIntervalLowerBound ??
185 0,
186 predefinedMetricType:
187 policy.TargetTrackingConfiguration?.PredefinedMetricSpecification
188 ?.PredefinedMetricType ?? "",
189 alarms: (policy.Alarms ?? [])
190 .map((alarm) => alarm.AlarmName)
191 .filter((alarm): alarm is string => Boolean(alarm)),
192 });
193
194 return {
195 stables: ["policyArn", "policyName", "autoScalingGroupName"],
196 // `describePolicies` enumerates every scaling policy across all Auto
197 // Scaling Groups in the account/region when no AutoScalingGroupName
198 // filter is supplied, so no parent enumeration is needed.
199 list: () =>
200 autoscaling.describePolicies.pages({}).pipe(
201 Stream.runCollect,
202 Effect.map((chunk) =>
203 Array.from(chunk).flatMap((page) =>
204 (page.ScalingPolicies ?? []).map(toAttributes),
205 ),
206 ),
207 ),
208 diff: Effect.fn(function* ({ id, olds, news: _news }) {
209 if (!isResolved(_news)) return undefined;
210 const news = _news as typeof olds;
211 const oldName = yield* toName(id, olds ?? {});
212 const newName = yield* toName(id, news ?? {});
213 // ASG change → replace, but only when both sides are known — a
214 // half-created state row may have lost an Output-valued
215 // `autoScalingGroup`, and an unknown old ASG must fall through to
216 // the create/update recovery path rather than force a replacement.
217 const oldGroupName = toAutoScalingGroupName(olds.autoScalingGroup);
218 const newGroupName = toAutoScalingGroupName(news.autoScalingGroup);
219 if (
220 oldName !== newName ||
221 (oldGroupName !== undefined &&
222 newGroupName !== undefined &&
223 oldGroupName !== newGroupName)
224 ) {
225 return { action: "replace" } as const;
226 }
227
228 if (!deepEqual(olds, news)) {
229 return {
230 action: "update",
231 stables: ["policyArn", "policyName", "autoScalingGroupName"],
232 } as const;
233 }
234 }),
235 read: Effect.fn(function* ({ id, olds, output }) {
236 const autoScalingGroupName =
237 output?.autoScalingGroupName ??
238 toAutoScalingGroupName(olds?.autoScalingGroup);
239 const policyName =
240 output?.policyName ?? (yield* toName(id, olds ?? {}));
241 const policy = yield* describePolicy({
242 autoScalingGroupName,
243 policyName,
244 });
245 return policy ? toAttributes(policy) : undefined;
246 }),
247 reconcile: Effect.fn(function* ({ id, news, output, session }) {
248 const autoScalingGroupName =
249 output?.autoScalingGroupName ??
250 toAutoScalingGroupName(news.autoScalingGroup);
251 const policyName = output?.policyName ?? (yield* toName(id, news));
252
253 // Ensure + Sync — `putScalingPolicy` is the single
254 // create-or-update API for a scaling policy. It's idempotent on
255 // matching params and overwrites policy type / target tracking
256 // config / estimated warmup on differences, so we issue it
257 // unconditionally.
258 yield* autoscaling.putScalingPolicy({
259 AutoScalingGroupName: autoScalingGroupName,
260 PolicyName: policyName,
261 PolicyType: news.policyType ?? "TargetTrackingScaling",
262 TargetTrackingConfiguration: {
263 PredefinedMetricSpecification: {
264 PredefinedMetricType: news.predefinedMetricType,
265 },
266 TargetValue: news.targetValue,
267 DisableScaleIn: news.disableScaleIn,
268 },
269 EstimatedInstanceWarmup: toSeconds(news.estimatedInstanceWarmup),
270 } as any);
271
272 // Observe final state — re-read so the returned attributes
273 // reflect the live cloud state (including the generated
274 // policyArn and any associated alarm names).
275 const policy = yield* describePolicy({
276 autoScalingGroupName,
277 policyName,
278 }).pipe(
279 Effect.flatMap((policy) =>
280 policy
281 ? Effect.succeed(policy)
282 : Effect.fail(
283 new Error(
284 `Scaling policy '${policyName}' was not readable after reconcile`,
285 ),
286 ),
287 ),
288 );
289 yield* session.note(policyName);
290 return toAttributes(policy);
291 }),
292 delete: Effect.fn(function* ({ output }) {
293 yield* autoscaling.deletePolicy({
294 AutoScalingGroupName: output.autoScalingGroupName,
295 PolicyName: output.policyName,
296 } as any);
297 }),
298 };
299 }),
300 );