1import type * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";
3import type * as Stream from "effect/Stream";
4import {
5 consumeBusEvents,
6 type EventRecord,
7} from "../EventBridge/EventSource.ts";
8import type { AutoScalingGroup } from "./AutoScalingGroup.ts";
9import {
10 LifecycleHook,
11 normalizeTransition,
12 type LifecycleActionResult,
13 type LifecycleTransition,
14} from "./LifecycleHook.ts";
15
16/**
17 * The `detail` payload EC2 Auto Scaling delivers to EventBridge when an instance
18 * enters a lifecycle wait state.
19 */
20export interface LifecycleActionDetail {
21 /**
22 * Token identifying this specific lifecycle action; pass it to
23 * `complete`/`heartbeat` on the {@link CompleteLifecycleAction} binding.
24 */
25 LifecycleActionToken: string;
26 /**
27 * Name of the Auto Scaling Group the instance belongs to.
28 */
29 AutoScalingGroupName: string;
30 /**
31 * Name of the lifecycle hook that paused the instance.
32 */
33 LifecycleHookName: string;
34 /**
35 * ID of the EC2 instance in the wait state.
36 */
37 EC2InstanceId: string;
38 /**
39 * The paused transition (e.g. `autoscaling:EC2_INSTANCE_LAUNCHING`).
40 */
41 LifecycleTransition: string;
42 /**
43 * Metadata configured on the hook via `notificationMetadata`.
44 */
45 NotificationMetadata?: string;
46 /**
47 * Where the instance is coming from (e.g. `EC2` or `WarmPool`).
48 */
49 Origin?: string;
50 /**
51 * Where the instance is headed (e.g. `AutoScalingGroup` or `WarmPool`).
52 */
53 Destination?: string;
54}
55
56/** A lifecycle-action EventBridge event delivered to the handler. */
57export type LifecycleActionEvent = EventRecord<LifecycleActionDetail>;
58
59export interface LifecycleHookEventSourceProps {
60 /**
61 * Logical id prefix for the backing hook + EventBridge rule. Defaults to the
62 * Auto Scaling Group's logical id.
63 */
64 id?: string;
65 /**
66 * Physical name for the lifecycle hook. If omitted, a deterministic name is
67 * generated.
68 */
69 lifecycleHookName?: string;
70 /**
71 * Instance state transition to pause on.
72 */
73 lifecycleTransition: LifecycleTransition;
74 /**
75 * Maximum time an instance stays paused before `defaultResult` applies,
76 * e.g. `"5 minutes"` or `Duration.seconds(300)` (whole seconds on the
77 * wire).
78 * @default "1 hour"
79 */
80 heartbeatTimeout?: Duration.Input;
81 /**
82 * Action taken when the hook times out.
83 * @default "ABANDON"
84 */
85 defaultResult?: LifecycleActionResult;
86 /**
87 * Metadata delivered with the lifecycle event.
88 */
89 notificationMetadata?: string;
90}
91
92const detailTypeFor = (transition: string): string =>
93 transition === "autoscaling:EC2_INSTANCE_LAUNCHING"
94 ? "EC2 Instance-launch Lifecycle Action"
95 : "EC2 Instance-terminate Lifecycle Action";
96
97/**
98 * Pause an Auto Scaling instance transition and deliver the lifecycle event to
99 * the host Function. Creates the backing {@link LifecycleHook} on `group` (so
100 * instances actually pause) and subscribes the Function to the matching
101 * `aws.autoscaling` EventBridge events. Pair with
102 * {@link CompleteLifecycleAction} to signal `CONTINUE` / `ABANDON` from the
103 * handler.
104 *
105 * The EventBridge pattern matches every lifecycle action of this transition in
106 * the account; inspect `event.detail.AutoScalingGroupName` /
107 * `event.detail.LifecycleHookName` in the handler if multiple groups share the
108 * Function.
109 *
110 * ### Draining Instances
111 * **Example:** Signal CONTINUE when an instance is about to terminate
112 * ```typescript
113 * const lifecycle = yield* CompleteLifecycleAction(group);
114 * yield* consumeLifecycleActions(
115 * group,
116 * { lifecycleTransition: "TERMINATING", heartbeatTimeout: "300 seconds" },
117 * (events) =>
118 * Stream.runForEach(events, (event) =>
119 * lifecycle
120 * .complete({
121 * LifecycleHookName: event.detail.LifecycleHookName,
122 * LifecycleActionToken: event.detail.LifecycleActionToken,
123 * LifecycleActionResult: "CONTINUE",
124 * })
125 * .pipe(Effect.orDie),
126 * ),
127 * );
128 * ```
129 *
130 * **Example:** Register the event source inside a Lambda Function
131 * ```typescript
132 * import * as AWS from "alchemy/AWS";
133 * import {
134 * CompleteLifecycleAction,
135 * CompleteLifecycleActionHttp,
136 * consumeLifecycleActions,
137 * } from "alchemy/AWS/AutoScaling";
138 *
139 * export class DrainFunction extends AWS.Lambda.Function<AWS.Lambda.Function>()(
140 * "DrainFunction",
141 * ) {}
142 *
143 * export default DrainFunction.make(
144 * { main: import.meta.url },
145 * Effect.gen(function* () {
146 * const lifecycle = yield* CompleteLifecycleAction(group);
147 *
148 * // creates the LifecycleHook on the group and subscribes this Function
149 * // to the matching EventBridge lifecycle events
150 * yield* consumeLifecycleActions(
151 * group,
152 * { lifecycleTransition: "TERMINATING" },
153 * (events) =>
154 * Stream.runForEach(events, (event) =>
155 * lifecycle
156 * .complete({
157 * LifecycleHookName: event.detail.LifecycleHookName,
158 * LifecycleActionToken: event.detail.LifecycleActionToken,
159 * LifecycleActionResult: "CONTINUE",
160 * })
161 * .pipe(Effect.orDie),
162 * ),
163 * );
164 *
165 * return {};
166 * }).pipe(
167 * Effect.provide(
168 * Layer.mergeAll(AWS.Lambda.EventSource, CompleteLifecycleActionHttp),
169 * ),
170 * ),
171 * );
172 * ```
173 */
174export const consumeLifecycleActions = <StreamReq = never, Req = never>(
175 group: AutoScalingGroup,
176 props: LifecycleHookEventSourceProps,
177 process: (
178 events: Stream.Stream<LifecycleActionEvent, never, StreamReq>,
179 ) => Effect.Effect<void, never, Req>,
180) =>
181 Effect.gen(function* () {
182 const transition = normalizeTransition(props.lifecycleTransition);
183 const idPrefix = props.id ?? group.LogicalId;
184
185 // Create the hook so instances actually pause and emit lifecycle events.
186 const hook = yield* LifecycleHook(`${idPrefix}-LifecycleHook`, {
187 autoScalingGroup: group,
188 lifecycleHookName: props.lifecycleHookName,
189 lifecycleTransition: props.lifecycleTransition,
190 heartbeatTimeout: props.heartbeatTimeout,
191 defaultResult: props.defaultResult,
192 notificationMetadata: props.notificationMetadata,
193 });
194
195 // Subscribe the host Function to the matching lifecycle events. The pattern
196 // uses only literal `source` + `detail-type` so it round-trips through both
197 // the deploy-time rule and the runtime matcher (Output values cannot appear
198 // in the pattern — they don't resolve inside the deployed bundle).
199 yield* consumeBusEvents(
200 `${idPrefix}-LifecycleEvents`,
201 {
202 source: ["aws.autoscaling"],
203 "detail-type": [detailTypeFor(transition)],
204 },
205 process,
206 );
207
208 return hook;
209 });