1import * as autoscaling from "@distilled.cloud/aws/auto-scaling";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { deepEqual, isResolved } from "../../Diff.ts";
7import type { Input } from "../../Input.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { toSeconds } from "../../Util/Duration.ts";
12import type { Providers } from "../Providers.ts";
13import { createInternalTags, diffTags } from "../../Tags.ts";
14import type { SubnetId } from "../EC2/Subnet.ts";
15import type {
16 LaunchTemplateId,
17 LaunchTemplateName,
18 LaunchTemplate as LaunchTemplateResource,
19} from "./LaunchTemplate.ts";
20
21export type AutoScalingGroupName = string;
22
23export interface LaunchTemplateReference {
24 /**
25 * ID of the launch template (`lt-...`). Provide either the ID or the name,
26 * not both.
27 */
28 launchTemplateId?: Input<LaunchTemplateId>;
29 /**
30 * Name of the launch template. Provide either the ID or the name, not both.
31 */
32 launchTemplateName?: Input<LaunchTemplateName>;
33 /**
34 * Template version to launch.
35 * @default "$Default"
36 */
37 version?: Input<string | number>;
38}
39
40export interface AutoScalingGroupProps {
41 /**
42 * Auto Scaling Group name. If omitted, a deterministic name is generated.
43 */
44 autoScalingGroupName?: string;
45 /**
46 * Launch template used for instances in the fleet.
47 */
48 launchTemplate: Input<LaunchTemplateReference> | LaunchTemplateResource;
49 /**
50 * Subnets to place the fleet into.
51 */
52 subnetIds: Input<SubnetId[]>;
53 /**
54 * Minimum number of instances.
55 */
56 minSize: number;
57 /**
58 * Maximum number of instances.
59 */
60 maxSize: number;
61 /**
62 * Desired number of instances.
63 * @default minSize
64 */
65 desiredCapacity?: number;
66 /**
67 * Target groups to attach to the fleet.
68 */
69 targetGroupArns?: Input<string[]>;
70 /**
71 * Health check type.
72 * @default "ELB" when target groups are present, otherwise "EC2"
73 */
74 healthCheckType?: "EC2" | "ELB";
75 /**
76 * Grace period before health checks start, e.g. `"5 minutes"` or
77 * `Duration.seconds(300)` (whole seconds on the wire).
78 */
79 healthCheckGracePeriod?: Duration.Input;
80 /**
81 * Default cooldown between scaling activities, e.g. `"5 minutes"` or
82 * `Duration.seconds(300)` (whole seconds on the wire).
83 */
84 defaultCooldown?: Duration.Input;
85 /**
86 * Termination policies for scale-in.
87 */
88 terminationPolicies?: string[];
89 /**
90 * Tags to apply to the Auto Scaling Group itself.
91 */
92 tags?: Record<string, string>;
93}
94
95export interface AutoScalingGroup extends Resource<
96 "AWS.AutoScaling.AutoScalingGroup",
97 AutoScalingGroupProps,
98 {
99 /**
100 * ARN of the Auto Scaling Group.
101 */
102 autoScalingGroupArn: string;
103 /**
104 * Name of the Auto Scaling Group.
105 */
106 autoScalingGroupName: AutoScalingGroupName;
107 /**
108 * ID of the launch template used by the fleet.
109 */
110 launchTemplateId?: string;
111 /**
112 * Name of the launch template used by the fleet.
113 */
114 launchTemplateName?: string;
115 /**
116 * Launch template version used by the fleet (e.g. `"$Default"` or a
117 * version number).
118 */
119 launchTemplateVersion?: string;
120 /**
121 * Subnets the fleet is placed into.
122 */
123 subnetIds: string[];
124 /**
125 * Minimum number of instances.
126 */
127 minSize: number;
128 /**
129 * Maximum number of instances.
130 */
131 maxSize: number;
132 /**
133 * Desired number of instances.
134 */
135 desiredCapacity: number;
136 /**
137 * Load balancer target groups the fleet is registered with.
138 */
139 targetGroupArns: string[];
140 /**
141 * Health check type (`EC2` or `ELB`).
142 */
143 healthCheckType?: string;
144 /**
145 * Grace period in seconds before health checks start.
146 */
147 healthCheckGracePeriod?: number;
148 /**
149 * Default cooldown in seconds between scaling activities.
150 */
151 defaultCooldown?: number;
152 /**
153 * Termination policies applied on scale-in.
154 */
155 terminationPolicies?: string[];
156 /**
157 * Tags on the Auto Scaling Group.
158 */
159 tags: Record<string, string>;
160 },
161 never,
162 Providers
163> {}
164
165/**
166 * An EC2 Auto Scaling Group that manages a fleet of instances from a launch
167 * template and can register that fleet with one or more load balancer target
168 * groups.
169 *
170 * Pair with {@link ScalingPolicy} for target-tracking scaling,
171 * {@link ScheduledAction} for time-based capacity changes, and
172 * {@link consumeLifecycleActions} to run a Lambda handler while instances
173 * pause during launch/terminate transitions.
174 * ### Creating an Auto Scaling Group
175 * **Example:** Fleet from a Launch Template
176 * ```typescript
177 * import { AutoScalingGroup, LaunchTemplate } from "alchemy/AWS/AutoScaling";
178 * import { Subnet, Vpc } from "alchemy/AWS/EC2";
179 *
180 * const vpc = yield* Vpc("Vpc", { cidrBlock: "10.0.0.0/16" });
181 * const subnet = yield* Subnet("Subnet", {
182 * vpcId: vpc.vpcId,
183 * cidrBlock: "10.0.1.0/24",
184 * });
185 *
186 * const template = yield* LaunchTemplate("Template", {
187 * imageId: "ami-0abcdef1234567890",
188 * instanceType: "t3.micro",
189 * });
190 *
191 * const group = yield* AutoScalingGroup("Fleet", {
192 * launchTemplate: template,
193 * subnetIds: [subnet.subnetId],
194 * minSize: 1,
195 * maxSize: 3,
196 * });
197 * ```
198 *
199 * **Example:** Reference an existing Launch Template by name
200 * ```typescript
201 * const group = yield* AutoScalingGroup("Fleet", {
202 * launchTemplate: { launchTemplateName: "my-template", version: 2 },
203 * subnetIds: [subnet.subnetId],
204 * minSize: 0,
205 * maxSize: 0,
206 * desiredCapacity: 0,
207 * });
208 * ```
209 *
210 * ### Load Balancing
211 * **Example:** Register the fleet with a target group
212 * ```typescript
213 * const group = yield* AutoScalingGroup("WebFleet", {
214 * launchTemplate: template,
215 * subnetIds: [subnetA.subnetId, subnetB.subnetId],
216 * minSize: 2,
217 * maxSize: 6,
218 * targetGroupArns: [targetGroup.targetGroupArn],
219 * // healthCheckType defaults to "ELB" when target groups are attached
220 * healthCheckGracePeriod: "5 minutes",
221 * });
222 * ```
223 *
224 * ### Scaling
225 * **Example:** Track average CPU utilization
226 * ```typescript
227 * import { ScalingPolicy } from "alchemy/AWS/AutoScaling";
228 *
229 * yield* ScalingPolicy("CpuPolicy", {
230 * autoScalingGroup: group,
231 * predefinedMetricType: "ASGAverageCPUUtilization",
232 * targetValue: 60,
233 * });
234 * ```
235 *
236 * @resource
237 */
238export const AutoScalingGroup = Resource<AutoScalingGroup>(
239 "AWS.AutoScaling.AutoScalingGroup",
240);
241
242const sortStrings = (values: readonly string[] = []) =>
243 [...values].sort((a, b) => a.localeCompare(b));
244
245export const AutoScalingGroupProvider = () =>
246 Provider.effect(
247 AutoScalingGroup,
248 Effect.gen(function* () {
249 const toName = (
250 id: string,
251 props: { autoScalingGroupName?: string } = {},
252 ) =>
253 props.autoScalingGroupName
254 ? Effect.succeed(props.autoScalingGroupName)
255 : createPhysicalName({ id, maxLength: 255, lowercase: true });
256
257 const toLaunchTemplateSpec = (
258 input: AutoScalingGroupProps["launchTemplate"],
259 ) => {
260 // A whole-resource `launchTemplate: template` resolves to the
261 // LaunchTemplate's bare Attributes before reaching the provider —
262 // the resource `Type` marker does not survive resolution — so narrow
263 // on the attributes shape (`launchTemplateArn` exists only on
264 // attributes, never on a LaunchTemplateReference). Attributes carry
265 // BOTH id and name, and the API rejects a spec with both, so send
266 // the id alone.
267 const attrs = input as
268 | Partial<LaunchTemplateResource["Attributes"]>
269 | undefined;
270 if (typeof attrs?.launchTemplateArn === "string") {
271 return {
272 LaunchTemplateId: attrs.launchTemplateId as string | undefined,
273 LaunchTemplateName: undefined,
274 Version:
275 attrs.defaultVersionNumber === undefined
276 ? "$Default"
277 : String(attrs.defaultVersionNumber),
278 };
279 }
280
281 const spec = (input ?? {}) as LaunchTemplateReference;
282 return {
283 LaunchTemplateId: spec.launchTemplateId as string | undefined,
284 LaunchTemplateName: spec.launchTemplateName as string | undefined,
285 Version:
286 spec.version === undefined ? "$Default" : String(spec.version),
287 };
288 };
289
290 const describeGroup = (autoScalingGroupName: string) =>
291 autoscaling
292 .describeAutoScalingGroups({
293 AutoScalingGroupNames: [autoScalingGroupName],
294 })
295 .pipe(Effect.map((result) => result.AutoScalingGroups?.[0]));
296
297 const toTags = (name: string, tags: Record<string, string>) =>
298 Object.entries(tags).map(([Key, Value]) => ({
299 ResourceId: name,
300 ResourceType: "auto-scaling-group",
301 Key,
302 Value,
303 PropagateAtLaunch: false,
304 }));
305
306 const syncTargetGroups = Effect.fn(function* ({
307 autoScalingGroupName,
308 oldTargetGroupArns,
309 newTargetGroupArns,
310 }: {
311 autoScalingGroupName: string;
312 oldTargetGroupArns: string[];
313 newTargetGroupArns: string[];
314 }) {
315 const oldSet = new Set(oldTargetGroupArns);
316 const newSet = new Set(newTargetGroupArns);
317
318 const detached = oldTargetGroupArns.filter((arn) => !newSet.has(arn));
319 const attached = newTargetGroupArns.filter((arn) => !oldSet.has(arn));
320
321 if (detached.length > 0) {
322 yield* autoscaling.detachLoadBalancerTargetGroups({
323 AutoScalingGroupName: autoScalingGroupName,
324 TargetGroupARNs: detached,
325 } as any);
326 }
327
328 if (attached.length > 0) {
329 yield* autoscaling.attachLoadBalancerTargetGroups({
330 AutoScalingGroupName: autoScalingGroupName,
331 TargetGroupARNs: attached,
332 } as any);
333 }
334 });
335
336 const syncTags = Effect.fn(function* ({
337 autoScalingGroupName,
338 oldTags,
339 newTags,
340 }: {
341 autoScalingGroupName: string;
342 oldTags: Record<string, string>;
343 newTags: Record<string, string>;
344 }) {
345 const { removed, upsert } = diffTags(oldTags, newTags);
346
347 if (removed.length > 0) {
348 yield* autoscaling.deleteTags({
349 Tags: removed.map((Key) => ({
350 ResourceId: autoScalingGroupName,
351 ResourceType: "auto-scaling-group",
352 Key,
353 })),
354 } as any);
355 }
356
357 if (upsert.length > 0) {
358 yield* autoscaling.createOrUpdateTags({
359 Tags: upsert.map(({ Key, Value }) => ({
360 ResourceId: autoScalingGroupName,
361 ResourceType: "auto-scaling-group",
362 Key,
363 Value,
364 PropagateAtLaunch: false,
365 })),
366 } as any);
367 }
368 });
369
370 const toAttributes = (
371 group: autoscaling.AutoScalingGroup,
372 ): AutoScalingGroup["Attributes"] => ({
373 autoScalingGroupArn: group.AutoScalingGroupARN!,
374 autoScalingGroupName: group.AutoScalingGroupName!,
375 launchTemplateId: group.LaunchTemplate?.LaunchTemplateId,
376 launchTemplateName: group.LaunchTemplate?.LaunchTemplateName,
377 launchTemplateVersion: group.LaunchTemplate?.Version,
378 subnetIds: String(group.VPCZoneIdentifier ?? "")
379 .split(",")
380 .filter(Boolean),
381 minSize: group.MinSize ?? 0,
382 maxSize: group.MaxSize ?? 0,
383 desiredCapacity: group.DesiredCapacity ?? 0,
384 targetGroupArns: sortStrings(group.TargetGroupARNs ?? []),
385 healthCheckType: group.HealthCheckType,
386 healthCheckGracePeriod: group.HealthCheckGracePeriod,
387 defaultCooldown: group.DefaultCooldown,
388 terminationPolicies: group.TerminationPolicies ?? [],
389 tags: Object.fromEntries(
390 (group.Tags ?? [])
391 .filter((tag): tag is { Key: string; Value: string } =>
392 Boolean(tag.Key && tag.Value !== undefined),
393 )
394 .map((tag) => [tag.Key, tag.Value]),
395 ),
396 });
397
398 return {
399 stables: ["autoScalingGroupArn", "autoScalingGroupName"],
400 list: () =>
401 // `describeAutoScalingGroups` is paginated; collect every page and
402 // flatten the `AutoScalingGroups` array into full `Attributes`.
403 autoscaling.describeAutoScalingGroups.pages({}).pipe(
404 Stream.runCollect,
405 Effect.map((chunk) =>
406 Array.from(chunk).flatMap((page) =>
407 (page.AutoScalingGroups ?? []).map(toAttributes),
408 ),
409 ),
410 ),
411 diff: Effect.fn(function* ({ id, olds, news: _news }) {
412 if (!isResolved(_news)) return undefined;
413 const news = _news as typeof olds;
414 const oldName = yield* toName(id, olds ?? {});
415 const newName = yield* toName(id, news ?? {});
416 if (oldName !== newName) {
417 return { action: "replace", deleteFirst: true } as const;
418 }
419
420 if (!deepEqual(olds, news)) {
421 return {
422 action: "update",
423 stables: ["autoScalingGroupArn", "autoScalingGroupName"],
424 } as const;
425 }
426 }),
427 read: Effect.fn(function* ({ id, olds, output }) {
428 const name =
429 output?.autoScalingGroupName ?? (yield* toName(id, olds ?? {}));
430 const group = yield* describeGroup(name);
431 return group ? toAttributes(group) : undefined;
432 }),
433 reconcile: Effect.fn(function* ({ id, news, output, session }) {
434 const autoScalingGroupName =
435 output?.autoScalingGroupName ?? (yield* toName(id, news));
436 const desiredTags = {
437 ...(yield* createInternalTags(id)),
438 ...news.tags,
439 };
440 const targetGroupArns = sortStrings(
441 (news.targetGroupArns ?? []) as string[],
442 );
443 const launchTemplate = toLaunchTemplateSpec(news.launchTemplate);
444 const healthCheckType =
445 news.healthCheckType ??
446 (targetGroupArns.length > 0 ? "ELB" : "EC2");
447
448 // Observe — fetch live state. `describeAutoScalingGroups` returns
449 // an empty list when the ASG is missing; we never trust `output`
450 // alone since the ASG may have been deleted out of band.
451 let existing = yield* describeGroup(autoScalingGroupName);
452
453 // Ensure — create the ASG if missing. `createAutoScalingGroup`
454 // raises `AlreadyExistsFault` on a race; we fall through to the
455 // sync path on that case.
456 if (!existing) {
457 yield* autoscaling
458 .createAutoScalingGroup({
459 AutoScalingGroupName: autoScalingGroupName,
460 MinSize: news.minSize,
461 MaxSize: news.maxSize,
462 DesiredCapacity: news.desiredCapacity ?? news.minSize,
463 LaunchTemplate: launchTemplate,
464 VPCZoneIdentifier: (news.subnetIds as string[]).join(","),
465 TargetGroupARNs: targetGroupArns,
466 HealthCheckType: healthCheckType,
467 HealthCheckGracePeriod: toSeconds(news.healthCheckGracePeriod),
468 DefaultCooldown: toSeconds(news.defaultCooldown),
469 TerminationPolicies: news.terminationPolicies,
470 Tags: toTags(autoScalingGroupName, desiredTags),
471 } as any)
472 .pipe(
473 Effect.catch((error: any) =>
474 error?._tag === "AlreadyExistsFault"
475 ? Effect.void
476 : Effect.fail(error),
477 ),
478 );
479
480 existing = yield* describeGroup(autoScalingGroupName).pipe(
481 Effect.filterOrFail(
482 Boolean,
483 () =>
484 new Error(
485 `Auto Scaling Group '${autoScalingGroupName}' was not readable after create`,
486 ),
487 ),
488 Effect.retry({
489 while: () => true,
490 schedule: Schedule.max([
491 Schedule.recurs(8),
492 Schedule.exponential("250 millis"),
493 ]),
494 }),
495 );
496 }
497
498 // Sync core ASG configuration — `updateAutoScalingGroup`
499 // overwrites min/max/desired/template/subnets/health-check
500 // settings in one call, so we issue it unconditionally
501 // (idempotent for matching values).
502 yield* autoscaling.updateAutoScalingGroup({
503 AutoScalingGroupName: autoScalingGroupName,
504 MinSize: news.minSize,
505 MaxSize: news.maxSize,
506 DesiredCapacity: news.desiredCapacity ?? news.minSize,
507 LaunchTemplate: launchTemplate,
508 VPCZoneIdentifier: (news.subnetIds as string[]).join(","),
509 HealthCheckType: healthCheckType,
510 HealthCheckGracePeriod: toSeconds(news.healthCheckGracePeriod),
511 DefaultCooldown: toSeconds(news.defaultCooldown),
512 TerminationPolicies: news.terminationPolicies,
513 } as any);
514
515 // Sync target groups — observed cloud attachments vs desired.
516 const observedAttrs = toAttributes(existing);
517 yield* syncTargetGroups({
518 autoScalingGroupName,
519 oldTargetGroupArns: sortStrings(existing.TargetGroupARNs ?? []),
520 newTargetGroupArns: targetGroupArns,
521 });
522
523 // Sync tags — observed cloud tags vs desired. Adoption brings
524 // tags through `existing.Tags`; we converge regardless of what
525 // was there before.
526 yield* syncTags({
527 autoScalingGroupName,
528 oldTags: observedAttrs.tags,
529 newTags: desiredTags,
530 });
531
532 // Re-read final state so attributes reflect post-sync cloud
533 // state.
534 const group = yield* describeGroup(autoScalingGroupName).pipe(
535 Effect.filterOrFail(
536 Boolean,
537 () =>
538 new Error(
539 `Auto Scaling Group '${autoScalingGroupName}' was not readable after reconcile`,
540 ),
541 ),
542 );
543 yield* session.note(autoScalingGroupName);
544 return toAttributes(group);
545 }),
546 delete: Effect.fn(function* ({ output }) {
547 const existing = yield* describeGroup(output.autoScalingGroupName);
548 if (!existing) {
549 return;
550 }
551
552 yield* autoscaling.deleteAutoScalingGroup({
553 AutoScalingGroupName: output.autoScalingGroupName,
554 ForceDelete: true,
555 } as any);
556
557 yield* describeGroup(output.autoScalingGroupName).pipe(
558 Effect.flatMap((group) =>
559 group
560 ? Effect.fail(new Error("AutoScalingGroupStillExists"))
561 : Effect.void,
562 ),
563 Effect.retry({
564 while: (error) =>
565 (error as Error).message === "AutoScalingGroupStillExists",
566 schedule: Schedule.max([
567 Schedule.recurs(12),
568 Schedule.exponential("250 millis"),
569 ]),
570 }),
571 );
572 }),
573 };
574 }),
575 );