1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Output from "@/Output";
4import * as Test from "./VpcTest.ts";
5import * as ec2 from "@distilled.cloud/aws/ec2";
6import * as eventbridge from "@distilled.cloud/aws/eventbridge";
7import { describe, expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientRequest from "effect/http/HttpClientRequest";
13import Ec2BindingsFunctionLive, {
14 Ec2BindingsFunction,
15} from "./fixtures/bindings-handler.ts";
16
17const testOptions = { providers: AWS.providers() };
18const { test, beforeAll, afterAll } = Test.make(testOptions);
19const sharedStack = Core.scratchStack(
20 testOptions,
21 "Ec2Bindings",
22 "test/AWS/EC2/Bindings.test.ts",
23);
24
25const readinessPolicy = Schedule.max([
26 Schedule.fixed("2 seconds"),
27 Schedule.recurs(75),
28]);
29
30let baseUrl: string;
31
32// A `type` (not `interface`) so it carries an implicit index signature and is
33// comparable to the `JsonObject` member of `response.json`'s union.
34type RouteResult = {
35 ok: boolean;
36 tag: string;
37 state?: string;
38 count?: number;
39 hasField?: boolean;
40 redacted?: boolean | null;
41 snapshotId?: string;
42};
43
44class SnapshotNotReady extends Data.TaggedError("SnapshotNotReady")<{
45 snapshotId: string;
46 state: string;
47}> {}
48
49class SnapshotStillVisible extends Data.TaggedError("SnapshotStillVisible")<{
50 snapshotId: string;
51}> {}
52
53// Call a fixture route, repeating (bounded) while the response still shows an
54// authorization failure — a freshly attached IAM policy is eventually
55// consistent and the first invocations after deploy can see EC2's
56// `UnauthorizedOperation`.
57const callRoute = (method: "GET" | "POST", path: string) =>
58 Effect.gen(function* () {
59 const request =
60 method === "GET"
61 ? HttpClientRequest.get(`${baseUrl}${path}`)
62 : HttpClientRequest.post(`${baseUrl}${path}`);
63 return yield* HttpClient.execute(request).pipe(
64 Effect.flatMap((response) =>
65 response.status === 200
66 ? response.json
67 : Effect.fail(
68 new Error(`Route ${path} not ready: ${response.status}`),
69 ),
70 ),
71 // A freshly deployed Function URL can briefly return 502 while the
72 // Lambda execution environment is still initializing. Retry transport
73 // readiness separately from the typed IAM-propagation result below.
74 Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 10 }),
75 Effect.map((json) => json as RouteResult),
76 Effect.repeat({
77 until: (body): boolean =>
78 body.tag !== "UnauthorizedOperation" &&
79 body.tag !== "AccessDenied" &&
80 body.tag !== "AccessDeniedException",
81 schedule: Schedule.spaced("3 seconds"),
82 times: 10,
83 }),
84 );
85 });
86
87// Deploys a Lambda bound to a real t3.micro instance, a 1 GiB volume, and an
88// empty security group, with every EC2 runtime binding plus the instance
89// state-change EventBridge subscription. Every route reports the typed result
90// tag; `/stop` runs last since it powers the instance off.
91describe(
92 "EC2 runtime bindings",
93 { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:lambda", "live"] },
94 () => {
95 beforeAll(
96 Effect.gen(function* () {
97 yield* sharedStack.destroy();
98
99 const { fn } = yield* sharedStack.deploy(
100 Effect.gen(function* () {
101 const fn = yield* Ec2BindingsFunction;
102 return { fn };
103 }).pipe(Effect.provide(Ec2BindingsFunctionLive)),
104 );
105
106 expect(fn.functionUrl).toBeTruthy();
107 baseUrl = fn.functionUrl!.replace(/\/+$/, "");
108
109 yield* HttpClient.get(`${baseUrl}/health`).pipe(
110 Effect.flatMap((response) =>
111 response.status === 200
112 ? Effect.succeed(response)
113 : Effect.fail(
114 new Error(`Function not ready: ${response.status}`),
115 ),
116 ),
117 Effect.retry({ schedule: readinessPolicy }),
118 );
119 }),
120 { timeout: 420_000 },
121 );
122
123 afterAll(sharedStack.destroy(), { timeout: 300_000 });
124
125 test.provider(
126 "DescribeInstance returns the bound instance's live state",
127 (_stack) =>
128 Effect.gen(function* () {
129 const body = yield* callRoute("GET", "/describe");
130 expect(body.tag).toBe("Success");
131 expect(body.ok).toBe(true);
132 expect(body.state).toBeTruthy();
133 }),
134 { timeout: 90_000 },
135 );
136
137 test.provider(
138 "DescribeInstanceStatus lists the bound instance's status checks",
139 (_stack) =>
140 Effect.gen(function* () {
141 const body = yield* callRoute("GET", "/status");
142 expect(body.tag).toBe("Success");
143 expect(body.ok).toBe(true);
144 expect(body.count).toBeGreaterThanOrEqual(1);
145 }),
146 { timeout: 90_000 },
147 );
148
149 test.provider(
150 "GetConsoleOutput fetches the instance's console output",
151 (_stack) =>
152 Effect.gen(function* () {
153 const body = yield* callRoute("GET", "/console");
154 expect(body.ok).toBe(true);
155 }),
156 { timeout: 90_000 },
157 );
158
159 test.provider(
160 "GetPasswordData succeeds and surfaces PasswordData as Redacted",
161 (_stack) =>
162 Effect.gen(function* () {
163 const body = yield* callRoute("GET", "/password");
164 expect(body.ok).toBe(true);
165 // Linux without a key pair: the field is either absent or Redacted —
166 // never a raw string.
167 if (body.hasField) {
168 expect(body.redacted).toBe(true);
169 }
170 }),
171 { timeout: 90_000 },
172 );
173
174 test.provider(
175 "StartInstance succeeds against the already-running instance",
176 (_stack) =>
177 Effect.gen(function* () {
178 const body = yield* callRoute("POST", "/start");
179 expect(body.ok).toBe(true);
180 }),
181 { timeout: 90_000 },
182 );
183
184 test.provider(
185 "RebootInstance requests an asynchronous reboot",
186 (_stack) =>
187 Effect.gen(function* () {
188 const body = yield* callRoute("POST", "/reboot");
189 expect(body.ok).toBe(true);
190 }),
191 { timeout: 90_000 },
192 );
193
194 test.provider(
195 "Authorize/RevokeSecurityGroupIngress round-trip a dynamic rule",
196 (_stack) =>
197 Effect.gen(function* () {
198 // A failed earlier run may have left the rule behind: authorize then
199 // tolerates the duplicate, revoke always finds one to remove.
200 const authorize = yield* callRoute("POST", "/authorize");
201 expect(["Success", "InvalidPermission.Duplicate"]).toContain(
202 authorize.tag,
203 );
204 const revoke = yield* callRoute("POST", "/revoke");
205 expect(revoke.tag).toEqual("Success");
206 }),
207 { timeout: 90_000 },
208 );
209
210 test.provider(
211 "CreateSnapshot snapshots the bound volume",
212 (_stack) =>
213 Effect.gen(function* () {
214 const body = yield* callRoute("POST", "/snapshot");
215 expect(body.ok).toBe(true);
216 expect(body.snapshotId).toBeTruthy();
217
218 // Do not delete a pending snapshot. AWS accepts that request and hides
219 // the snapshot immediately, but its background copy can keep the
220 // source volume stuck in `deleting` for many minutes. Wait for this
221 // tiny empty-volume snapshot to finish before removing it.
222 yield* ec2
223 .describeSnapshots({ SnapshotIds: [body.snapshotId!] })
224 .pipe(
225 Effect.flatMap((result) => {
226 const state = result.Snapshots?.[0]?.State ?? "missing";
227 if (state === "completed") return Effect.void;
228 if (state === "error") {
229 return Effect.fail(
230 new Error(
231 `Snapshot ${body.snapshotId} entered error state`,
232 ),
233 );
234 }
235 return Effect.fail(
236 new SnapshotNotReady({
237 snapshotId: body.snapshotId!,
238 state,
239 }),
240 );
241 }),
242 Effect.retry({
243 while: (error) => error instanceof SnapshotNotReady,
244 schedule: Schedule.max([
245 Schedule.fixed("2 seconds"),
246 Schedule.recurs(29),
247 ]),
248 }),
249 );
250
251 // The runtime-created snapshot is not stack-managed — delete it
252 // out-of-band and confirm the exact ID is no longer enumerable.
253 yield* ec2
254 .deleteSnapshot({ SnapshotId: body.snapshotId! })
255 .pipe(
256 Effect.catchTag("InvalidSnapshot.NotFound", () => Effect.void),
257 );
258 yield* ec2
259 .describeSnapshots({ SnapshotIds: [body.snapshotId!] })
260 .pipe(
261 Effect.flatMap((result) =>
262 (result.Snapshots ?? []).length === 0
263 ? Effect.void
264 : Effect.fail(
265 new SnapshotStillVisible({
266 snapshotId: body.snapshotId!,
267 }),
268 ),
269 ),
270 Effect.catchTag("InvalidSnapshot.NotFound", () => Effect.void),
271 Effect.retry({
272 while: (error) => error instanceof SnapshotStillVisible,
273 schedule: Schedule.max([
274 Schedule.fixed("1 second"),
275 Schedule.recurs(10),
276 ]),
277 }),
278 );
279 }),
280 { timeout: 90_000 },
281 );
282
283 test.provider(
284 "consumeInstanceStateEvents created the EventBridge rule",
285 (_stack) =>
286 Effect.gen(function* () {
287 const ref = yield* AWS.EventBridge.Rule.ref(
288 "BindingsInstance-InstanceState",
289 {
290 stack: sharedStack.name,
291 stage: sharedStack.stage,
292 },
293 );
294 const { Name, EventBusName } = yield* Effect.all({
295 Name: Output.evaluate(ref.ruleName, {}),
296 EventBusName: Output.evaluate(ref.eventBusName, {}),
297 }).pipe(Effect.provide(sharedStack.state));
298 const rule = yield* eventbridge.describeRule({ Name, EventBusName });
299 expect(rule?.EventPattern).toContain("aws.ec2");
300 expect(rule?.EventPattern).toContain(
301 "EC2 Instance State-change Notification",
302 );
303 }),
304 { tags: ["provider:aws:eventbridge"], timeout: 60_000 },
305 );
306
307 // Runs last: the instance stays stopped until the stack is destroyed.
308 test.provider(
309 "StopInstance powers the instance off",
310 (_stack) =>
311 Effect.gen(function* () {
312 const body = yield* callRoute("POST", "/stop");
313 expect(body.ok).toBe(true);
314 expect(["stopping", "stopped"]).toContain(body.state);
315 }),
316 { timeout: 90_000 },
317 );
318 },
319);