1import * as AWS from "@/AWS";
2import {
3 amazonLinux2023,
4 AuthorizeSecurityGroupIngress,
5 AuthorizeSecurityGroupIngressHttp,
6 consumeInstanceStateEvents,
7 CreateSnapshot,
8 CreateSnapshotHttp,
9 DescribeInstance,
10 DescribeInstanceHttp,
11 DescribeInstanceStatus,
12 DescribeInstanceStatusHttp,
13 GetConsoleOutput,
14 GetConsoleOutputHttp,
15 GetPasswordData,
16 GetPasswordDataHttp,
17 Instance,
18 RebootInstance,
19 RebootInstanceHttp,
20 RevokeSecurityGroupIngress,
21 RevokeSecurityGroupIngressHttp,
22 SecurityGroup,
23 StartInstance,
24 StartInstanceHttp,
25 StopInstance,
26 StopInstanceHttp,
27 Subnet,
28 Volume,
29 Vpc,
30} from "@/AWS/EC2";
31import type * as ec2 from "@distilled.cloud/aws/ec2";
32import * as Context from "effect/Context";
33import * as Duration from "effect/Duration";
34import * as Effect from "effect/Effect";
35import * as Layer from "effect/Layer";
36import * as Redacted from "effect/Redacted";
37import * as Schedule from "effect/Schedule";
38import * as Stream from "effect/Stream";
39import { HttpServerRequest } from "effect/http/HttpServerRequest";
40import * as HttpServerResponse from "effect/http/HttpServerResponse";
41
42type BindingError =
43 | ec2.DescribeInstancesError
44 | ec2.DescribeInstanceStatusError
45 | ec2.StartInstancesError
46 | ec2.StopInstancesError
47 | ec2.RebootInstancesError
48 | ec2.GetConsoleOutputError
49 | ec2.GetPasswordDataError
50 | ec2.CreateSnapshotError
51 | ec2.AuthorizeSecurityGroupIngressError
52 | ec2.RevokeSecurityGroupIngressError;
53
54const authorizationResult = <A, E extends BindingError, R>(
55 operation: Effect.Effect<A, E, R>,
56) =>
57 operation.pipe(
58 Effect.retry({
59 while: (error) => error._tag === "UnauthorizedOperation",
60 schedule: Schedule.spaced("3 seconds"),
61 times: 8,
62 }),
63 // Return a typed timeout before Lambda's 30-second invocation limit.
64 Effect.timeout("25 seconds"),
65 Effect.result,
66 );
67
68/** The dynamic-allowlisting rule the authorize/revoke routes add and remove. */
69export const testIngressRule = {
70 IpProtocol: "tcp",
71 FromPort: 443,
72 ToPort: 443,
73 CidrIp: "203.0.113.0/24",
74} as const;
75
76export class Ec2BindingsFunction extends AWS.Lambda.Function<AWS.Lambda.Function>()(
77 "Ec2BindingsFunction",
78) {}
79
80/**
81 * Shared fleet for the bindings E2E: a dedicated VPC/subnet (the testing
82 * account has no default VPC), a t3.micro instance, a 1 GiB volume, and an
83 * empty security group. The AMI is an `Output` resolved at deploy time only,
84 * so this composition is safe to re-execute inside the deployed Lambda
85 * without runtime guards.
86 */
87export class BindingsFleet extends Context.Service<
88 BindingsFleet,
89 { instance: Instance; group: SecurityGroup; volume: Volume }
90>()("Ec2BindingsFleet") {}
91
92export const BindingsFleetLive = Layer.effect(
93 BindingsFleet,
94 Effect.gen(function* () {
95 const imageId = amazonLinux2023();
96
97 const vpc = yield* Vpc("BindingsVpc", { cidrBlock: "10.61.0.0/16" });
98 const subnet = yield* Subnet("BindingsSubnet", {
99 vpcId: vpc.vpcId,
100 cidrBlock: "10.61.1.0/24",
101 });
102 const group = yield* SecurityGroup("BindingsSecurityGroup", {
103 vpcId: vpc.vpcId,
104 description: "EC2 bindings test group",
105 });
106 const volume = yield* Volume("BindingsVolume", {
107 availabilityZone: subnet.availabilityZone,
108 size: 1,
109 });
110 const instance = yield* Instance("BindingsInstance", {
111 imageId,
112 instanceType: "t3.micro",
113 subnetId: subnet.subnetId,
114 });
115 return { instance, group, volume };
116 }),
117);
118
119export default Ec2BindingsFunction.make(
120 {
121 main: import.meta.url,
122 functionUrl: true,
123 // The AWS defaults (128 MB / 3s) are too tight for the EC2 client:
124 // cold routes time out at 3s (502 from the function URL) with memory
125 // pegged against the 128 MB floor.
126 timeout: Duration.seconds(30),
127 memorySize: 512,
128 },
129 Effect.gen(function* () {
130 const { instance, group, volume } = yield* BindingsFleet;
131
132 const describeInstance = yield* DescribeInstance(instance);
133 const describeStatus = yield* DescribeInstanceStatus(instance);
134 const startInstance = yield* StartInstance(instance);
135 const stopInstance = yield* StopInstance(instance);
136 const rebootInstance = yield* RebootInstance(instance);
137 const getConsoleOutput = yield* GetConsoleOutput(instance);
138 const getPasswordData = yield* GetPasswordData(instance);
139 const createSnapshot = yield* CreateSnapshot(volume);
140 const authorizeIngress = yield* AuthorizeSecurityGroupIngress(group);
141 const revokeIngress = yield* RevokeSecurityGroupIngress(group);
142
143 // Subscribe to instance stop/terminate transitions (creates the
144 // EventBridge rule at deploy time).
145 yield* consumeInstanceStateEvents(
146 instance,
147 { states: ["stopped", "terminated"] },
148 (events) =>
149 Stream.runForEach(events, (event) =>
150 Effect.log(
151 `${event.detail["instance-id"]} is now ${event.detail.state}`,
152 ),
153 ),
154 );
155
156 return {
157 fetch: Effect.gen(function* () {
158 const request = yield* HttpServerRequest;
159 const pathname = new URL(request.originalUrl).pathname;
160
161 if (request.method === "GET" && pathname === "/health") {
162 return yield* HttpServerResponse.json({ ok: true });
163 }
164
165 if (request.method === "GET" && pathname === "/describe") {
166 const result = yield* describeInstance().pipe(authorizationResult);
167 return yield* HttpServerResponse.json({
168 ok: result._tag === "Success",
169 tag: result._tag === "Failure" ? result.failure._tag : "Success",
170 state:
171 result._tag === "Success"
172 ? result.success?.State?.Name
173 : undefined,
174 });
175 }
176
177 if (request.method === "GET" && pathname === "/status") {
178 const result = yield* describeStatus({
179 IncludeAllInstances: true,
180 }).pipe(authorizationResult);
181 return yield* HttpServerResponse.json({
182 ok: result._tag === "Success",
183 tag: result._tag === "Failure" ? result.failure._tag : "Success",
184 count:
185 result._tag === "Success"
186 ? (result.success.InstanceStatuses ?? []).length
187 : undefined,
188 });
189 }
190
191 if (request.method === "GET" && pathname === "/console") {
192 const result = yield* getConsoleOutput().pipe(authorizationResult);
193 return yield* HttpServerResponse.json({
194 ok: result._tag === "Success",
195 tag: result._tag === "Failure" ? result.failure._tag : "Success",
196 });
197 }
198
199 // The instance runs Linux without a key pair, so the password data is
200 // empty — the probe proves the IAM grant and that a present
201 // `PasswordData` surfaces as Redacted (never a raw string).
202 if (request.method === "GET" && pathname === "/password") {
203 const result = yield* getPasswordData().pipe(authorizationResult);
204 const passwordData =
205 result._tag === "Success" ? result.success.PasswordData : undefined;
206 return yield* HttpServerResponse.json({
207 ok: result._tag === "Success",
208 tag: result._tag === "Failure" ? result.failure._tag : "Success",
209 hasField: passwordData !== undefined,
210 redacted:
211 passwordData !== undefined
212 ? Redacted.isRedacted(passwordData)
213 : null,
214 });
215 }
216
217 // Starting an already-running instance succeeds without effect.
218 if (request.method === "POST" && pathname === "/start") {
219 const result = yield* startInstance().pipe(authorizationResult);
220 return yield* HttpServerResponse.json({
221 ok: result._tag === "Success",
222 tag: result._tag === "Failure" ? result.failure._tag : "Success",
223 });
224 }
225
226 if (request.method === "POST" && pathname === "/reboot") {
227 const result = yield* rebootInstance().pipe(authorizationResult);
228 return yield* HttpServerResponse.json({
229 ok: result._tag === "Success",
230 tag: result._tag === "Failure" ? result.failure._tag : "Success",
231 });
232 }
233
234 // Runs LAST in the test file — the instance stays stopped until the
235 // stack is destroyed.
236 if (request.method === "POST" && pathname === "/stop") {
237 const result = yield* stopInstance().pipe(authorizationResult);
238 return yield* HttpServerResponse.json({
239 ok: result._tag === "Success",
240 tag: result._tag === "Failure" ? result.failure._tag : "Success",
241 state:
242 result._tag === "Success"
243 ? result.success.StoppingInstances?.[0]?.CurrentState?.Name
244 : undefined,
245 });
246 }
247
248 if (request.method === "POST" && pathname === "/authorize") {
249 const result = yield* authorizeIngress({
250 IpPermissions: [
251 {
252 IpProtocol: testIngressRule.IpProtocol,
253 FromPort: testIngressRule.FromPort,
254 ToPort: testIngressRule.ToPort,
255 IpRanges: [{ CidrIp: testIngressRule.CidrIp }],
256 },
257 ],
258 }).pipe(authorizationResult);
259 return yield* HttpServerResponse.json({
260 ok: result._tag === "Success",
261 tag: result._tag === "Failure" ? result.failure._tag : "Success",
262 });
263 }
264
265 if (request.method === "POST" && pathname === "/revoke") {
266 const result = yield* revokeIngress({
267 IpPermissions: [
268 {
269 IpProtocol: testIngressRule.IpProtocol,
270 FromPort: testIngressRule.FromPort,
271 ToPort: testIngressRule.ToPort,
272 IpRanges: [{ CidrIp: testIngressRule.CidrIp }],
273 },
274 ],
275 }).pipe(authorizationResult);
276 return yield* HttpServerResponse.json({
277 ok: result._tag === "Success",
278 tag: result._tag === "Failure" ? result.failure._tag : "Success",
279 });
280 }
281
282 // Creates a real (1 GiB) snapshot; the test deletes it out-of-band.
283 if (request.method === "POST" && pathname === "/snapshot") {
284 const result = yield* createSnapshot({
285 Description: "alchemy EC2 bindings test snapshot",
286 }).pipe(authorizationResult);
287 return yield* HttpServerResponse.json({
288 ok: result._tag === "Success",
289 tag: result._tag === "Failure" ? result.failure._tag : "Success",
290 snapshotId:
291 result._tag === "Success" ? result.success.SnapshotId : undefined,
292 });
293 }
294
295 return yield* HttpServerResponse.json(
296 { error: "Not found", method: request.method, pathname },
297 { status: 404 },
298 );
299 }).pipe(Effect.orDie),
300 };
301 }).pipe(
302 Effect.provide(
303 Layer.mergeAll(
304 AWS.Lambda.EventSource,
305 DescribeInstanceHttp,
306 DescribeInstanceStatusHttp,
307 StartInstanceHttp,
308 StopInstanceHttp,
309 RebootInstanceHttp,
310 GetConsoleOutputHttp,
311 GetPasswordDataHttp,
312 CreateSnapshotHttp,
313 AuthorizeSecurityGroupIngressHttp,
314 RevokeSecurityGroupIngressHttp,
315 BindingsFleetLive,
316 ),
317 ),
318 ),
319);