| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as FileSystem from "effect/FileSystem"; |
| 3 | import * as Path from "effect/Path"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import type * as rolldown from "rolldown"; |
| 6 | import { AlchemyContext } from "../AlchemyContext.ts"; |
| 7 | import * as Bundle from "../Bundle/Bundle.ts"; |
| 8 | import { |
| 9 | findCwdForBundle, |
| 10 | getStableContextDir, |
| 11 | resolveMainPath, |
| 12 | } from "../Bundle/TempRoot.ts"; |
| 13 | import { hashDirectory } from "../Command/Memo.ts"; |
| 14 | import { sha256Object } from "../Util/sha256.ts"; |
| 15 | import { Docker, type RegistryCredentials } from "./Docker.ts"; |
| 16 | import { 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 | */ |
| 47 | export 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 | */ |
| 93 | export 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 | */ |
| 112 | export 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 | */ |
| 124 | export type ImageSourceProps = |
| 125 | | BundledImageSource |
| 126 | | DockerfileImageSource |
| 127 | | RegistryImageSource; |
| 128 | |
| 129 | /** Loose bag shape used to sniff which source variant a props object is. */ |
| 130 | export interface ImageSourceLike { |
| 131 | main?: string; |
| 132 | handler?: string; |
| 133 | build?: BundledImageSource["build"]; |
| 134 | context?: string; |
| 135 | dockerfile?: string | InlineDockerfile; |
| 136 | image?: string; |
| 137 | } |
| 138 | |
| 139 | export 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 | */ |
| 147 | export 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 | */ |
| 162 | export 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 | */ |
| 198 | export 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 | |
| 213 | export 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 | */ |
| 250 | const 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 | */ |
| 287 | export 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 | */ |
| 334 | export 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}. */ |
| 821 | export interface ContainerImageSource extends Effect.Success< |
| 822 | typeof makeContainerImageSource |
| 823 | > {} |