1import type { Credentials } from "@distilled.cloud/aws/Credentials";
2import type { Region } from "@distilled.cloud/aws/Region";
3import type * as microvms from "@distilled.cloud/aws/lambda-microvms";
4
5import * as Effect from "effect/Effect";
6import type * as Bundle from "../../Bundle/Bundle.ts";
7import { Platform } from "../../Platform.ts";
8import type { Main } from "../../Platform.ts";
9import type { Resource } from "../../Resource.ts";
10import type * as Server from "../../Server/index.ts";
11import type { PolicyStatement } from "../IAM/Policy.ts";
12import type { Role } from "../IAM/Role.ts";
13import type { Providers } from "../Providers.ts";
14import {
15 makeMicrovmRuntimeContext,
16 MicrovmImageTypeId,
17} from "./MicrovmRuntimeContext.ts";
18
19/**
20 * The IAM permissions a build role needs: read the code artifact from the
21 * Assets bucket(s) and write build logs to CloudWatch. Used to auto-grant a
22 * {@link Role} passed as `buildRole`.
23 */
24const buildRolePolicyStatements: PolicyStatement[] = [
25 {
26 Effect: "Allow",
27 Action: ["s3:GetObject"],
28 Resource: ["arn:aws:s3:::alchemy-assets-*-an/*"],
29 },
30 {
31 Effect: "Allow",
32 Action: [
33 "logs:CreateLogGroup",
34 "logs:CreateLogStream",
35 "logs:PutLogEvents",
36 ],
37 Resource: ["arn:aws:logs:*:*:log-group:/aws/lambda/microvms/*"],
38 },
39];
40
41export { MicrovmImageTypeId } from "./MicrovmRuntimeContext.ts";
42
43export interface MicrovmImageProps {
44 /**
45 * The name of the MicroVM image. Must be 1-64 characters of letters,
46 * numbers, hyphens, or underscores. If omitted, a unique name is generated.
47 * Changing the name replaces the image.
48 */
49 name?: string;
50
51 /**
52 * **Effectful mode.** Entrypoint file for an Effect-native HTTP server,
53 * typically `import.meta.filename`. Alchemy bundles it (with its bindings),
54 * generates a Dockerfile, zips the result, and uploads it as the
55 * `codeArtifact`.
56 */
57 main?: string;
58
59 /**
60 * **External mode.** A build-context directory containing your Dockerfile and
61 * the files it copies. Alchemy zips and uploads it; AWS runs your Dockerfile.
62 * @default "./"
63 */
64 context?: string;
65
66 /**
67 * The Dockerfile. In effectful mode this is an inline base string (defaults
68 * to `FROM public.ecr.aws/lambda/microvms:al2023-minimal`). In external mode
69 * it is a path relative to {@link context} (defaults to `<context>/Dockerfile`).
70 */
71 dockerfile?: string;
72
73 /**
74 * The port the in-VM HTTP server listens on (effectful mode).
75 * @default 8080
76 */
77 port?: number;
78
79 /**
80 * The JS runtime to bundle for (effectful mode).
81 * @default "node"
82 */
83 runtime?: "bun" | "node";
84
85 /**
86 * Extra module ids to leave external when bundling (effectful mode).
87 */
88 external?: string[];
89
90 /**
91 * Bundler configuration for {@link main} (effectful mode). Unused
92 * code is tree-shaken. `effect`, alchemy, and `@distilled.cloud` are
93 * marked pure so unused parts prune more aggressively. List extra
94 * packages with `pure.packages`, or disable with `pure: false`.
95 */
96 build?: Bundle.BundleConfig;
97
98 /**
99 * The base MicroVM image to build on top of — either its ARN or another
100 * {@link MicrovmImage} instance. If omitted, the latest AWS-managed `al2023`
101 * base is used (discover bases with {@link microvms.listManagedMicrovmImages}).
102 */
103 baseImage?: string | MicrovmImage;
104
105 /**
106 * The specific version of the base MicroVM image.
107 */
108 baseImageVersion?: string;
109
110 /**
111 * The IAM role Lambda assumes to build the image (read the code artifact,
112 * write build logs) — either its ARN or an {@link Role} instance. When you
113 * pass a `Role`, the required S3 (Assets bucket) and CloudWatch-logs
114 * permissions are granted to it automatically via a binding, so you don't
115 * have to write the inline policy yourself. Required for all builds.
116 */
117 buildRole?: string | Role;
118
119 /**
120 * **Prebuilt mode.** A code artifact (S3 zip path or ECR image URI) to use
121 * as-is, instead of bundling (`main`) or building a context (`context`).
122 */
123 codeArtifact?: microvms.CodeArtifact;
124
125 /**
126 * A description of the image version.
127 */
128 description?: string;
129
130 /**
131 * Logging configuration for the MicroVM runtime/build. Specify exactly one of
132 * `cloudWatch` or `disabled`.
133 */
134 logging?: microvms.Logging;
135
136 /**
137 * The names/ARNs of egress network connectors available to the MicroVM at
138 * runtime (see {@link NetworkConnector}).
139 */
140 egressNetworkConnectors?: string[];
141
142 /**
143 * The CPU configurations the image supports.
144 */
145 cpuConfigurations?: microvms.CpuConfiguration[];
146
147 /**
148 * The resource requirements (e.g. minimum memory) for the MicroVM.
149 */
150 resources?: microvms.Resources[];
151
152 /**
153 * Additional OS capabilities granted to the MicroVM runtime.
154 */
155 additionalOsCapabilities?: microvms.Capability[];
156
157 /**
158 * Lifecycle hook configuration for the MicroVM and its image.
159 */
160 hooks?: microvms.Hooks;
161
162 /**
163 * Environment variables set in the MicroVM runtime environment. Capability
164 * bindings (e.g. S3/DynamoDB clients) also contribute entries here during
165 * plan; all of them are written to the image's environment.
166 */
167 env?: Record<string, any>;
168
169 /**
170 * Tags to apply to the image.
171 */
172 tags?: Record<string, string>;
173
174 /**
175 * @internal Platform-managed: the bundled runtime entrypoint.
176 */
177 exports?: string[] | Record<string, any>;
178
179 /**
180 * @internal Platform-managed: signals a non-Effect-native (external) image.
181 */
182 isExternal?: boolean;
183}
184
185export interface MicrovmImage extends Resource<
186 "AWS.Lambda.MicrovmImage",
187 MicrovmImageProps,
188 {
189 /** The ARN of the MicroVM image. */
190 imageArn: string;
191 /** The name of the MicroVM image. */
192 name: string;
193 /** The current state of the image (e.g. `CREATED`, `CREATING`). */
194 state: microvms.MicrovmImageState;
195 /** The image version produced by the most recent build. */
196 imageVersion?: string;
197 /** The latest version that built successfully. */
198 latestActiveImageVersion?: string;
199 /** The latest version that failed to build, if any. */
200 latestFailedImageVersion?: string;
201 /** When the image was created (ISO 8601). */
202 createdAt?: string;
203 /** When the image was last updated (ISO 8601). */
204 updatedAt?: string;
205 /**
206 * The resolved code artifact and its build identity hash. `contentHash`
207 * covers only the bundled program / packaged context (no props), so a
208 * content-only edit is detectable at plan time before the props resolve.
209 */
210 codeArtifact?: { uri?: string; hash?: string; contentHash?: string };
211 },
212 {
213 env?: Record<string, any>;
214 policyStatements?: PolicyStatement[];
215 },
216 Providers
217> {}
218
219export type MicrovmImageServices = Credentials | Region;
220export type MicrovmImageShape = Main<MicrovmImageServices>;
221
222/**
223 * A Lambda **MicroVM image** — a Firecracker snapshot that boots a fully
224 * initialized application in milliseconds. The model is *image-then-launch*:
225 * you create an image once (this resource), then launch isolated, stateful
226 * MicroVM instances from it at runtime (one per end-user/session) with the
227 * {@link RunMicrovm} binding from a Lambda Function.
228 *
229 * ### How the build works <!-- api-prose -->
230 *
231 * The build runs **server-side on AWS** — there is no local Docker. You supply
232 * a *code artifact* (a zip of a Dockerfile + your code) and a *base image*
233 * (`baseImage`); AWS runs your Dockerfile on top of the base, initializes
234 * the app, and takes a Firecracker snapshot. The build is asynchronous: the
235 * provider uploads the artifact, calls `CreateMicrovmImage`/`UpdateMicrovmImage`,
236 * and polls until the image reaches `CREATED`/`UPDATED` (or surfaces the build
237 * failure). Build logs stream to CloudWatch at `/aws/lambda/microvms/<name>`.
238 *
239 * Alchemy produces the code artifact for you in three ways, selected by which
240 * prop you set (`main` → `context` → `codeArtifact.uri`):
241 *
242 * - **Effectful** (`main`): write the in-VM HTTP server in TypeScript as an
243 * Effect. Alchemy bundles it (with its capability bindings), generates a
244 * Dockerfile on the MicroVM base, zips both, and uploads to the Assets bucket.
245 * - **External** (`context`/`dockerfile`): bring your own Dockerfile + build
246 * context (any language). Alchemy zips the directory and uploads it.
247 * - **Prebuilt** (`codeArtifact.uri`): point at an existing S3 zip or ECR image
248 * URI; nothing is built or uploaded.
249 *
250 * Re-deploys only trigger a new build when the artifact's content hash or a
251 * build-affecting prop changes; otherwise the image is left untouched.
252 *
253 * ### Prerequisites <!-- api-prose -->
254 *
255 * - A **build role** (`buildRole`) Lambda assumes to read the code artifact and
256 * write build logs. Pass a {@link Role} instance and the required permissions
257 * are granted automatically — see the example below.
258 * - A **bootstrapped Assets bucket** (`alchemy provider aws bootstrap`) for effectful /
259 * external modes, which upload the artifact to S3.
260 * - The account must be **onboarded to the Lambda MicroVM preview**.
261 *
262 *
263 * ### Creating the Build Role
264 * Pass a bare {@link Role} as `buildRole` and the MicroVM image grants
265 * everything it needs via a binding: the trust policy (so Lambda can assume it)
266 * plus the S3 (Assets bucket) and CloudWatch-logs permissions (folded into an
267 * `alchemy-bindings` inline policy). You don't write any policy yourself.
268 * **Example:** Bare build role
269 * ```typescript
270 * const buildRole = yield* AWS.IAM.Role("MicrovmBuildRole", {});
271 *
272 * // Pass the role instance; trust + permissions are attached for you.
273 * const image = yield* AWS.Lambda.MicrovmImage("Sandbox", {
274 * main: import.meta.filename,
275 * buildRole,
276 * });
277 * ```
278 *
279 * ### Effectful MicroVMs
280 * Write the in-VM server in TypeScript. Alchemy bundles `main` and bakes it into
281 * the image; the server listens on `port` (default 8080), which becomes the
282 * MicroVM `endpoint`.
283 * **Example:** In-VM HTTP server (single file)
284 * ```typescript
285 * export default class Sandbox extends AWS.Lambda.MicrovmImage<Sandbox>()(
286 * "Sandbox",
287 * { main: import.meta.filename, buildRole },
288 * Effect.gen(function* () {
289 * return {
290 * fetch: Effect.gen(function* () {
291 * const request = yield* HttpServerRequest;
292 * return HttpServerResponse.text(`hello from ${request.url}`);
293 * }),
294 * };
295 * }),
296 * ) {}
297 * ```
298 *
299 * **Example:** With capability bindings and env vars
300 * ```typescript
301 * export default class Sandbox extends AWS.Lambda.MicrovmImage<Sandbox>()(
302 * "Sandbox",
303 * {
304 * main: import.meta.filename,
305 * buildRole,
306 * runtime: "bun",
307 * env: { LOG_LEVEL: "info" },
308 * },
309 * Effect.gen(function* () {
310 * // bindings are bundled into the image and resolved at runtime
311 * const getObject = yield* AWS.S3.GetObject(bucket);
312 * return {
313 * fetch: Effect.gen(function* () {
314 * const obj = yield* getObject({ key: "data.json" });
315 * return HttpServerResponse.json(yield* obj.json);
316 * }),
317 * };
318 * }).pipe(Effect.provide(AWS.S3.GetObjectHttp)),
319 * ) {}
320 * ```
321 *
322 * **Example:** Class + `.make()` (two files, for a Lambda orchestrator)
323 * When a Lambda Function imports the image to bind its instance operations, keep
324 * the class (a typed handle) and the `.make()` runtime in separate files so the
325 * orchestrator's bundle doesn't pull in the VM's runtime deps.
326 * ```typescript
327 * // sandbox.ts — imported by the orchestrator
328 * export class Sandbox extends AWS.Lambda.MicrovmImage<Sandbox>()("Sandbox") {}
329 *
330 * // sandbox.live.ts — provided on the Stack; bundled into the image.
331 * // Must be the `default` export: the bundler resolves the image entrypoint
332 * // from the code artifact's default export.
333 * export default Sandbox.make(
334 * { main: import.meta.filename, buildRole },
335 * Effect.gen(function* () {
336 * return { fetch: Effect.gen(function* () {
337 * return HttpServerResponse.text("ok");
338 * }) };
339 * }),
340 * );
341 * ```
342 *
343 * ### Tagged RPC
344 * Beyond (or instead of) a raw `fetch` handler, an image can expose a typed
345 * **RPC `Shape`** as the second type parameter. The in-VM runtime serves those
346 * methods over an `/__rpc__/*` protocol and falls through to `fetch` for every
347 * other request, so an image can offer BOTH a typed RPC surface and ordinary
348 * HTTP routes. A caller gets a fully-typed client with {@link connectMicrovm}:
349 * value methods `yield*` as `Effect`s, streaming methods pipe as `Stream`s.
350 * **Example:** Define a tagged-RPC image (RPC + fetch)
351 * ```typescript
352 * // sandbox.ts — the typed handle imported by the orchestrator Lambda
353 * export class Sandbox extends AWS.Lambda.MicrovmImage<
354 * Sandbox,
355 * { hello: (message: string) => Effect.Effect<string> }
356 * >()("Sandbox") {}
357 *
358 * // sandbox.live.ts — provided on the Stack; bundled into the image (default export)
359 * export default Sandbox.make(
360 * { main: import.meta.filename, buildRole },
361 * Effect.gen(function* () {
362 * return {
363 * // RPC method — reached with `connectMicrovm` below
364 * hello: (message: string) => Effect.succeed(`hello, ${message}!`),
365 * // raw HTTP route — reached with a plain HTTPS request to the endpoint
366 * fetch: Effect.gen(function* () {
367 * const request = yield* HttpServerRequest;
368 * const url = new URL(request.url, "http://microvm");
369 * return HttpServerResponse.json({ path: url.pathname });
370 * }),
371 * };
372 * }),
373 * );
374 * ```
375 *
376 * **Example:** Call the RPC method from a Lambda
377 * `connectMicrovm` builds the typed stub over an `HttpClient` (provide
378 * `FetchHttpClient.layer` for the request scope), pointing at the running
379 * MicroVM's `endpoint` and authenticating with the `authToken` headers.
380 * ```typescript
381 * const vm = yield* runMicrovm({});
382 * // ...wait until `vm.state` is RUNNING (poll `getMicrovm`)...
383 * const { authToken } = yield* createAuthToken({
384 * microvmIdentifier: vm.microvmId,
385 * expirationInMinutes: 5,
386 * allowedPorts: [{ port: 8080 }], // the in-VM server's port
387 * });
388 *
389 * const sandbox = yield* AWS.Lambda.connectMicrovm(Sandbox, {
390 * endpoint: vm.endpoint,
391 * authToken,
392 * });
393 * const reply = yield* sandbox.hello("world"); // "hello, world!"
394 * ```
395 *
396 * **Example:** Call the same MicroVM's `fetch` route directly
397 * For the raw HTTP path, send the auth token as request headers via
398 * {@link microvmAuthHeaders}.
399 * ```typescript
400 * const client = yield* HttpClient.HttpClient;
401 * const res = yield* client.get(`https://${vm.endpoint}/echo?message=hi`, {
402 * headers: AWS.Lambda.microvmAuthHeaders(authToken),
403 * });
404 * const body = yield* res.json;
405 * ```
406 *
407 * ### Bundling & Tree-shaking
408 * `main` is bundled with rolldown at deploy time. Unused code is
409 * tree-shaken. `effect`, alchemy, and `@distilled.cloud` are marked
410 * pure so unused parts prune more aggressively. Your app is not
411 * marked pure.
412 *
413 * **Example:** Mark additional packages as pure
414 * Only list packages with no top-level side effects.
415 * ```typescript
416 * {
417 * main: import.meta.url,
418 * build: {
419 * pure: { packages: ["my-lib", "@my-scope/*"] },
420 * },
421 * }
422 * ```
423 *
424 * **Example:** Turn it off
425 * ```typescript
426 * {
427 * main: import.meta.url,
428 * build: { pure: false },
429 * }
430 * ```
431 *
432 * ### External Images (your own Dockerfile)
433 * Bring a build context directory containing a Dockerfile (any language).
434 * Alchemy zips and uploads it; AWS runs your Dockerfile. Your Dockerfile should
435 * build on a MicroVM-compatible base (e.g.
436 * `FROM public.ecr.aws/lambda/microvms:al2023-minimal`).
437 * **Example:** Flask app from a Dockerfile
438 * ```typescript
439 * const image = yield* AWS.Lambda.MicrovmImage("Flask", {
440 * context: `${import.meta.dirname}/app`, // dir with Dockerfile + app.py
441 * buildRole,
442 * });
443 * ```
444 * **Example:** Custom Dockerfile path within the context
445 * ```typescript
446 * const image = yield* AWS.Lambda.MicrovmImage("Worker", {
447 * context: `${import.meta.dirname}/app`,
448 * dockerfile: "docker/worker.Dockerfile", // relative to `context`
449 * buildRole,
450 * });
451 * ```
452 *
453 * ### Prebuilt Artifacts
454 * Skip the build entirely and point at an artifact you already produced.
455 * **Example:** From an existing S3 zip
456 * ```typescript
457 * const image = yield* AWS.Lambda.MicrovmImage("Prebuilt", {
458 * codeArtifact: { uri: "s3://my-bucket/app.zip" }, // or an ECR image URI
459 * buildRole,
460 * });
461 * ```
462 *
463 * ### Sizing and Base Image
464 * `baseImage` defaults to the latest AWS-managed `al2023` base (discovered
465 * via `listManagedMicrovmImages`). Override it, and tune CPU/memory, explicitly.
466 * **Example:** Pin the base image and resources
467 * ```typescript
468 * const image = yield* AWS.Lambda.MicrovmImage("Sized", {
469 * main: import.meta.filename,
470 * buildRole,
471 * baseImage: "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
472 * cpuConfigurations: [{ architecture: "ARM_64" }],
473 * resources: [{ minimumMemoryInMiB: 2048 }],
474 * });
475 * ```
476 *
477 * ### Logging
478 * **Example:** Stream runtime + build logs to a custom CloudWatch group
479 * ```typescript
480 * const image = yield* AWS.Lambda.MicrovmImage("Logged", {
481 * main: import.meta.filename,
482 * buildRole,
483 * logging: { cloudWatch: { logGroup: "/aws/microvm/my-app" } },
484 * // or disable: logging: { disabled: {} }
485 * });
486 * ```
487 *
488 * ### VPC Egress
489 * Give the MicroVM a managed egress path into your VPC with a
490 * {@link NetworkConnector} (reference it by ARN).
491 * **Example:** Image-level egress connector
492 * ```typescript
493 * const egress = yield* AWS.Lambda.NetworkConnector("Egress", {
494 * subnetIds: [subnet.subnetId],
495 * securityGroupIds: [sg.groupId],
496 * operatorRole: operatorRole.roleArn,
497 * });
498 *
499 * const image = yield* AWS.Lambda.MicrovmImage("Connected", {
500 * main: import.meta.filename,
501 * buildRole,
502 * egressNetworkConnectors: [egress.networkConnectorArn],
503 * });
504 * ```
505 *
506 * ### Launching MicroVMs
507 * The image is just the template. Launch and drive instances from a Lambda
508 * Function using the per-operation bindings ({@link RunMicrovm},
509 * {@link GetMicrovm}, {@link CreateAuthToken}, {@link TerminateMicrovm}, …).
510 * Each binding's IAM policy is scoped to this image automatically.
511 * **Example:** Run a MicroVM, call its RPC, and tear it down
512 * Always terminate the MicroVM you launched — wrap the work in
513 * `Effect.ensuring` so a failure (or a client retry) never leaks a running
514 * MicroVM against your account's memory quota. Give the Function a generous
515 * `timeout` since it waits for the MicroVM to reach RUNNING in-line.
516 * ```typescript
517 * export default class Api extends AWS.Lambda.Function<Api>()(
518 * "Api",
519 * { main: import.meta.filename, functionUrl: true, timeout: Duration.seconds(120) },
520 * Effect.gen(function* () {
521 * const runMicrovm = yield* AWS.Lambda.RunMicrovm(Sandbox);
522 * const getMicrovm = yield* AWS.Lambda.GetMicrovm(Sandbox);
523 * const createAuthToken = yield* AWS.Lambda.CreateAuthToken(Sandbox);
524 * const terminateMicrovm = yield* AWS.Lambda.TerminateMicrovm(Sandbox);
525 * return {
526 * fetch: Effect.gen(function* () {
527 * const vm = yield* runMicrovm({
528 * idlePolicy: {
529 * maxIdleDurationSeconds: 900,
530 * suspendedDurationSeconds: 300,
531 * autoResumeEnabled: true,
532 * },
533 * });
534 * return yield* Effect.gen(function* () {
535 * // wait until the MicroVM is RUNNING before connecting
536 * yield* getMicrovm({ microvmIdentifier: vm.microvmId }).pipe(
537 * Effect.flatMap((m) =>
538 * m.state === "RUNNING"
539 * ? Effect.void
540 * : Effect.fail(new Error(`microvm ${m.state}`)),
541 * ),
542 * Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 30 }),
543 * );
544 * const { authToken } = yield* createAuthToken({
545 * microvmIdentifier: vm.microvmId,
546 * expirationInMinutes: 5,
547 * allowedPorts: [{ port: 8080 }],
548 * });
549 * const sandbox = yield* AWS.Lambda.connectMicrovm(Sandbox, {
550 * endpoint: vm.endpoint,
551 * authToken,
552 * });
553 * const reply = yield* sandbox.hello("world");
554 * return yield* HttpServerResponse.json({ reply });
555 * }).pipe(
556 * // terminate on success OR failure — never leak a running MicroVM
557 * Effect.ensuring(
558 * terminateMicrovm({ microvmIdentifier: vm.microvmId }).pipe(
559 * Effect.ignore,
560 * ),
561 * ),
562 * // the in-VM endpoint calls need an HttpClient for this scope
563 * Effect.provide(FetchHttpClient.layer),
564 * );
565 * }),
566 * };
567 * }).pipe(
568 * Effect.provide(
569 * Layer.mergeAll(
570 * AWS.Lambda.RunMicrovmHttp,
571 * AWS.Lambda.GetMicrovmHttp,
572 * AWS.Lambda.CreateAuthTokenHttp,
573 * AWS.Lambda.TerminateMicrovmHttp,
574 * ),
575 * ),
576 * ),
577 * ) {}
578 * ```
579 *
580 * @resource
581 */
582export const MicrovmImage: Platform<
583 MicrovmImage,
584 MicrovmImageServices,
585 MicrovmImageShape,
586 Server.ProcessContext
587> = Platform(MicrovmImageTypeId, {
588 createRuntimeContext: makeMicrovmRuntimeContext,
589 // When `buildRole` is a Role instance, grant it the build permissions via a
590 // binding so the user doesn't have to author the inline policy.
591 onCreate: (_resource, props: MicrovmImageProps) =>
592 props.buildRole && typeof props.buildRole !== "string"
593 ? props.buildRole
594 .bind`Allow(${_resource}, AWS.Lambda.MicrovmImage.build)`({
595 // Grant the build permissions...
596 policyStatements: buildRolePolicyStatements,
597 // ...and the trust statement, so a build role passed as a bare
598 // `Role` (no `assumeRolePolicyDocument`) can be assumed by Lambda.
599 assumeRolePolicyStatements: [
600 {
601 Effect: "Allow",
602 Principal: { Service: "lambda.amazonaws.com" },
603 Action: ["sts:AssumeRole"],
604 },
605 ],
606 })
607 : Effect.void,
608});