1import * as AWS from "@/AWS";
2import * as Alchemy from "@/index.ts";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import {
10 materializeIsolatedProject,
11 removeIsolatedProject,
12} from "../../IsolatedProject.ts";
13import { project } from "./fixtures/microvm/isolated/sandbox.ts";
14import IsolatedStack from "./fixtures/microvm/isolated/stack.ts";
15
16const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
17 providers: AWS.providers(),
18 state: Alchemy.localState(),
19});
20
21const logLevel = Effect.provideService(
22 MinimumLogLevel,
23 process.env.DEBUG ? "Debug" : "Info",
24);
25
26// MicroVM image builds run server-side (Firecracker snapshot) and can take
27// several minutes, so give deploy/destroy plenty of room.
28const HOOK_TIMEOUT = 1_500_000;
29const TEST_TIMEOUT = 300_000;
30
31// Same gate as MicrovmImage.test.ts: builds are asynchronous (minutes).
32const skip = !process.env.LAMBDA_TEST_MICROVM;
33
34// Live proof that the MicroVM bootstrap boots when the image's `main` lives
35// in an isolated project (see test/IsolatedProject.ts) — the bundle `cwd`
36// resolves none of alchemy's dependencies, so the bootstrap's `alchemy/*` /
37// `effect/*` imports must be bundled by the virtual-entry plugin rather than
38// found from the project root. With them left external the in-VM server
39// dies at module load and the orchestrator's RPC/fetch calls never get a
40// reply (the `/rpc` route answers 500).
41describe.skipIf(skip)(
42 "isolated-project microvm (main)",
43 { tags: ["provider:aws", "provider:aws:iam", "provider:aws:lambda", "live"] },
44 () => {
45 beforeAll(materializeIsolatedProject(project));
46 const stack = beforeAll(deploy(IsolatedStack), { timeout: HOOK_TIMEOUT });
47 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(IsolatedStack), {
48 timeout: HOOK_TIMEOUT,
49 });
50 afterAll(removeIsolatedProject(project));
51
52 test(
53 "in-VM program bundled from an isolated project answers RPC and fetch",
54 Effect.gen(function* () {
55 const { url } = yield* stack;
56 const baseUrl = url.replace(/\/+$/, "");
57 const client = yield* HttpClient.HttpClient;
58
59 // One request per attempt: the handler runs ONE MicroVM, waits for
60 // RUNNING, calls `hello` over RPC and GETs `/echo`, then terminates it.
61 //
62 // Non-200s are retried on a small budget because a fresh deploy leaves
63 // the function's config update `InProgress` for a few seconds, during
64 // which AWS still runs the PREVIOUS configuration — whose environment
65 // lacks the image ARN this binding injects as `imageIdentifier`, so
66 // `runMicrovm` fails to serialize and the route answers 500. (The
67 // provider does not block on that window by design; see the precreate
68 // stub comment in AWS/Lambda/Function.ts.) Retrying is safe here: the
69 // handler terminates the MicroVM it launched via `Effect.ensuring`, on
70 // success or failure, so an attempt cannot leak a running VM.
71 const res = yield* client.post(`${baseUrl}/rpc?message=world`).pipe(
72 Effect.timeout("150 seconds"),
73 Effect.flatMap((r) =>
74 r.status === 200
75 ? Effect.succeed(r)
76 : r.text.pipe(
77 Effect.flatMap((text) =>
78 Effect.fail(new Error(`/rpc ${r.status}: ${text}`)),
79 ),
80 ),
81 ),
82 Effect.retry({ schedule: Schedule.spaced("5 seconds"), times: 6 }),
83 Effect.orDie,
84 );
85 const body = (yield* res.json.pipe(Effect.orDie)) as {
86 reply: string;
87 echo: string;
88 };
89 expect(body.reply).toBe("hello, world!");
90 expect(body.echo).toBe("world");
91 }).pipe(logLevel),
92 { timeout: TEST_TIMEOUT },
93 );
94 },
95);