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 { 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 LifecycleHookName = string;
15
16/**
17 * Lifecycle transition the hook fires on. The friendly `LAUNCHING` /
18 * `TERMINATING` aliases map to the `autoscaling:EC2_INSTANCE_*` transition
19 * strings the API expects.
20 */
21export type LifecycleTransition =
22 | "LAUNCHING"
23 | "TERMINATING"
24 | "autoscaling:EC2_INSTANCE_LAUNCHING"
25 | "autoscaling:EC2_INSTANCE_TERMINATING";
26
27export type LifecycleActionResult = "CONTINUE" | "ABANDON";
28
29export const normalizeTransition = (transition: LifecycleTransition): string =>
30 transition === "LAUNCHING"
31 ? "autoscaling:EC2_INSTANCE_LAUNCHING"
32 : transition === "TERMINATING"
33 ? "autoscaling:EC2_INSTANCE_TERMINATING"
34 : transition;
35
36export interface LifecycleHookProps {
37 /**
38 * Lifecycle hook name. If omitted, a deterministic name is generated.
39 */
40 lifecycleHookName?: string;
41 /**
42 * Auto Scaling Group to attach the hook to.
43 */
44 autoScalingGroup: Input<string> | AutoScalingGroupResource;
45 /**
46 * Instance state transition the hook pauses on. Use `LAUNCHING` to drain
47 * before an instance enters service, `TERMINATING` to drain before it is
48 * removed.
49 */
50 lifecycleTransition: LifecycleTransition;
51 /**
52 * Maximum time the instance stays in the wait state before the
53 * `defaultResult` is applied, e.g. `"5 minutes"` or
54 * `Duration.seconds(300)` (30 seconds–2 hours on the wire).
55 * @default "1 hour"
56 */
57 heartbeatTimeout?: Duration.Input;
58 /**
59 * Action taken when the hook times out or the ASG is otherwise unable to
60 * respond.
61 * @default "ABANDON"
62 */
63 defaultResult?: LifecycleActionResult;
64 /**
65 * ARN of an SNS topic or SQS queue to notify when the transition occurs.
66 * Omit to receive lifecycle events via EventBridge (the default target).
67 */
68 notificationTargetARN?: string;
69 /**
70 * ARN of the IAM role that permits the ASG to publish to
71 * `notificationTargetARN`. Required when `notificationTargetARN` is set.
72 */
73 roleARN?: string;
74 /**
75 * Arbitrary metadata delivered with the lifecycle notification.
76 */
77 notificationMetadata?: string;
78}
79
80export interface LifecycleHook extends Resource<
81 "AWS.AutoScaling.LifecycleHook",
82 LifecycleHookProps,
83 {
84 /**
85 * Name of the lifecycle hook.
86 */
87 lifecycleHookName: LifecycleHookName;
88 /**
89 * Name of the Auto Scaling Group the hook is attached to.
90 */
91 autoScalingGroupName: string;
92 /**
93 * The paused transition (e.g. `autoscaling:EC2_INSTANCE_LAUNCHING`).
94 */
95 lifecycleTransition: string;
96 /**
97 * Seconds an instance stays paused before `defaultResult` applies.
98 */
99 heartbeatTimeout: number;
100 /**
101 * Maximum total seconds an instance can remain in the wait state
102 * (100x heartbeat, capped by AWS).
103 */
104 globalTimeout: number;
105 /**
106 * Action taken when the hook times out (`CONTINUE` or `ABANDON`).
107 */
108 defaultResult: string;
109 /**
110 * SNS/SQS target lifecycle notifications are published to, if any.
111 */
112 notificationTargetARN?: string;
113 /**
114 * IAM role used to publish to the notification target, if any.
115 */
116 roleARN?: string;
117 /**
118 * Metadata delivered with each lifecycle event.
119 */
120 notificationMetadata?: string;
121 },
122 never,
123 Providers
124> {}
125
126/**
127 * A lifecycle hook that pauses an Auto Scaling instance in a wait state on
128 * launch or termination so a handler can drain connections, snapshot state, or
129 * warm caches before the transition completes. Pair with
130 * {@link consumeLifecycleActions} to receive the transition events and
131 * {@link CompleteLifecycleAction} to signal `CONTINUE` / `ABANDON`.
132 *
133 * ### Creating a Lifecycle Hook
134 * **Example:** Drain before termination (EventBridge target)
135 * ```typescript
136 * const hook = yield* LifecycleHook("Drain", {
137 * autoScalingGroup: group,
138 * lifecycleTransition: "TERMINATING",
139 * heartbeatTimeout: "300 seconds",
140 * defaultResult: "CONTINUE",
141 * });
142 * ```
143 *
144 * **Example:** Warm caches before an instance enters service
145 * ```typescript
146 * const hook = yield* LifecycleHook("Warm", {
147 * autoScalingGroup: group,
148 * lifecycleTransition: "LAUNCHING",
149 * heartbeatTimeout: "2 minutes",
150 * });
151 * ```
152 *
153 * @resource
154 */
155export const LifecycleHook = Resource<LifecycleHook>(
156 "AWS.AutoScaling.LifecycleHook",
157);
158
159// Derive the group name from either spelling of `autoScalingGroup`. A whole
160// AutoScalingGroup resource resolves to its bare Attributes before reaching the
161// provider — the resource `Type` marker does not survive resolution — so narrow
162// on the attributes shape, never on `Type`.
163const toAutoScalingGroupName = (
164 input: LifecycleHookProps["autoScalingGroup"] | undefined,
165): string | undefined =>
166 typeof input === "string"
167 ? input
168 : typeof (input as { autoScalingGroupName?: unknown } | undefined)
169 ?.autoScalingGroupName === "string"
170 ? (input as unknown as { autoScalingGroupName: string })
171 .autoScalingGroupName
172 : undefined;
173
174export const LifecycleHookProvider = () =>
175 Provider.effect(
176 LifecycleHook,
177 Effect.gen(function* () {
178 const toName = (
179 id: string,
180 props: { lifecycleHookName?: string } = {},
181 ) =>
182 props.lifecycleHookName
183 ? Effect.succeed(props.lifecycleHookName)
184 : createPhysicalName({ id, maxLength: 255, lowercase: true });
185
186 const describeHook = ({
187 autoScalingGroupName,
188 lifecycleHookName,
189 }: {
190 autoScalingGroupName: string;
191 lifecycleHookName: string;
192 }) =>
193 autoscaling
194 .describeLifecycleHooks({
195 AutoScalingGroupName: autoScalingGroupName,
196 LifecycleHookNames: [lifecycleHookName],
197 } as any)
198 .pipe(
199 Effect.map((result) => result.LifecycleHooks?.[0]),
200 // A deleted/absent Auto Scaling Group surfaces as
201 // `ValidationError: AutoScalingGroup ... not found` (typed
202 // `AutoScalingGroupNotFound` via the auto-scaling patch); treat it
203 // as "hook gone" so refresh/read converge instead of failing.
204 Effect.catchTag("AutoScalingGroupNotFound", () =>
205 Effect.succeed(undefined),
206 ),
207 );
208
209 const toAttributes = (
210 hook: autoscaling.LifecycleHook,
211 ): LifecycleHook["Attributes"] => ({
212 lifecycleHookName: hook.LifecycleHookName!,
213 autoScalingGroupName: hook.AutoScalingGroupName!,
214 lifecycleTransition: hook.LifecycleTransition!,
215 heartbeatTimeout: hook.HeartbeatTimeout ?? 0,
216 globalTimeout: hook.GlobalTimeout ?? 0,
217 defaultResult: hook.DefaultResult ?? "ABANDON",
218 notificationTargetARN: hook.NotificationTargetARN,
219 roleARN: hook.RoleARN,
220 notificationMetadata: hook.NotificationMetadata,
221 });
222
223 return {
224 stables: ["lifecycleHookName", "autoScalingGroupName"],
225 // A lifecycle hook is a sub-resource of its Auto Scaling Group;
226 // `describeLifecycleHooks` requires an AutoScalingGroupName and cannot
227 // enumerate account-wide, so there is no parent-free listing.
228 list: () => Effect.succeed([]),
229 diff: Effect.fn(function* ({ id, olds, news: _news }) {
230 if (!isResolved(_news)) return undefined;
231 const news = _news as typeof olds;
232 const oldName = yield* toName(id, olds ?? {});
233 const newName = yield* toName(id, news ?? {});
234 const oldGroup = toAutoScalingGroupName(olds.autoScalingGroup);
235 const newGroup = toAutoScalingGroupName(news.autoScalingGroup);
236 // Hook name or ASG change → replace (identity fields). Both sides must
237 // be known before forcing a replacement; a half-created row may have
238 // lost an Output-valued `autoScalingGroup`.
239 if (
240 oldName !== newName ||
241 (oldGroup !== undefined &&
242 newGroup !== undefined &&
243 oldGroup !== newGroup)
244 ) {
245 return { action: "replace", deleteFirst: true } as const;
246 }
247
248 if (!deepEqual(olds, news)) {
249 return {
250 action: "update",
251 stables: ["lifecycleHookName", "autoScalingGroupName"],
252 } as const;
253 }
254 }),
255 read: Effect.fn(function* ({ id, olds, output }) {
256 const autoScalingGroupName =
257 output?.autoScalingGroupName ??
258 toAutoScalingGroupName(olds?.autoScalingGroup);
259 const lifecycleHookName =
260 output?.lifecycleHookName ?? (yield* toName(id, olds ?? {}));
261 if (!autoScalingGroupName) return undefined;
262 const hook = yield* describeHook({
263 autoScalingGroupName,
264 lifecycleHookName,
265 });
266 return hook ? toAttributes(hook) : undefined;
267 }),
268 reconcile: Effect.fn(function* ({ id, news, output, session }) {
269 const autoScalingGroupName =
270 output?.autoScalingGroupName ??
271 toAutoScalingGroupName(news.autoScalingGroup);
272 if (!autoScalingGroupName) {
273 return yield* Effect.die(
274 new Error(
275 "LifecycleHook requires a resolvable autoScalingGroup name",
276 ),
277 );
278 }
279 const lifecycleHookName =
280 output?.lifecycleHookName ?? (yield* toName(id, news));
281
282 // Ensure + Sync — `putLifecycleHook` is the single create-or-update
283 // API. It overwrites the whole hook configuration, so we issue it
284 // unconditionally (idempotent on matching params).
285 yield* autoscaling.putLifecycleHook({
286 LifecycleHookName: lifecycleHookName,
287 AutoScalingGroupName: autoScalingGroupName,
288 LifecycleTransition: normalizeTransition(news.lifecycleTransition),
289 HeartbeatTimeout: toSeconds(news.heartbeatTimeout),
290 DefaultResult: news.defaultResult,
291 NotificationTargetARN: news.notificationTargetARN,
292 RoleARN: news.roleARN,
293 NotificationMetadata: news.notificationMetadata,
294 } as any);
295
296 const hook = yield* describeHook({
297 autoScalingGroupName,
298 lifecycleHookName,
299 }).pipe(
300 Effect.flatMap((hook) =>
301 hook
302 ? Effect.succeed(hook)
303 : Effect.fail(
304 new Error(
305 `Lifecycle hook '${lifecycleHookName}' was not readable after reconcile`,
306 ),
307 ),
308 ),
309 );
310 yield* session.note(lifecycleHookName);
311 return toAttributes(hook);
312 }),
313 delete: Effect.fn(function* ({ output }) {
314 // `deleteLifecycleHook` is idempotent — a missing hook returns
315 // success. Retry only the transient contention fault.
316 yield* autoscaling
317 .deleteLifecycleHook({
318 AutoScalingGroupName: output.autoScalingGroupName,
319 LifecycleHookName: output.lifecycleHookName,
320 } as any)
321 .pipe(
322 Effect.retry({
323 while: (error) => error._tag === "ResourceContentionFault",
324 schedule: Schedule.max([
325 Schedule.recurs(5),
326 Schedule.exponential("250 millis"),
327 ]),
328 }),
329 );
330 }),
331 };
332 }),
333 );