1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import * as Schedule from "effect/Schedule";
5import type * as rolldown from "rolldown";
6import { AlchemyContext } from "../AlchemyContext.ts";
7import * as Bundle from "../Bundle/Bundle.ts";
8import {
9 findCwdForBundle,
10 getStableContextDir,
11 resolveMainPath,
12} from "../Bundle/TempRoot.ts";
13import { hashDirectory } from "../Command/Memo.ts";
14import { sha256Object } from "../Util/sha256.ts";
15import { Docker, type RegistryCredentials } from "./Docker.ts";
16import { isInlineDockerfile, type InlineDockerfile } from "./Dockerfile.ts";
17
18/**
19 * INTERNAL — registry-agnostic container image-source machinery shared by
20 * the container platforms (`AWS.ECS.*`, `AWS.EKS.*` via ECR, and
21 * `Kubernetes.*` workloads pushing to any registry).
22 *
23 * NOT exported from any barrel. Consumers import the module path directly.
24 *
25 * A platform's props separate the ENVIRONMENT (what the container is) from
26 * the PROGRAM (`main` — "and run my bundled Effect program in it"):
27 *
28 * - environment (at most one): `image` (registry ref) | `context` +
29 * `dockerfile`-as-path (docker build) | inline `dockerfile` content
30 * (`Dockerfile.inline`) | the default bun base when `main` stands alone.
31 * - `main` present → the bundle is injected into the environment (COPY +
32 * ENTRYPOINT) and pushed as a derived image. `main` absent → the
33 * environment runs verbatim: `image` is mirrored (docker pull → tag →
34 * push, content-addressed on the ref), a Dockerfile builds as-is.
35 *
36 * Where images land is an {@link ImageRegistryTarget}: ECR supplies one that
37 * auto-creates a private repository; a plain registry (a local `registry`
38 * container, GHCR, Docker Hub, …) supplies one that pushes to
39 * `<server>/<name>`.
40 */
41
42/**
43 * Bundle an Effect program into a generated image. Alchemy bundles `main`
44 * with rolldown and bakes it into a Dockerfile generated from the
45 * environment (`image`, `dockerfile`, or the default bun base).
46 */
47export interface BundledImageSource {
48 /**
49 * Module entrypoint for the bundled program. This should typically be
50 * `import.meta.url` from an inline Effect program.
51 */
52 main: string;
53 /**
54 * Environment image: used as the generated Dockerfile's `FROM`. Any
55 * registry ref works (private non-ECR registries require docker
56 * credentials on the build machine); the image must be able to run the
57 * bun runtime. Exclusive with {@link dockerfile} / {@link context}.
58 * @default "oven/bun:1"
59 */
60 image?: string;
61 /**
62 * Environment Dockerfile: a string is a PATH (built in {@link context},
63 * then the bundle is layered on top in a second stage); an
64 * {@link InlineDockerfile} is inline content used as the environment
65 * preamble (built with no context — its own `COPY`s are unsupported).
66 * The resulting environment must be able to run the bun runtime.
67 * Exclusive with {@link image}.
68 */
69 dockerfile?: string | InlineDockerfile;
70 /**
71 * Build context for a path {@link dockerfile} environment. Exclusive
72 * with {@link image} and inline dockerfiles.
73 */
74 context?: string;
75 /**
76 * Named export to load from `main`.
77 * @default "default"
78 */
79 handler?: string;
80 /**
81 * Bundler configuration for the entrypoint. Unused code is tree-shaken.
82 * `effect`, alchemy, and `@distilled.cloud` are marked pure so unused
83 * parts prune more aggressively. List extra packages with
84 * `pure.packages`, or disable with `pure: false`.
85 */
86 build?: Bundle.BundleConfig;
87}
88
89/**
90 * Build the image from the user's own Dockerfile and build context — no
91 * Effect program is bundled.
92 */
93export interface DockerfileImageSource {
94 /**
95 * Docker build context directory. Optional when {@link dockerfile} is
96 * inline content (which builds with an empty context).
97 */
98 context?: string;
99 /**
100 * Path to the Dockerfile relative to the cwd (NOT the context), or
101 * {@link InlineDockerfile} content. The Dockerfile must define its own
102 * `CMD`/`ENTRYPOINT` (no program is injected).
103 * @default `${context}/Dockerfile`
104 */
105 dockerfile?: string | InlineDockerfile;
106}
107
108/**
109 * Run a pre-built registry image. Registry-backed platforms may mirror it
110 * into their own registry (ECR: docker pull → tag → push).
111 */
112export interface RegistryImageSource {
113 /**
114 * A pre-built image reference, e.g.
115 * `public.ecr.aws/docker/library/busybox:stable`.
116 */
117 image: string;
118}
119
120/**
121 * The image-source union. Discriminated by presence: exactly one of
122 * `main`, `context`, or `image`.
123 */
124export type ImageSourceProps =
125 | BundledImageSource
126 | DockerfileImageSource
127 | RegistryImageSource;
128
129/** Loose bag shape used to sniff which source variant a props object is. */
130export interface ImageSourceLike {
131 main?: string;
132 handler?: string;
133 build?: BundledImageSource["build"];
134 context?: string;
135 dockerfile?: string | InlineDockerfile;
136 image?: string;
137}
138
139export type ImageSourceKind = "main" | "context" | "image";
140
141/**
142 * Which image source a props bag declares. `main` always wins (the other
143 * fields then describe its ENVIRONMENT); without `main`, `image` is the
144 * mirrored-verbatim source and any `context`/`dockerfile` (path or inline)
145 * is an external docker build.
146 */
147export const imageSourceKind = (
148 source: ImageSourceLike,
149): ImageSourceKind | undefined =>
150 source.main !== undefined
151 ? "main"
152 : source.image !== undefined
153 ? "image"
154 : source.context !== undefined || source.dockerfile !== undefined
155 ? "context"
156 : undefined;
157
158/**
159 * Validate environment-source exclusivity. Dies (plan-time defect) on
160 * `image`+`dockerfile`, `image`+`context`, or inline-`dockerfile`+`context`.
161 */
162export const validateImageSource = (
163 id: string,
164 source: ImageSourceLike,
165): Effect.Effect<void> => {
166 if (source.image !== undefined && source.dockerfile !== undefined) {
167 return Effect.die(
168 new Error(
169 `'${id}': 'image' and 'dockerfile' are both set — declare exactly one environment source (an 'image' ref, or a Dockerfile)`,
170 ),
171 );
172 }
173 if (source.image !== undefined && source.context !== undefined) {
174 return Effect.die(
175 new Error(
176 `'${id}': 'image' and 'context' are both set — declare exactly one environment source`,
177 ),
178 );
179 }
180 if (
181 source.dockerfile !== undefined &&
182 isInlineDockerfile(source.dockerfile) &&
183 source.context !== undefined
184 ) {
185 return Effect.die(
186 new Error(
187 `'${id}': inline 'dockerfile' content builds with no context — use a path dockerfile with 'context', or drop 'context'`,
188 ),
189 );
190 }
191 return Effect.void;
192};
193
194/**
195 * Where a resolved image is pushed. Each field is an Effect so a target can
196 * create resources lazily (ECR creates the repository on first use).
197 */
198export interface ImageRegistryTarget<R = never> {
199 /** The repository URI images are tagged into (`<repositoryUri>:<hash>`). */
200 readonly repositoryUri: Effect.Effect<string, any, R>;
201 /**
202 * Whether the content-addressed tag is already in the registry. `true`
203 * skips the build and push entirely.
204 */
205 readonly hasTag: (tag: string) => Effect.Effect<boolean, any, R>;
206 /**
207 * Push credentials. `undefined` pushes with the build machine's own
208 * Docker configuration (`docker login`, credential helpers).
209 */
210 readonly credentials: Effect.Effect<RegistryCredentials | undefined, any, R>;
211}
212
213export interface ResolveContainerImageOptions {
214 /** Logical resource id — keys the stable build-context directory. */
215 id: string;
216 /**
217 * The props bag carrying the image source fields (`main` / `context` /
218 * `image` plus their modifiers).
219 */
220 source: ImageSourceLike;
221 /**
222 * Target image platform.
223 * @default "linux/amd64"
224 */
225 platform?: string;
226 /**
227 * Port the generated Dockerfile should `ENV PORT=` + `EXPOSE`
228 * (`main` source only).
229 */
230 port?: number;
231 /**
232 * True when the resource was declared without an inline Effect impl —
233 * `main` is then bundled as-is without the virtual-entry bootstrap.
234 */
235 isExternal?: boolean;
236 /**
237 * The virtual-entry bootstrap wrapped around `main` for Effect-native
238 * programs: receives the resolved entry import path and returns the
239 * generated entry module source.
240 */
241 bootstrap: (importPath: string) => string;
242 /** Plan-status session used to emit build/push progress notes. */
243 session: { note: (message: string) => Effect.Effect<void> };
244}
245
246/**
247 * Resolve the Dockerfile path for a `context` source: always relative to the
248 * cwd (absolute paths pass through), defaulting to `${context}/Dockerfile`.
249 */
250const resolveContextPaths = Effect.fn(function* (source: {
251 context: string | undefined;
252 dockerfile?: string;
253}) {
254 const fs = yield* FileSystem.FileSystem;
255 const path = yield* Path.Path;
256 const context = path.resolve(source.context ?? ".");
257 const dockerfile = source.dockerfile
258 ? path.resolve(source.dockerfile)
259 : path.join(context, "Dockerfile");
260 if (!(yield* fs.exists(context))) {
261 return yield* Effect.die(
262 new Error(`Docker build context does not exist: ${context}`),
263 );
264 }
265 if (!(yield* fs.exists(dockerfile))) {
266 return yield* Effect.die(
267 new Error(`Dockerfile does not exist: ${dockerfile}`),
268 );
269 }
270 return { context, dockerfile };
271});
272
273/**
274 * Content hash for the sources whose identity is computable WITHOUT running
275 * a bundler:
276 *
277 * - `context` — hash of the build-context directory + Dockerfile content +
278 * platform.
279 * - `image` — hash of the image reference + platform (re-mirror only when
280 * the ref changes).
281 * - `main` — `undefined`: the hash comes from the bundle output, so it is
282 * only known inside `resolve`.
283 *
284 * Providers use this in `diff` to surface content drift (files changed under
285 * an unchanged `context` path) as an update.
286 */
287export const computeStaticSourceHash = Effect.fn(function* (
288 source: ImageSourceLike,
289 platform?: string,
290) {
291 const kind = imageSourceKind(source);
292 if (kind === "image") {
293 return (yield* sha256Object({
294 image: source.image!,
295 platform: platform ?? "linux/amd64",
296 })).slice(0, 16);
297 }
298 if (kind === "context") {
299 if (
300 source.dockerfile !== undefined &&
301 isInlineDockerfile(source.dockerfile)
302 ) {
303 // Inline content builds with no context; an unresolved (Output)
304 // content can't be hashed at plan time — defer to reconcile.
305 if (typeof source.dockerfile.content !== "string") return undefined;
306 return (yield* sha256Object({
307 dockerfile: source.dockerfile.content,
308 platform: platform ?? "linux/amd64",
309 })).slice(0, 16);
310 }
311 const fs = yield* FileSystem.FileSystem;
312 const { context, dockerfile } = yield* resolveContextPaths({
313 context: source.context!,
314 dockerfile: source.dockerfile,
315 });
316 const contextHash = yield* hashDirectory({ cwd: context });
317 const dockerfileContent = yield* fs.readFileString(dockerfile);
318 return (yield* sha256Object({
319 contextHash,
320 dockerfile: dockerfileContent,
321 platform: platform ?? "linux/amd64",
322 })).slice(0, 16);
323 }
324 return undefined;
325});
326
327/**
328 * Init-time constructor for the image-source resolver. Resolves the services
329 * that are only available at provider-layer construction (Docker, the
330 * `.alchemy` directory, the rolldown virtual-entry plugin) and returns
331 * `resolve` (build/mirror + push to an {@link ImageRegistryTarget}), `hash`,
332 * and `watchMain`.
333 */
334export const makeContainerImageSource = Effect.gen(function* () {
335 const docker = yield* Docker;
336 const { dotAlchemy } = yield* AlchemyContext;
337 const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
338
339 /**
340 * The exact rolldown input/output options `bundleProgram` hands to
341 * `Bundle.build` — shared with {@link watchMain} so a dev watcher's
342 * `Bundle.watch` observes the identical module graph.
343 */
344 const mainBundleOptions = (
345 source: BundledImageSource,
346 entry: string,
347 cwd: string,
348 plugins?: rolldown.RolldownPluginOption,
349 ): {
350 inputOptions: rolldown.InputOptions;
351 outputOptions: rolldown.OutputOptions;
352 } => ({
353 inputOptions: {
354 ...source.build?.input,
355 input: entry,
356 cwd,
357 platform: "node",
358 // The container runs on `bun`; keep `bun`/`bun:*` external (the
359 // runtime provides them) and resolve the `bun` export condition
360 // so `@effect/platform-bun` picks its Bun implementations.
361 external: [
362 "bun",
363 "bun:*",
364 ...((source.build?.input?.external as string[] | undefined) ?? []),
365 ],
366 resolve: {
367 conditionNames: [...Bundle.BUN_CONDITION_NAMES],
368 ...source.build?.input?.resolve,
369 },
370 plugins: [source.build?.input?.plugins, plugins],
371 },
372 outputOptions: {
373 ...source.build?.output,
374 format: "esm",
375 sourcemap: source.build?.output?.sourcemap ?? false,
376 minify: source.build?.output?.minify ?? false,
377 entryFileNames: "index.mjs",
378 },
379 });
380
381 /** Bundle the Effect program behind a `main` source. */
382 const bundleProgram = Effect.fn(function* (options: {
383 source: BundledImageSource;
384 isExternal?: boolean;
385 bootstrap: (importPath: string) => string;
386 }) {
387 const { source } = options;
388 const realMain = yield* resolveMainPath(source.main);
389 const cwd = yield* findCwdForBundle(realMain);
390
391 const buildBundle = Effect.fn(function* (
392 entry: string,
393 plugins?: rolldown.RolldownPluginOption,
394 ) {
395 const opts = mainBundleOptions(source, entry, cwd, plugins);
396 return yield* Bundle.build(
397 opts.inputOptions,
398 opts.outputOptions,
399 source.build,
400 );
401 });
402
403 const bundleOutput = options.isExternal
404 ? yield* buildBundle(realMain)
405 : yield* buildBundle(realMain, virtualEntryPlugin(options.bootstrap));
406
407 // Return every emitted file (entry + shared chunks). Dynamic imports in
408 // the Bun HTTP server / AWS SDK split into chunks; dropping any of them
409 // crashes the container with `Cannot find module './chunk-XXX.js'`.
410 const files = bundleOutput.files.map((file) => ({
411 path: file.path,
412 content:
413 typeof file.content === "string"
414 ? new TextEncoder().encode(file.content)
415 : file.content,
416 }));
417
418 return { files, hash: bundleOutput.hash };
419 });
420
421 /**
422 * Bundle a `main` source and compute its content-addressed code hash.
423 *
424 * The hash covers the FULL image identity: the bundle output (which
425 * includes the generated bootstrap entry, so bootstrap-template changes
426 * invalidate it), the generated Dockerfile, and the target platform.
427 * `resolve` and `hash` share this so the plan-time diff hash and the
428 * pushed image tag always agree.
429 */
430 const computeMainCodeHash = Effect.fn(function* (options: {
431 source: BundledImageSource;
432 isExternal?: boolean;
433 bootstrap: (importPath: string) => string;
434 port?: number;
435 platform: string;
436 }) {
437 const bundled = yield* bundleProgram({
438 source: options.source,
439 isExternal: options.isExternal,
440 bootstrap: options.bootstrap,
441 });
442 // A path-`dockerfile` environment is built as a separate local stage in
443 // `resolve`; hash its identity (context files + Dockerfile content)
444 // explicitly since the generated Dockerfile only references the local
445 // env tag. Inline environments flow through the generated Dockerfile
446 // text itself; `image` environments through its FROM line.
447 const df = options.source.dockerfile;
448 const isPathEnv = df !== undefined && !isInlineDockerfile(df);
449 let envIdentity: Record<string, string> = {};
450 if (isPathEnv) {
451 const fs = yield* FileSystem.FileSystem;
452 const { context, dockerfile } = yield* resolveContextPaths({
453 context: options.source.context!,
454 dockerfile: df,
455 });
456 envIdentity = {
457 envContextHash: yield* hashDirectory({ cwd: context }),
458 envDockerfile: yield* fs.readFileString(dockerfile),
459 };
460 }
461 const dockerfile = generateDockerfile(
462 options.source,
463 options.port,
464 isPathEnv ? "<env>" : undefined,
465 );
466 const codeHash = (yield* sha256Object({
467 bundleHash: bundled.hash,
468 dockerfile,
469 platform: options.platform,
470 ...envIdentity,
471 })).slice(0, 16);
472 return { bundled, dockerfile, codeHash };
473 });
474
475 /**
476 * Generated Dockerfile for a bundled `main` program. The environment
477 * preamble is, in order of precedence: `envFrom` (a locally-built
478 * environment tag from a path-`dockerfile` two-stage build), inline
479 * `dockerfile` content (already resolved), the `image` ref, or the
480 * default bun base (`oven/bun` is Docker-Hub only — there is no
481 * `docker/library/bun` and no `public.ecr.aws/oven/bun`).
482 */
483 const generateDockerfile = (
484 source: BundledImageSource,
485 port?: number,
486 envFrom?: string,
487 ) => {
488 const preamble =
489 envFrom !== undefined
490 ? `FROM ${envFrom}`
491 : source.dockerfile !== undefined &&
492 isInlineDockerfile(source.dockerfile)
493 ? String(source.dockerfile.content).trimEnd()
494 : `FROM ${source.image ?? "oven/bun:1"}`;
495 const lines = [
496 preamble,
497 `WORKDIR /app`,
498 `COPY index.mjs /app/index.mjs`,
499 // Copy any additional rolldown chunks (`chunk-XXX.js`,
500 // `BunServices-YYY.js`, …). Non-trivial bundles always emit at
501 // least one; minimal bundles emit none and the COPY no-ops.
502 `COPY *.js /app/`,
503 ];
504 if (port !== undefined) {
505 lines.push(`ENV PORT=${String(port)}`, `EXPOSE ${String(port)}`);
506 }
507 lines.push(`ENTRYPOINT ["bun", "/app/index.mjs"]`);
508 return `${lines.join("\n")}\n`;
509 };
510
511 /**
512 * Build (or mirror) and push an image to `target`, pushing with the
513 * target's credentials or — when it has none — the build machine's own
514 * Docker login.
515 */
516 const push = (
517 imageUri: string,
518 credentials: RegistryCredentials | undefined,
519 platform?: string,
520 ) =>
521 (credentials === undefined
522 ? docker.run([
523 "image",
524 "push",
525 ...(platform !== undefined ? ["--platform", platform] : []),
526 imageUri,
527 ])
528 : docker.image.push(imageUri, credentials, platform)
529 ).pipe(
530 // Pushes are idempotent; transient registry-transport failures
531 // (Docker Desktop's embedded proxy timing out under load, registry
532 // 503s, credential helper contention) resolve on a bounded re-attempt.
533 Effect.retry({
534 while: (): boolean => true,
535 schedule: Schedule.exponential("2 seconds"),
536 times: 3,
537 }),
538 );
539
540 const buildAndPush = Effect.fnUntraced(function* <R>(
541 target: ImageRegistryTarget<R>,
542 options: {
543 imageUri: string;
544 context: string;
545 dockerfile?: string;
546 platform?: string;
547 },
548 ) {
549 const credentials = yield* target.credentials;
550 yield* docker.image.build({
551 tag: options.imageUri,
552 context: options.context,
553 file: options.dockerfile,
554 platform: options.platform,
555 });
556 yield* push(options.imageUri, credentials);
557 return options.imageUri;
558 });
559
560 /**
561 * Resolve the image for a props bag: resolve the target repository,
562 * compute the content-addressed tag, then build/mirror + push only when
563 * that exact tag is not already in the registry (crash-safe convergence).
564 */
565 const resolve = Effect.fnUntraced(function* <R>(
566 options: ResolveContainerImageOptions,
567 target: ImageRegistryTarget<R>,
568 ) {
569 const { id, source, session } = options;
570 const platform = options.platform ?? "linux/amd64";
571 const kind = imageSourceKind(source);
572 if (kind === undefined) {
573 return yield* Effect.die(
574 new Error(
575 `'${id}' must declare exactly one image source: 'main' (bundled Effect program), 'context' (Dockerfile build), or 'image' (registry reference)`,
576 ),
577 );
578 }
579
580 yield* validateImageSource(id, source);
581
582 const repositoryUri = yield* target.repositoryUri;
583
584 if (kind === "main") {
585 // Bundle → hash → (skip if pushed) → materialize generated Dockerfile
586 // → build + push.
587 const df = source.dockerfile;
588 if (
589 df !== undefined &&
590 isInlineDockerfile(df) &&
591 typeof df.content !== "string"
592 ) {
593 return yield* Effect.die(
594 new Error(
595 `'${id}': inline dockerfile content did not resolve to a string — Outputs in Dockerfile.inline must be resolvable at deploy time`,
596 ),
597 );
598 }
599 yield* session.note(`Bundling ${id} program...`);
600 const { bundled, dockerfile, codeHash } = yield* computeMainCodeHash({
601 source: source as BundledImageSource,
602 isExternal: options.isExternal,
603 bootstrap: options.bootstrap,
604 port: options.port,
605 platform,
606 });
607 const imageUri = `${repositoryUri}:${codeHash}`;
608
609 if (yield* target.hasTag(codeHash)) {
610 return { imageUri, repositoryUri, codeHash };
611 }
612
613 // A path-`dockerfile` environment builds first as a local stage in the
614 // USER's context (so its COPYs resolve), then the generated Dockerfile
615 // FROMs the local tag and layers the bundle on top.
616 let envFrom: string | undefined;
617 if (df !== undefined && !isInlineDockerfile(df)) {
618 const env = yield* resolveContextPaths({
619 context: source.context!,
620 dockerfile: df,
621 });
622 envFrom = `alchemy-env-${id.toLowerCase()}:${codeHash}`;
623 yield* session.note(`Building environment image for ${id}...`);
624 yield* docker.image.build({
625 context: env.context,
626 file: env.dockerfile,
627 tag: envFrom,
628 platform,
629 });
630 }
631 const finalDockerfile =
632 envFrom === undefined
633 ? dockerfile
634 : generateDockerfile(
635 source as BundledImageSource,
636 options.port,
637 envFrom,
638 );
639
640 const realMain = yield* resolveMainPath(
641 (source as BundledImageSource).main,
642 );
643 const contextDir = yield* getStableContextDir(
644 realMain,
645 dotAlchemy,
646 `${id}-image`,
647 );
648 yield* docker.materialize({
649 context: contextDir,
650 dockerfile: finalDockerfile,
651 // Entry chunk becomes `index.mjs`; all other chunks keep their
652 // emitted `*.js` names so the entry's relative imports resolve.
653 files: bundled.files.map((file, index) => ({
654 path: index === 0 ? "index.mjs" : file.path,
655 content: file.content,
656 })),
657 });
658 yield* session.note(`Building container image ${imageUri}...`);
659 yield* buildAndPush(target, {
660 imageUri,
661 context: contextDir,
662 platform,
663 });
664 yield* session.note(`Pushed ${imageUri}`);
665 return { imageUri, repositoryUri, codeHash };
666 }
667
668 if (kind === "image") {
669 // Mirror: pull → tag → push. Content-addressed on the image ref, so
670 // an already-mirrored ref is a no-op.
671 const ref = (source as RegistryImageSource).image;
672 const codeHash = (yield* computeStaticSourceHash(source, platform))!;
673 const imageUri = `${repositoryUri}:${codeHash}`;
674
675 if (yield* target.hasTag(codeHash)) {
676 return { imageUri, repositoryUri, codeHash };
677 }
678
679 yield* session.note(`Pulling container image ${ref}...`);
680 // A registry pull can wedge indefinitely under Docker Hub throttling /
681 // credential-helper contention — bound it so a stuck pull fails the
682 // deploy loudly instead of hanging the plan.
683 yield* docker.image.pull(ref, platform).pipe(Effect.timeout("4 minutes"));
684 yield* docker.image.tag(ref, imageUri);
685 yield* session.note(`Pushing mirrored image ${imageUri}...`);
686 const credentials = yield* target.credentials;
687 // Pin the platform on push: with the containerd image store a bare
688 // push of a multi-arch tag sends every locally-present variant — a
689 // stale other-arch variant in the local cache would reach the
690 // registry and the container would crash with `exec format error`.
691 yield* push(imageUri, credentials, platform);
692 yield* session.note(`Pushed ${imageUri}`);
693 return { imageUri, repositoryUri, codeHash };
694 }
695
696 // kind === "context": docker build the user's Dockerfile — from a path
697 // (in their context) or from inline content (empty stable context).
698 const externalDf = (source as DockerfileImageSource).dockerfile;
699 if (externalDf !== undefined && isInlineDockerfile(externalDf)) {
700 if (typeof externalDf.content !== "string") {
701 return yield* Effect.die(
702 new Error(
703 `'${id}': inline dockerfile content did not resolve to a string — Outputs in Dockerfile.inline must be resolvable at deploy time`,
704 ),
705 );
706 }
707 const codeHash = (yield* computeStaticSourceHash(source, platform))!;
708 const imageUri = `${repositoryUri}:${codeHash}`;
709 if (yield* target.hasTag(codeHash)) {
710 return { imageUri, repositoryUri, codeHash };
711 }
712 const contextDir = yield* getStableContextDir(
713 dotAlchemy,
714 dotAlchemy,
715 `${id}-image`,
716 );
717 yield* docker.materialize({
718 context: contextDir,
719 dockerfile: externalDf.content,
720 files: [],
721 });
722 yield* session.note(`Building container image ${imageUri}...`);
723 yield* buildAndPush(target, {
724 imageUri,
725 context: contextDir,
726 platform,
727 });
728 yield* session.note(`Pushed ${imageUri}`);
729 return { imageUri, repositoryUri, codeHash };
730 }
731
732 const { context, dockerfile } = yield* resolveContextPaths({
733 context: (source as DockerfileImageSource).context!,
734 dockerfile: externalDf,
735 });
736 const codeHash = (yield* computeStaticSourceHash(source, platform))!;
737 const imageUri = `${repositoryUri}:${codeHash}`;
738
739 if (yield* target.hasTag(codeHash)) {
740 return { imageUri, repositoryUri, codeHash };
741 }
742
743 yield* session.note(`Building container image ${imageUri}...`);
744 yield* buildAndPush(target, {
745 imageUri,
746 context,
747 dockerfile,
748 platform,
749 });
750 yield* session.note(`Pushed ${imageUri}`);
751 return { imageUri, repositoryUri, codeHash };
752 });
753
754 /**
755 * Content hash for ANY source kind without building or pushing an image.
756 *
757 * For `main` sources this runs the bundler (bootstrap entry included) so
758 * the hash reflects the exact image `resolve` would push — bootstrap
759 * template changes and user-code edits both surface as drift. Providers
760 * call this from `diff` and compare against `output.code.hash`; static
761 * sources (`context` / `image`) delegate to
762 * {@link computeStaticSourceHash}.
763 */
764 const hash = Effect.fn(function* (options: {
765 source: ImageSourceLike;
766 platform?: string;
767 port?: number;
768 isExternal?: boolean;
769 bootstrap: (importPath: string) => string;
770 }) {
771 const platform = options.platform ?? "linux/amd64";
772 if (imageSourceKind(options.source) === "main") {
773 // Unresolved inline environment content (an Output) can't be hashed
774 // at plan time — return undefined so the diff defers to reconcile.
775 const df = options.source.dockerfile;
776 if (
777 df !== undefined &&
778 isInlineDockerfile(df) &&
779 typeof df.content !== "string"
780 ) {
781 return undefined;
782 }
783 const { codeHash } = yield* computeMainCodeHash({
784 source: options.source as BundledImageSource,
785 isExternal: options.isExternal,
786 bootstrap: options.bootstrap,
787 port: options.port,
788 platform,
789 });
790 return codeHash;
791 }
792 return yield* computeStaticSourceHash(options.source, platform);
793 });
794
795 /**
796 * The rolldown watch plan for a `main` source — the exact input/output
797 * options `resolve` bundles with, reusable verbatim with `Bundle.watch`
798 * so a dev watcher observes the identical module graph and rebuild
799 * triggers.
800 */
801 const watchMain = Effect.fn(function* (options: {
802 source: BundledImageSource;
803 isExternal?: boolean;
804 bootstrap: (importPath: string) => string;
805 }) {
806 const realMain = yield* resolveMainPath(options.source.main);
807 const cwd = yield* findCwdForBundle(realMain);
808 const opts = mainBundleOptions(
809 options.source,
810 realMain,
811 cwd,
812 options.isExternal ? undefined : virtualEntryPlugin(options.bootstrap),
813 );
814 return { ...opts, extra: options.source.build };
815 });
816
817 return { resolve, hash, watchMain };
818});
819
820/** The resolver service returned by {@link makeContainerImageSource}. */
821export interface ContainerImageSource extends Effect.Success<
822 typeof makeContainerImageSource
823> {}