1import * as AWS from "@/AWS";
2import * as Cloudflare from "@/Cloudflare";
3import * as Alchemy from "@/index.ts";
4import * as Test from "@/Test/Alchemy";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import EffectfulStack from "./fixtures/microvm/stack.ts";
13import ExternalStack from "./fixtures/microvm/external/stack.ts";
14
15const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
16 // The effectful stack deploys an AWS Lambda AND a Cloudflare Worker driving
17 // the same MicroVM image, so the harness needs both provider sets.
18 providers: Layer.mergeAll(AWS.providers(), Cloudflare.providers()),
19 state: Alchemy.localState(),
20});
21
22const logLevel = Effect.provideService(
23 MinimumLogLevel,
24 process.env.DEBUG ? "Debug" : "Info",
25);
26
27// MicroVM image builds run server-side (Firecracker snapshot) and can take
28// several minutes, so give deploy/destroy plenty of room.
29const HOOK_TIMEOUT = 1_500_000;
30const TEST_TIMEOUT = 300_000;
31
32// Lambda MicroVM is a preview feature: builds are asynchronous (minutes) and the
33// account must be onboarded to the preview, with a bootstrapped Assets bucket.
34const skip = !process.env.LAMBDA_TEST_MICROVM;
35
36const readinessSchedule = Schedule.min([
37 Schedule.exponential("500 millis"),
38 Schedule.spaced("3 seconds"),
39]);
40const readinessRetries = 30;
41
42// Retry a freshly-deployed Lambda URL until it answers 200 (cold-start, IAM
43// propagation).
44const send = (req: HttpClientRequest.HttpClientRequest) =>
45 Effect.gen(function* () {
46 const client = yield* HttpClient.HttpClient;
47 return yield* client.execute(req).pipe(
48 Effect.flatMap((r) =>
49 r.status === 200
50 ? Effect.succeed(r)
51 : Effect.fail(new Error(`not ready: ${r.status}`)),
52 ),
53 Effect.timeout("30 seconds"),
54 Effect.retry({ schedule: readinessSchedule, times: readinessRetries }),
55 );
56 });
57
58/**
59 * Effectful MicroVM (`main`): the in-VM HTTP server is written in TypeScript and
60 * bundled into a generated image. A Lambda orchestrator binds the instance
61 * operations and drives the full lifecycle (run -> auth-token -> list ->
62 * terminate) over its function URL.
63 */
64describe.skipIf(skip)(
65 "effectful microvm (main)",
66 {
67 tags: [
68 "provider:aws",
69 "provider:aws:iam",
70 "provider:aws:lambda",
71 "provider:cloudflare",
72 "provider:cloudflare:worker",
73 "live",
74 ],
75 },
76 () => {
77 const stack = beforeAll(deploy(EffectfulStack), { timeout: HOOK_TIMEOUT });
78 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(EffectfulStack), {
79 timeout: HOOK_TIMEOUT,
80 });
81
82 test(
83 "runs a MicroVM, mints a token, and terminates it",
84 Effect.gen(function* () {
85 const { url } = yield* stack;
86 const baseUrl = url.replace(/\/+$/, "");
87
88 const runRes = yield* send(HttpClientRequest.post(`${baseUrl}/run`));
89 const vm = (yield* runRes.json) as {
90 microvmId: string;
91 endpoint: string;
92 state: string;
93 };
94 expect(vm.microvmId).toBeTruthy();
95 // RunMicrovm returns the MicroVM's endpoint as a bare hostname
96 // (`<id>.lambda-microvm.<region>.on.aws`), not a full URL.
97 expect(vm.endpoint).toContain("lambda-microvm");
98
99 yield* send(HttpClientRequest.get(`${baseUrl}/get?id=${vm.microvmId}`));
100
101 const tokenRes = yield* send(
102 HttpClientRequest.post(`${baseUrl}/auth-token?id=${vm.microvmId}`),
103 );
104 const token = (yield* tokenRes.json) as { hasToken: boolean };
105 expect(token.hasToken).toBe(true);
106
107 const listRes = yield* send(HttpClientRequest.get(`${baseUrl}/list`));
108 const list = (yield* listRes.json) as { count: number };
109 expect(list.count).toBeGreaterThanOrEqual(1);
110
111 const termRes = yield* send(
112 HttpClientRequest.post(`${baseUrl}/terminate?id=${vm.microvmId}`),
113 );
114 expect(termRes.status).toBe(200);
115 }).pipe(logLevel),
116 { timeout: TEST_TIMEOUT },
117 );
118
119 test(
120 "drives the in-VM tagged RPC method and the fetch route",
121 Effect.gen(function* () {
122 const { url } = yield* stack;
123 const baseUrl = url.replace(/\/+$/, "");
124 const client = yield* HttpClient.HttpClient;
125
126 // The orchestrator runs ONE MicroVM, calls `hello` over RPC, and GETs the
127 // `/echo` fetch route — both against the in-VM server's endpoint — then
128 // terminates it. We issue a single request (the handler waits for RUNNING
129 // internally) and retry only transport errors, NOT 500s: a 500 is a real
130 // failure to surface, and retrying it would spawn more MicroVMs.
131 const res = yield* client.post(`${baseUrl}/rpc?message=world`).pipe(
132 Effect.timeout("150 seconds"),
133 Effect.retry({
134 schedule: Schedule.exponential("1 second"),
135 times: 3,
136 }),
137 Effect.orDie,
138 );
139 if (res.status !== 200) {
140 const text = yield* res.text.pipe(Effect.orDie);
141 return yield* Effect.die(new Error(`/rpc ${res.status}: ${text}`));
142 }
143 const body = (yield* res.json.pipe(Effect.orDie)) as {
144 reply: string;
145 echo: string;
146 };
147 expect(body.reply).toBe("hello, world!");
148 expect(body.echo).toBe("world");
149 }).pipe(logLevel),
150 { timeout: TEST_TIMEOUT },
151 );
152
153 test(
154 "drives the MicroVM from a Cloudflare Worker (cross-cloud assume-role)",
155 Effect.gen(function* () {
156 const { workerUrl } = yield* stack;
157 const baseUrl = workerUrl.replace(/\/+$/, "");
158 const client = yield* HttpClient.HttpClient;
159
160 // Same `/rpc` flow as the Lambda host, but the orchestrator is a
161 // Cloudflare Worker reaching AWS via the IAM User → assume-role
162 // credentials minted by the binding. A single request (waits for RUNNING
163 // internally); retry only transport errors, not 500s.
164 const res = yield* client
165 .post(`${baseUrl}/rpc?message=cloudflare`)
166 .pipe(
167 Effect.timeout("150 seconds"),
168 Effect.retry({
169 schedule: Schedule.exponential("1 second"),
170 times: 3,
171 }),
172 Effect.orDie,
173 );
174 if (res.status !== 200) {
175 const text = yield* res.text.pipe(Effect.orDie);
176 return yield* Effect.die(new Error(`/rpc ${res.status}: ${text}`));
177 }
178 const body = (yield* res.json.pipe(Effect.orDie)) as {
179 reply: string;
180 echo: string;
181 };
182 expect(body.reply).toBe("hello, cloudflare!");
183 expect(body.echo).toBe("cloudflare");
184 }).pipe(logLevel),
185 { timeout: TEST_TIMEOUT },
186 );
187 },
188);
189
190/**
191 * External MicroVM (`context`/`dockerfile`): Alchemy zips the build context and
192 * AWS runs the user's Dockerfile server-side.
193 */
194describe.skipIf(skip)(
195 "external microvm (context/dockerfile)",
196 {
197 tags: [
198 "provider:aws",
199 "provider:aws:iam",
200 "provider:aws:lambda",
201 "provider:cloudflare",
202 "live",
203 ],
204 },
205 () => {
206 const stack = beforeAll(deploy(ExternalStack), { timeout: HOOK_TIMEOUT });
207 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(ExternalStack), {
208 timeout: HOOK_TIMEOUT,
209 });
210
211 test(
212 "builds the image from a Dockerfile + context",
213 Effect.gen(function* () {
214 // The stack outputs reflect the resolved image — a `CREATED` state proves
215 // the server-side build of the user Dockerfile succeeded.
216 const { imageArn, state } = yield* stack;
217 expect(state).toBe("CREATED");
218 expect(imageArn).toContain("microvm-image");
219 }).pipe(logLevel),
220 { timeout: TEST_TIMEOUT },
221 );
222 },
223);