1import * as AWS from "@/AWS";
2import {
3 AutoScalingGroup,
4 CompleteLifecycleAction,
5 CompleteLifecycleActionHttp,
6 consumeLifecycleActions,
7 LaunchTemplate,
8} from "@/AWS/AutoScaling";
9import { amazonLinux2023 } from "@/AWS/EC2";
10import * as Output from "@/Output";
11import * as Context from "effect/Context";
12import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Stream from "effect/Stream";
16import { HttpServerRequest } from "effect/http/HttpServerRequest";
17import * as HttpServerResponse from "effect/http/HttpServerResponse";
18import { getAutoScalingTestSubnetId } from "../TestNetwork.ts";
19
20export const lifecycleFleetAsgName = "alchemy-test-lifecycle-e2e-asg";
21export const lifecycleHookName = "alchemy-test-lifecycle-e2e-hook";
22
23export class LifecycleTestFunction extends AWS.Lambda.Function<AWS.Lambda.Function>()(
24 "LifecycleTestFunction",
25) {}
26
27/**
28 * Shared fleet for the lifecycle-hook E2E: a launch template + Auto Scaling
29 * Group sized to zero (no instances launch by default). Subnet/AMI are
30 * `Output`s resolved at deploy time only, so this composition is safe to
31 * re-execute inside the deployed Lambda without runtime guards.
32 */
33export class LifecycleFleet extends Context.Service<
34 LifecycleFleet,
35 { group: AutoScalingGroup }
36>()("AutoScalingLifecycleFleet") {}
37
38export const LifecycleFleetLive = Layer.effect(
39 LifecycleFleet,
40 Effect.gen(function* () {
41 const imageId = amazonLinux2023();
42 const subnetId = Output.fromEffect(
43 getAutoScalingTestSubnetId.pipe(Effect.orDie),
44 );
45
46 const template = yield* LaunchTemplate("LifecycleTemplate", {
47 imageId,
48 instanceType: "t3.micro",
49 });
50 const group = yield* AutoScalingGroup("LifecycleGroup", {
51 autoScalingGroupName: lifecycleFleetAsgName,
52 launchTemplate: template,
53 subnetIds: [subnetId],
54 minSize: 0,
55 maxSize: 0,
56 desiredCapacity: 0,
57 });
58 return { group };
59 }),
60);
61
62export default LifecycleTestFunction.make(
63 {
64 main: import.meta.url,
65 functionUrl: true,
66 // /complete-bogus makes a live Auto Scaling call; a cold start plus SDK
67 // round-trip can exceed Lambda's 3s default and surface as a 502.
68 timeout: Duration.seconds(30),
69 },
70 Effect.gen(function* () {
71 const { group } = yield* LifecycleFleet;
72 const lifecycle = yield* CompleteLifecycleAction(group);
73
74 // Register the launch lifecycle hook + EventBridge subscription. Instances
75 // that enter the wait state are drained by signalling CONTINUE.
76 yield* consumeLifecycleActions(
77 group,
78 {
79 lifecycleHookName,
80 lifecycleTransition: "LAUNCHING",
81 heartbeatTimeout: "300 seconds",
82 defaultResult: "ABANDON",
83 },
84 (events) =>
85 Stream.runForEach(events, (event) =>
86 lifecycle
87 .complete({
88 LifecycleHookName: event.detail.LifecycleHookName,
89 LifecycleActionToken: event.detail.LifecycleActionToken,
90 LifecycleActionResult: "CONTINUE",
91 })
92 .pipe(Effect.orDie),
93 ),
94 );
95
96 return {
97 fetch: Effect.gen(function* () {
98 const request = yield* HttpServerRequest;
99 const pathname = new URL(request.originalUrl).pathname;
100
101 if (request.method === "GET" && pathname === "/health") {
102 return yield* HttpServerResponse.json({ ok: true });
103 }
104
105 // Exercise the CompleteLifecycleAction binding directly with a
106 // non-existent action token: AWS rejects it (typed `ValidationError`)
107 // rather than AccessDenied, proving the IAM policy the binding attached
108 // is correct. Returns the error tag so the test can assert wiring.
109 if (request.method === "POST" && pathname === "/complete-bogus") {
110 const result = yield* lifecycle
111 .complete({
112 LifecycleHookName: lifecycleHookName,
113 LifecycleActionToken: "00000000-0000-0000-0000-000000000000",
114 LifecycleActionResult: "CONTINUE",
115 })
116 .pipe(Effect.result);
117 return yield* HttpServerResponse.json({
118 ok: result._tag === "Success",
119 tag: result._tag === "Failure" ? result.failure._tag : "Success",
120 });
121 }
122
123 return yield* HttpServerResponse.json(
124 { error: "Not found", method: request.method, pathname },
125 { status: 404 },
126 );
127 }).pipe(Effect.orDie),
128 };
129 }).pipe(
130 Effect.provide(
131 Layer.mergeAll(
132 AWS.Lambda.EventSource,
133 CompleteLifecycleActionHttp,
134 LifecycleFleetLive,
135 ),
136 ),
137 ),
138);