@alchemy.run/docker 0.0.0 → 2.0.0-beta.85

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