1/** @effect-diagnostics anyUnknownInErrorContext:off */
2
3/**
4 * The `alchemy dev` provider for `AWS.Lambda.MicrovmImage`: builds the image
5 * as a plain LOCAL `docker build` and hands the emulator a pre-built
6 * reference, so an image reload costs what a `docker build` costs — with
7 * real BuildKit layer caching against the user's actual sources.
8 *
9 * The live flow (zip the context → upload to S3 → floci downloads, extracts
10 * to a fresh temp dir, and docker-builds server-side) is faithful to the
11 * real AWS API but hostile to dev: the fresh extract defeated layer caching
12 * and every content edit cost a full image build (~60s measured). Here the
13 * flow is:
14 *
15 * 1. bundle (`main`) or use the user's context/Dockerfile as-is;
16 * 2. `docker build -t alchemy-dev/microvm-<name>:<contentHash>` on the
17 * HOST daemon — unchanged layers cache, so a well-ordered Dockerfile
18 * only rebuilds its tail (the alchemy-generated Dockerfile installs the
19 * runtime BEFORE copying the bundle for exactly this reason);
20 * 3. delegate to the LIVE reconcile with
21 * `codeArtifact: { uri: "docker://<tag>" }` — the alchemy floci fork
22 * (≥ the docker-artifact build) short-circuits its build for that form
23 * and marks the version ACTIVE immediately.
24 *
25 * `RunMicrovm` defaults to the latest ACTIVE version, so every VM booted
26 * after a rebuild runs the new image; running VMs are deliberately NOT
27 * terminated — MicroVM sessions are stateful, and killing a user's live
28 * shell/sandbox on every source edit would be worse than serving the old
29 * code until it re-runs.
30 *
31 * Built on the shared dev-watch skeleton
32 * ([DevWatchProvider](../Local/DevWatchProvider.ts)); the watch trigger is
33 * the shared [ImageSourceTrigger](../Local/ImageSourceTrigger.ts) (`main` →
34 * rolldown module-graph watch, `context` → fs-watch incl. an
35 * outside-context Dockerfile).
36 */
37
38import * as Effect from "effect/Effect";
39import * as FileSystem from "effect/FileSystem";
40import * as Artifacts from "../../Artifacts.ts";
41import * as Path from "effect/Path";
42import * as Stream from "effect/Stream";
43import { AlchemyContext } from "../../AlchemyContext.ts";
44import { getStableContextDir } from "../../Bundle/TempRoot.ts";
45import { Docker, DockerLive } from "../../Docker/Docker.ts";
46import { isInlineDockerfile } from "../../Docker/Dockerfile.ts";
47import { hashDirectory } from "../../Command/Memo.ts";
48import { sha256 } from "../../Util/sha256.ts";
49import {
50 flociProvidersUrl,
51 makeDevWatchProvider,
52} from "../Local/DevWatchProvider.ts";
53import { imageSourceTrigger } from "../Local/ImageSourceTrigger.ts";
54import type { ImageSourceLike } from "../ECR/ImageSource.ts";
55import {
56 buildMicrovmDockerfile,
57 bundleMicrovmProgram,
58 DEFAULT_MICROVM_PORT,
59 MICROVM_BASE_DOCKER_IMAGE,
60} from "./MicrovmBundle.ts";
61import { MicrovmImage, type MicrovmImageProps } from "./MicrovmImage.ts";
62import { MicrovmImageProvider } from "./MicrovmProvider.ts";
63
64/**
65 * The AWS-managed MicroVM base is not publicly pullable; the floci fork
66 * rewrites `FROM public.ecr.aws/lambda/microvms:*` to this Amazon Linux
67 * 2023 mirror in its own builds, and host-side dev builds must match so
68 * both paths produce the same image.
69 */
70const LOCAL_MICROVM_BASE_IMAGE =
71 process.env.FLOCI_MICROVM_BASE_IMAGE ||
72 "public.ecr.aws/amazonlinux/amazonlinux:2023";
73
74const rewriteBaseImage = (dockerfile: string): string =>
75 dockerfile
76 .split("\n")
77 .map((line) => {
78 const trimmed = line.trimStart();
79 if (!/^from\s/i.test(trimmed)) return line;
80 const rest = trimmed.slice(5).trimStart();
81 if (!rest.startsWith(MICROVM_BASE_DOCKER_IMAGE.split(":")[0])) {
82 return line;
83 }
84 const space = rest.indexOf(" ");
85 const suffix = space >= 0 ? rest.slice(space) : "";
86 return `FROM ${LOCAL_MICROVM_BASE_IMAGE}${suffix}`;
87 })
88 .join("\n");
89
90/**
91 * Build the image on the host daemon and return the content-addressed
92 * `docker://` artifact reference for it. The tag embeds the content hash,
93 * so an unchanged build is a pure cache hit AND the live reconcile's
94 * artifact-hash comparison (`sha256(uri:propsId)`) sees content changes.
95 */
96const buildLocalImage = Effect.fn(function* (
97 id: string,
98 news: MicrovmImageProps,
99) {
100 const docker = yield* Docker;
101 const fs = yield* FileSystem.FileSystem;
102 const path = yield* Path.Path;
103 const { dotAlchemy } = yield* AlchemyContext;
104 const runtime = news.runtime ?? "node";
105 const port = news.port ?? DEFAULT_MICROVM_PORT;
106
107 if (news.main) {
108 const { files, hash: bundleHash } = yield* bundleMicrovmProgram({
109 main: news.main,
110 runtime,
111 isExternal: news.isExternal ?? false,
112 external: news.external,
113 port,
114 build: news.build,
115 });
116 const userDockerfile =
117 news.dockerfile !== undefined && isInlineDockerfile(news.dockerfile)
118 ? (news.dockerfile.content as string)
119 : news.dockerfile;
120 const dockerfile = rewriteBaseImage(
121 buildMicrovmDockerfile(userDockerfile, runtime, port),
122 );
123 const contentHash = yield* sha256(`${bundleHash}:${dockerfile}`);
124 const context = yield* getStableContextDir(
125 process.cwd(),
126 dotAlchemy,
127 `${id}-microvm`,
128 );
129 yield* docker.materialize({
130 context,
131 dockerfile,
132 files: files.map((f) => ({ path: f.path, content: f.content })),
133 });
134 const tag = `alchemy-dev/microvm-${id.toLowerCase()}:${contentHash.slice(0, 16)}`;
135 yield* docker.image.build({ context, tag });
136 return tag;
137 }
138
139 if (news.context) {
140 const context = yield* fs.realPath(news.context);
141 const dockerfilePath =
142 news.dockerfile !== undefined && !isInlineDockerfile(news.dockerfile)
143 ? yield* fs.realPath(news.dockerfile)
144 : path.join(context, "Dockerfile");
145 const original = yield* fs.readFileString(dockerfilePath);
146 const rewritten = rewriteBaseImage(original);
147 const contextHash = yield* hashDirectory({ cwd: context });
148 const contentHash = yield* sha256(`${contextHash}:${rewritten}`);
149 // The rewritten Dockerfile lives OUTSIDE the user's context (never
150 // touch their files); `docker build -f` accepts that.
151 const staging = yield* getStableContextDir(
152 process.cwd(),
153 dotAlchemy,
154 `${id}-microvm`,
155 );
156 const rewrittenPath = path.join(staging, "Dockerfile");
157 yield* fs.writeFileString(rewrittenPath, rewritten);
158 const tag = `alchemy-dev/microvm-${id.toLowerCase()}:${contentHash.slice(0, 16)}`;
159 yield* docker.image.build({ context, file: rewrittenPath, tag });
160 return tag;
161 }
162
163 return undefined;
164});
165
166export const FlociMicrovmImageProvider = () =>
167 makeDevWatchProvider<
168 MicrovmImage,
169 MicrovmImageProps,
170 MicrovmImage["Attributes"]
171 >(MicrovmImage, flociProvidersUrl(), {
172 liveProvider: () => MicrovmImageProvider(),
173 services: DockerLive,
174 // The restart surface of the watch loop: everything that changes WHAT
175 // is built (props flow through the reconcile anyway).
176 watchConfigOf: (news, attrs) => {
177 const source = news as ImageSourceLike;
178 return {
179 name: attrs.name,
180 main: source.main,
181 context: source.context,
182 dockerfile: source.dockerfile,
183 codeArtifactUri: news.codeArtifact?.uri,
184 runtime: news.runtime,
185 port: news.port,
186 isExternal: news.isExternal,
187 external: news.external,
188 build: news.build,
189 };
190 },
191 // Mirrors the live diff: the image name is the identity.
192 replaceOn: ({ id: _id, olds, news }) =>
193 Effect.sync(() =>
194 (olds.name ?? null) !== (news.name ?? null)
195 ? { action: "replace" as const }
196 : undefined,
197 ),
198 // Build locally, delegate with a pre-built reference. A user-supplied
199 // `codeArtifact.uri` passes through untouched (already pre-built).
200 transformReconcileNews: ({ id, news }) =>
201 Effect.gen(function* () {
202 const tag = yield* buildLocalImage(id, news);
203 if (tag === undefined) return news;
204 // The live reconcile memoizes its resolved artifact per id for the
205 // run (`Artifacts.cached`) — but the sidecar's artifact store lives
206 // for the whole dev session, so a rebuilt docker:// reference would
207 // read back the FIRST reconcile's artifact and no-op forever. Evict
208 // so the reconcile resolves the fresh reference.
209 yield* (yield* Artifacts.Artifacts).delete(
210 `microvm-image-content:${id}`,
211 );
212 return {
213 ...news,
214 main: undefined,
215 context: undefined,
216 dockerfile: undefined,
217 codeArtifact: { uri: `docker://${tag}` },
218 } satisfies MicrovmImageProps;
219 }),
220 startWatch: (ctx) =>
221 Effect.gen(function* () {
222 const trigger = yield* imageSourceTrigger({
223 id: ctx.id,
224 source: ctx.news as ImageSourceLike,
225 isExternal: ctx.news.isExternal,
226 });
227 yield* trigger.pipe(
228 // The re-run builds (cached) and re-reconciles; floci marks the
229 // new version ACTIVE immediately, and the next RunMicrovm boots
230 // it. Running VMs keep their session (deliberately not killed).
231 Stream.runForEach(() =>
232 Effect.gen(function* () {
233 const startedAt = Date.now();
234 yield* Effect.logInfo(
235 `[alchemy dev] ${ctx.id}: microvm source changed — rebuilding`,
236 );
237 const previous = yield* ctx.currentAttrs;
238 const attrs = yield* ctx.rerunReconcile;
239 if (attrs.codeArtifact?.hash !== previous.codeArtifact?.hash) {
240 yield* Effect.logInfo(
241 `[alchemy dev] ${attrs.name}: microvm image rebuilt (v${attrs.latestActiveImageVersion}) in ${Date.now() - startedAt}ms`,
242 );
243 }
244 }).pipe(
245 Effect.catchCause((cause) =>
246 Effect.logWarning(
247 `[alchemy dev] ${ctx.id}: microvm image rebuild failed`,
248 cause,
249 ),
250 ),
251 ),
252 ),
253 );
254 }),
255 });