1import * as AWS from "@/AWS";
2import {
3 AutoScalingGroup,
4 consumeInstanceEvents,
5 DescribeAutoScalingGroup,
6 DescribeAutoScalingGroupHttp,
7 DescribeScalingActivities,
8 DescribeScalingActivitiesHttp,
9 ExecutePolicy,
10 ExecutePolicyHttp,
11 InstanceRefresh,
12 InstanceRefreshHttp,
13 LaunchTemplate,
14 SetDesiredCapacity,
15 SetDesiredCapacityHttp,
16 SetInstanceHealth,
17 SetInstanceHealthHttp,
18 SetInstanceProtection,
19 SetInstanceProtectionHttp,
20 Standby,
21 StandbyHttp,
22 TerminateInstanceInAutoScalingGroup,
23 TerminateInstanceInAutoScalingGroupHttp,
24} from "@/AWS/AutoScaling";
25import { amazonLinux2023 } from "@/AWS/EC2";
26import * as Output from "@/Output";
27import * as Context from "effect/Context";
28import * as Duration from "effect/Duration";
29import * as Effect from "effect/Effect";
30import * as Layer from "effect/Layer";
31import * as Stream from "effect/Stream";
32import { HttpServerRequest } from "effect/http/HttpServerRequest";
33import * as HttpServerResponse from "effect/http/HttpServerResponse";
34import { getAutoScalingTestSubnetId } from "../TestNetwork.ts";
35
36export const bindingsAsgName = "alchemy-test-asg-bindings-asg";
37
38/** A well-formed instance id that does not exist — probes reach the service
39 * (proving credentials + IAM) and come back as a typed `ValidationError`. */
40const bogusInstanceId = "i-00000000000000000";
41
42export class AsgBindingsFunction extends AWS.Lambda.Function<AWS.Lambda.Function>()(
43 "AsgBindingsFunction",
44) {}
45
46/**
47 * Shared fleet for the bindings E2E: a launch template + Auto Scaling Group
48 * sized to zero (no instances launch). Subnet/AMI are `Output`s resolved at
49 * deploy time only, so this composition is safe to re-execute inside the
50 * deployed Lambda without runtime guards.
51 */
52export class BindingsFleet extends Context.Service<
53 BindingsFleet,
54 { group: AutoScalingGroup }
55>()("AutoScalingBindingsFleet") {}
56
57export const BindingsFleetLive = Layer.effect(
58 BindingsFleet,
59 Effect.gen(function* () {
60 const imageId = amazonLinux2023();
61 const subnetId = Output.fromEffect(
62 getAutoScalingTestSubnetId.pipe(Effect.orDie),
63 );
64
65 const template = yield* LaunchTemplate("BindingsTemplate", {
66 imageId,
67 instanceType: "t3.micro",
68 });
69 const group = yield* AutoScalingGroup("BindingsGroup", {
70 autoScalingGroupName: bindingsAsgName,
71 launchTemplate: template,
72 subnetIds: [subnetId],
73 minSize: 0,
74 maxSize: 0,
75 desiredCapacity: 0,
76 });
77 return { group };
78 }),
79);
80
81export default AsgBindingsFunction.make(
82 {
83 main: import.meta.url,
84 functionUrl: true,
85 // The AWS defaults (128 MB / 3s) are too tight for the AutoScaling
86 // client: cold routes time out at exactly 3s (the function URL turns
87 // that into a 502) with memory pegged at 127/128 MB.
88 timeout: Duration.seconds(30),
89 memorySize: 512,
90 },
91 Effect.gen(function* () {
92 const { group } = yield* BindingsFleet;
93
94 const describeGroup = yield* DescribeAutoScalingGroup(group);
95 const describeActivities = yield* DescribeScalingActivities(group);
96 const setDesiredCapacity = yield* SetDesiredCapacity(group);
97 const executePolicy = yield* ExecutePolicy(group);
98 const setInstanceHealth = yield* SetInstanceHealth(group);
99 const setInstanceProtection = yield* SetInstanceProtection(group);
100 const terminateInstance = yield* TerminateInstanceInAutoScalingGroup(group);
101 const standby = yield* Standby(group);
102 const refresh = yield* InstanceRefresh(group);
103
104 // Subscribe to completed launch/terminate scaling activities (creates the
105 // EventBridge rule at deploy time).
106 yield* consumeInstanceEvents(group, {}, (events) =>
107 Stream.runForEach(events, (event) =>
108 Effect.log(
109 `${event["detail-type"]}: ${event.detail.EC2InstanceId} in ${event.detail.AutoScalingGroupName}`,
110 ),
111 ),
112 );
113
114 return {
115 fetch: Effect.gen(function* () {
116 const request = yield* HttpServerRequest;
117 const pathname = new URL(request.originalUrl).pathname;
118
119 if (request.method === "GET" && pathname === "/health") {
120 return yield* HttpServerResponse.json({ ok: true });
121 }
122
123 if (request.method === "GET" && pathname === "/describe") {
124 const result = yield* describeGroup().pipe(Effect.result);
125 return yield* HttpServerResponse.json({
126 ok: result._tag === "Success",
127 tag: result._tag === "Failure" ? result.failure._tag : "Success",
128 name:
129 result._tag === "Success"
130 ? result.success?.AutoScalingGroupName
131 : undefined,
132 maxSize:
133 result._tag === "Success" ? result.success?.MaxSize : undefined,
134 });
135 }
136
137 if (request.method === "GET" && pathname === "/activities") {
138 const result = yield* describeActivities({ MaxRecords: 10 }).pipe(
139 Effect.result,
140 );
141 return yield* HttpServerResponse.json({
142 ok: result._tag === "Success",
143 tag: result._tag === "Failure" ? result.failure._tag : "Success",
144 count:
145 result._tag === "Success"
146 ? (result.success.Activities ?? []).length
147 : undefined,
148 });
149 }
150
151 if (request.method === "POST" && pathname === "/set-desired") {
152 const result = yield* setDesiredCapacity({
153 DesiredCapacity: 0,
154 }).pipe(Effect.result);
155 return yield* HttpServerResponse.json({
156 ok: result._tag === "Success",
157 tag: result._tag === "Failure" ? result.failure._tag : "Success",
158 });
159 }
160
161 // Executes a policy that doesn't exist: AWS rejects it with a typed
162 // `ValidationError` rather than AccessDenied, proving the IAM policy
163 // the binding attached is correct.
164 if (request.method === "POST" && pathname === "/execute-policy") {
165 const result = yield* executePolicy({
166 PolicyName: "alchemy-test-bogus-policy",
167 }).pipe(Effect.result);
168 return yield* HttpServerResponse.json({
169 ok: result._tag === "Success",
170 tag: result._tag === "Failure" ? result.failure._tag : "Success",
171 });
172 }
173
174 // Instance-scoped operation: the request carries no group name, so
175 // EC2 Auto Scaling resolves the instance's owning group for
176 // resource-level authorization. A bogus instance resolves to nothing,
177 // which the (correct, least-privilege) group-scoped grant denies.
178 if (request.method === "POST" && pathname === "/set-health") {
179 const result = yield* setInstanceHealth({
180 InstanceId: bogusInstanceId,
181 HealthStatus: "Unhealthy",
182 }).pipe(Effect.result);
183 return yield* HttpServerResponse.json({
184 ok: result._tag === "Success",
185 tag: result._tag === "Failure" ? result.failure._tag : "Success",
186 });
187 }
188
189 if (request.method === "POST" && pathname === "/set-protection") {
190 const result = yield* setInstanceProtection({
191 InstanceIds: [bogusInstanceId],
192 ProtectedFromScaleIn: true,
193 }).pipe(Effect.result);
194 return yield* HttpServerResponse.json({
195 ok: result._tag === "Success",
196 tag: result._tag === "Failure" ? result.failure._tag : "Success",
197 });
198 }
199
200 // Instance-scoped like /set-health above — see that comment.
201 if (request.method === "POST" && pathname === "/terminate") {
202 const result = yield* terminateInstance({
203 InstanceId: bogusInstanceId,
204 ShouldDecrementDesiredCapacity: false,
205 }).pipe(Effect.result);
206 return yield* HttpServerResponse.json({
207 ok: result._tag === "Success",
208 tag: result._tag === "Failure" ? result.failure._tag : "Success",
209 });
210 }
211
212 if (request.method === "POST" && pathname === "/standby-enter") {
213 const result = yield* standby
214 .enter({
215 InstanceIds: [bogusInstanceId],
216 ShouldDecrementDesiredCapacity: true,
217 })
218 .pipe(Effect.result);
219 return yield* HttpServerResponse.json({
220 ok: result._tag === "Success",
221 tag: result._tag === "Failure" ? result.failure._tag : "Success",
222 });
223 }
224
225 if (request.method === "POST" && pathname === "/standby-exit") {
226 const result = yield* standby
227 .exit({ InstanceIds: [bogusInstanceId] })
228 .pipe(Effect.result);
229 return yield* HttpServerResponse.json({
230 ok: result._tag === "Success",
231 tag: result._tag === "Failure" ? result.failure._tag : "Success",
232 });
233 }
234
235 // Full instance-refresh cycle on the zero-instance fleet: start
236 // succeeds (and completes immediately with nothing to replace),
237 // describe lists it, cancel tolerates the already-finished refresh.
238 if (request.method === "POST" && pathname === "/refresh") {
239 const started = yield* refresh.start().pipe(Effect.result);
240 const startTag =
241 started._tag === "Failure" ? started.failure._tag : "Success";
242
243 const described = yield* refresh.describe().pipe(Effect.result);
244 const describeOk = described._tag === "Success";
245 const describeCount =
246 described._tag === "Success"
247 ? (described.success.InstanceRefreshes ?? []).length
248 : undefined;
249
250 const cancelled = yield* refresh.cancel().pipe(
251 Effect.catchTag("ActiveInstanceRefreshNotFoundFault", () =>
252 Effect.succeed({ InstanceRefreshId: undefined }),
253 ),
254 Effect.result,
255 );
256 const cancelTag =
257 cancelled._tag === "Failure" ? cancelled.failure._tag : "Success";
258
259 return yield* HttpServerResponse.json({
260 startTag,
261 describeOk,
262 describeCount,
263 cancelTag,
264 });
265 }
266
267 return yield* HttpServerResponse.json(
268 { error: "Not found", method: request.method, pathname },
269 { status: 404 },
270 );
271 }).pipe(Effect.orDie),
272 };
273 }).pipe(
274 Effect.provide(
275 Layer.mergeAll(
276 AWS.Lambda.EventSource,
277 DescribeAutoScalingGroupHttp,
278 DescribeScalingActivitiesHttp,
279 SetDesiredCapacityHttp,
280 ExecutePolicyHttp,
281 SetInstanceHealthHttp,
282 SetInstanceProtectionHttp,
283 TerminateInstanceInAutoScalingGroupHttp,
284 StandbyHttp,
285 InstanceRefreshHttp,
286 BindingsFleetLive,
287 ),
288 ),
289 ),
290);