@alchemy.run/docker 2.0.0-beta.84 → 2.0.0-beta.86

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/README.md +16 -0
  2. package/lib/Bootstrap.d.ts.map +1 -1
  3. package/lib/Bootstrap.js +2 -1
  4. package/lib/Bootstrap.js.map +1 -1
  5. package/lib/BuildCache.d.ts +87 -0
  6. package/lib/BuildCache.d.ts.map +1 -0
  7. package/lib/BuildCache.js +39 -0
  8. package/lib/BuildCache.js.map +1 -0
  9. package/lib/BuildHash.d.ts +20 -3
  10. package/lib/BuildHash.d.ts.map +1 -1
  11. package/lib/BuildHash.js +60 -21
  12. package/lib/BuildHash.js.map +1 -1
  13. package/lib/BuildPlan.d.ts +202 -0
  14. package/lib/BuildPlan.d.ts.map +1 -0
  15. package/lib/BuildPlan.js +514 -0
  16. package/lib/BuildPlan.js.map +1 -0
  17. package/lib/Container.d.ts +122 -1
  18. package/lib/Container.d.ts.map +1 -1
  19. package/lib/Container.js +232 -4
  20. package/lib/Container.js.map +1 -1
  21. package/lib/Context.d.ts +20 -1
  22. package/lib/Context.d.ts.map +1 -1
  23. package/lib/Context.js +15 -0
  24. package/lib/Context.js.map +1 -1
  25. package/lib/Docker.d.ts +113 -1
  26. package/lib/Docker.d.ts.map +1 -1
  27. package/lib/Docker.js +50 -8
  28. package/lib/Docker.js.map +1 -1
  29. package/lib/Dockerfile.d.ts +0 -2
  30. package/lib/Dockerfile.d.ts.map +1 -1
  31. package/lib/Dockerfile.js.map +1 -1
  32. package/lib/DockerfileParser.d.ts +125 -0
  33. package/lib/DockerfileParser.d.ts.map +1 -0
  34. package/lib/DockerfileParser.js +1040 -0
  35. package/lib/DockerfileParser.js.map +1 -0
  36. package/lib/HealthcheckCommand.d.ts.map +1 -1
  37. package/lib/HealthcheckCommand.js.map +1 -1
  38. package/lib/Image.d.ts +69 -13
  39. package/lib/Image.d.ts.map +1 -1
  40. package/lib/Image.js +59 -9
  41. package/lib/Image.js.map +1 -1
  42. package/lib/ImageArchive.d.ts +41 -0
  43. package/lib/ImageArchive.d.ts.map +1 -0
  44. package/lib/ImageArchive.js +54 -0
  45. package/lib/ImageArchive.js.map +1 -0
  46. package/lib/ImageSource.d.ts +151 -8
  47. package/lib/ImageSource.d.ts.map +1 -1
  48. package/lib/ImageSource.js +247 -72
  49. package/lib/ImageSource.js.map +1 -1
  50. package/lib/Network.d.ts +175 -3
  51. package/lib/Network.d.ts.map +1 -1
  52. package/lib/Network.js +296 -8
  53. package/lib/Network.js.map +1 -1
  54. package/lib/Providers.d.ts.map +1 -1
  55. package/lib/Providers.js.map +1 -1
  56. package/lib/Registry.d.ts.map +1 -1
  57. package/lib/Registry.js.map +1 -1
  58. package/lib/RegistryError.d.ts.map +1 -1
  59. package/lib/RegistryError.js.map +1 -1
  60. package/lib/RemoteImage.d.ts.map +1 -1
  61. package/lib/RemoteImage.js +1 -1
  62. package/lib/RemoteImage.js.map +1 -1
  63. package/lib/Service.d.ts +24 -3
  64. package/lib/Service.d.ts.map +1 -1
  65. package/lib/Service.js +154 -12
  66. package/lib/Service.js.map +1 -1
  67. package/lib/ServiceImage.d.ts.map +1 -1
  68. package/lib/ServiceImage.js.map +1 -1
  69. package/lib/ShellWord.d.ts +28 -0
  70. package/lib/ShellWord.d.ts.map +1 -0
  71. package/lib/ShellWord.js +301 -0
  72. package/lib/ShellWord.js.map +1 -0
  73. package/lib/Swarm.d.ts.map +1 -1
  74. package/lib/Swarm.js.map +1 -1
  75. package/lib/Volume.d.ts +24 -1
  76. package/lib/Volume.d.ts.map +1 -1
  77. package/lib/Volume.js +16 -0
  78. package/lib/Volume.js.map +1 -1
  79. package/lib/index.d.ts.map +1 -1
  80. package/lib/index.js.map +1 -1
  81. package/package.json +9 -9
  82. package/src/Bootstrap.ts +2 -0
  83. package/src/BuildCache.ts +111 -0
  84. package/src/BuildHash.ts +85 -26
  85. package/src/BuildPlan.ts +758 -0
  86. package/src/Container.ts +361 -5
  87. package/src/Context.ts +27 -1
  88. package/src/Docker.ts +157 -9
  89. package/src/Dockerfile.ts +0 -2
  90. package/src/DockerfileParser.ts +1152 -0
  91. package/src/Image.ts +75 -15
  92. package/src/ImageArchive.ts +77 -0
  93. package/src/ImageSource.ts +391 -99
  94. package/src/Network.ts +432 -10
  95. package/src/RemoteImage.ts +1 -1
  96. package/src/Service.ts +246 -62
  97. package/src/ShellWord.ts +326 -0
  98. package/src/Volume.ts +33 -1
@@ -1,13 +1,16 @@
1
1
  import { AlchemyContext } from "alchemy/AlchemyContext";
2
2
  import * as Bundle from "alchemy/Bundle/Bundle";
3
3
  import { findCwdForBundle, getStableContextDir, resolveMainPath } from "alchemy/Bundle/TempRoot";
4
- import { hashDirectory } from "alchemy/Command/Memo";
5
4
  import { sha256Object } from "alchemy/Util/sha256";
5
+ import * as Data from "effect/Data";
6
6
  import * as Effect from "effect/Effect";
7
7
  import * as FileSystem from "effect/FileSystem";
8
8
  import * as Path from "effect/Path";
9
+ import type * as Redacted from "effect/Redacted";
9
10
  import * as Schedule from "effect/Schedule";
10
11
  import type * as rolldown from "rolldown";
12
+ import { type BuildCacheOptions, imageBuildCacheOptions } from "./BuildCache.ts";
13
+ import { hashDockerBuildInputs } from "./BuildHash.ts";
11
14
  import { Docker, type RegistryCredentials } from "./Docker.ts";
12
15
  import { isInlineDockerfile, type InlineDockerfile } from "./Dockerfile.ts";
13
16
 
@@ -28,6 +31,9 @@ import { isInlineDockerfile, type InlineDockerfile } from "./Dockerfile.ts";
28
31
  * ENTRYPOINT) and pushed as a derived image. `main` absent → the
29
32
  * environment runs verbatim: `image` is mirrored (docker pull → tag →
30
33
  * push, content-addressed on the ref), a Dockerfile builds as-is.
34
+ * - `main` absent and an {@link ImageAgent} requested → the environment
35
+ * still runs verbatim, and the agent is pushed beside it as an image of
36
+ * its own, for the platform to mount into the environment's container.
31
37
  *
32
38
  * Where images land is an {@link ImageRegistryTarget}: ECR supplies one that
33
39
  * auto-creates a private repository; a plain registry (a local `registry`
@@ -46,6 +52,13 @@ export interface BundledImageSource {
46
52
  * `import.meta.url` from an inline Effect program.
47
53
  */
48
54
  main: string;
55
+ /**
56
+ * @internal Dockerfile lines contributed by mount bindings (see
57
+ * `alchemy/Mount`), inserted after the environment preamble so a
58
+ * program change doesn't reinstall them. Hosts fold these in from their
59
+ * bindings; users don't set them.
60
+ */
61
+ install?: ReadonlyArray<string>;
49
62
  /**
50
63
  * Environment image: used as the generated Dockerfile's `FROM`. Any
51
64
  * registry ref works (private non-ECR registries require docker
@@ -77,16 +90,47 @@ export interface BundledImageSource {
77
90
  * Bundler configuration for the entrypoint. Unused code is tree-shaken.
78
91
  * `effect`, alchemy, and `@distilled.cloud` are marked pure so unused
79
92
  * parts prune more aggressively. List extra packages with
80
- * `pure.packages`, or disable with `pure: false`.
93
+ * `pure.packages`, or disable with `pure: false`. `cache` configures the
94
+ * image build's BuildKit layer cache.
95
+ */
96
+ build?: Bundle.BundleConfig & BuildCacheOptions;
97
+ }
98
+
99
+ /**
100
+ * Build arguments, BuildKit secrets and the layer cache for an image built
101
+ * from the user's own Dockerfile (`context` / `dockerfile`).
102
+ */
103
+ export interface DockerfileBuild extends BuildCacheOptions {
104
+ /**
105
+ * Docker build arguments (`--build-arg`), read by `ARG` instructions.
106
+ * They are part of the image's content hash, so changing one rebuilds the
107
+ * image. Never pass secrets here: build args are visible in the image
108
+ * history. Use {@link secrets} instead.
81
109
  */
82
- build?: Bundle.BundleConfig;
110
+ args?: Record<string, string>;
111
+ /**
112
+ * BuildKit secrets, keyed by id. Mount one in a `RUN` step with
113
+ * `--mount=type=secret,id=<id>` and read it from `/run/secrets/<id>`, or
114
+ * add `env=<NAME>` to expose it as an environment variable for that step.
115
+ *
116
+ * A secret never enters the image, its build history, or `docker`'s argv.
117
+ * Secrets are left out of the image's content hash, so rotating one does
118
+ * not rebuild the image.
119
+ */
120
+ secrets?: Record<string, Redacted.Redacted<string>>;
121
+ }
122
+
123
+ /** The `build` prop of a source built from the user's own Dockerfile. */
124
+ export interface DockerfileBuildOptions {
125
+ /** Build arguments, BuildKit secrets and the layer cache for the Dockerfile build. */
126
+ build?: DockerfileBuild;
83
127
  }
84
128
 
85
129
  /**
86
130
  * Build the image from the user's own Dockerfile and build context — no
87
131
  * Effect program is bundled.
88
132
  */
89
- export interface DockerfileImageSource {
133
+ export interface DockerfileImageSource extends DockerfileBuildOptions {
90
134
  /**
91
135
  * Docker build context directory. Optional when {@link dockerfile} is
92
136
  * inline content (which builds with an empty context).
@@ -123,10 +167,13 @@ export type ImageSourceProps = BundledImageSource | DockerfileImageSource | Regi
123
167
  export interface ImageSourceLike {
124
168
  main?: string;
125
169
  handler?: string;
126
- build?: BundledImageSource["build"];
170
+ /** Bundler config for `main`; Docker build args and secrets for `context`. */
171
+ build?: Bundle.BundleConfig & DockerfileBuild;
127
172
  context?: string;
128
173
  dockerfile?: string | InlineDockerfile;
129
174
  image?: string;
175
+ /** @internal Mount-binding install lines (see {@link BundledImageSource.install}). */
176
+ install?: ReadonlyArray<string>;
130
177
  }
131
178
 
132
179
  export type ImageSourceKind = "main" | "context" | "image";
@@ -146,9 +193,48 @@ export const imageSourceKind = (source: ImageSourceLike): ImageSourceKind | unde
146
193
  ? "context"
147
194
  : undefined;
148
195
 
196
+ /**
197
+ * `build.args` or `build.secrets` set on an image that is not built from the
198
+ * user's own Dockerfile (a `main` program or a pre-built `image`), or
199
+ * `build.cache` set on a pre-built `image`, which is never built.
200
+ */
201
+ export class DockerfileBuildConflict extends Data.TaggedError("DockerfileBuildConflict")<{
202
+ readonly id: string;
203
+ readonly source: "main" | "image";
204
+ readonly option?: "cache";
205
+ }> {
206
+ override get message() {
207
+ return this.option === "cache"
208
+ ? `'${this.id}': 'build.cache' applies to an image Alchemy builds ('main' / 'context' / 'dockerfile'), not to a pre-built 'image'`
209
+ : `'${this.id}': 'build.args' and 'build.secrets' apply to an image built from your own Dockerfile ('context' / 'dockerfile'), not to '${this.source}'`;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Dies with {@link DockerfileBuildConflict} when `build.args` or
215
+ * `build.secrets` are set alongside `main` or `image`, or `build.cache`
216
+ * alongside `image`.
217
+ */
218
+ export const validateDockerfileBuild = (
219
+ id: string,
220
+ source: { main?: string; image?: string; build?: DockerfileBuild },
221
+ ): Effect.Effect<void> =>
222
+ (source.main !== undefined || source.image !== undefined) &&
223
+ (source.build?.args !== undefined || source.build?.secrets !== undefined)
224
+ ? Effect.die(
225
+ new DockerfileBuildConflict({
226
+ id,
227
+ source: source.main !== undefined ? "main" : "image",
228
+ }),
229
+ )
230
+ : source.main === undefined && source.image !== undefined && source.build?.cache
231
+ ? Effect.die(new DockerfileBuildConflict({ id, source: "image", option: "cache" }))
232
+ : Effect.void;
233
+
149
234
  /**
150
235
  * Validate environment-source exclusivity. Dies (plan-time defect) on
151
- * `image`+`dockerfile`, `image`+`context`, or inline-`dockerfile`+`context`.
236
+ * `image`+`dockerfile`, `image`+`context`, inline-`dockerfile`+`context`, or
237
+ * build args/secrets without a Dockerfile build.
152
238
  */
153
239
  export const validateImageSource = (id: string, source: ImageSourceLike): Effect.Effect<void> => {
154
240
  if (source.image !== undefined && source.dockerfile !== undefined) {
@@ -176,7 +262,7 @@ export const validateImageSource = (id: string, source: ImageSourceLike): Effect
176
262
  ),
177
263
  );
178
264
  }
179
- return Effect.void;
265
+ return validateDockerfileBuild(id, source);
180
266
  };
181
267
 
182
268
  /**
@@ -227,10 +313,95 @@ export interface ResolveContainerImageOptions {
227
313
  * generated entry module source.
228
314
  */
229
315
  bootstrap: (importPath: string) => string;
316
+ /**
317
+ * Publish an agent beside an image that has no `main`. See
318
+ * {@link ImageAgent}. Ignored for a `main` source, whose program serves the
319
+ * agent itself.
320
+ */
321
+ agent?: ImageAgent;
230
322
  /** Plan-status session used to emit build/push progress notes. */
231
323
  session: { note: (message: string) => Effect.Effect<void> };
232
324
  }
233
325
 
326
+ /**
327
+ * A program the platform runs inside a container whose image has none of its
328
+ * own (`image` or `context`), so it can drive the container: a sandbox agent
329
+ * that runs commands and serves files inside it.
330
+ *
331
+ * The image itself is never changed. The agent is bundled and pushed as an
332
+ * image of its own, to the same repository, under an `agent-` tag. Run as a
333
+ * container, that image copies the agent and its Bun runtime into
334
+ * {@link IMAGE_AGENT_DIR} and exits. A platform mounts a volume there, shares
335
+ * it with the image's container, and starts that container with
336
+ * {@link imageAgentEntryPoint}.
337
+ */
338
+ export interface ImageAgent {
339
+ /**
340
+ * Module that exports `bootstrap(): Promise<void>`, which runs the agent.
341
+ * A path or a `file:` URL.
342
+ */
343
+ main: string;
344
+ }
345
+
346
+ /**
347
+ * Where an {@link ImageAgent}'s image copies the agent and its runtime, and
348
+ * where the image's container finds them.
349
+ */
350
+ export const IMAGE_AGENT_DIR = "/opt/alchemy/sandbox";
351
+
352
+ /**
353
+ * The entry point that starts an {@link ImageAgent} from
354
+ * {@link IMAGE_AGENT_DIR}. Bun starts through the loader that came with it,
355
+ * so the image's own libc (glibc, another musl, or none) is never consulted
356
+ * and the image needs nothing installed. The container's command arrives as
357
+ * the agent's arguments.
358
+ */
359
+ export const imageAgentEntryPoint: ReadonlyArray<string> = [
360
+ `${IMAGE_AGENT_DIR}/ld-musl.so.1`,
361
+ "--library-path",
362
+ `${IMAGE_AGENT_DIR}/lib`,
363
+ `${IMAGE_AGENT_DIR}/bun`,
364
+ `${IMAGE_AGENT_DIR}/index.mjs`,
365
+ ];
366
+
367
+ /**
368
+ * The image the agent's runtime comes from. The musl build of Bun runs on any
369
+ * Linux image once its loader and C++ runtime come with it.
370
+ */
371
+ const AGENT_RUNTIME_IMAGE = "oven/bun:1-alpine";
372
+
373
+ /** Where the agent's bundle sits inside its own image. */
374
+ const AGENT_BUNDLE_DIR = "/agent";
375
+
376
+ /**
377
+ * The Dockerfile of an {@link ImageAgent}'s image: the runtime's image plus
378
+ * the agent's bundle. Its entry point copies the runtime, the loader, the
379
+ * C++ runtime and the bundle into {@link IMAGE_AGENT_DIR}, where the platform
380
+ * mounted a volume, and exits.
381
+ */
382
+ const AGENT_DOCKERFILE = `${[
383
+ `FROM ${AGENT_RUNTIME_IMAGE}`,
384
+ `COPY index.mjs ${AGENT_BUNDLE_DIR}/index.mjs`,
385
+ `COPY *.js ${AGENT_BUNDLE_DIR}/`,
386
+ `ENTRYPOINT ${JSON.stringify([
387
+ "/bin/sh",
388
+ "-ec",
389
+ [
390
+ `mkdir -p ${IMAGE_AGENT_DIR}/lib`,
391
+ `cp /usr/local/bin/bun ${IMAGE_AGENT_DIR}/bun`,
392
+ `cp /lib/ld-musl-*.so.1 ${IMAGE_AGENT_DIR}/ld-musl.so.1`,
393
+ `cp /usr/lib/libstdc++.so.6 /usr/lib/libgcc_s.so.1 ${IMAGE_AGENT_DIR}/lib/`,
394
+ `cp ${AGENT_BUNDLE_DIR}/* ${IMAGE_AGENT_DIR}/`,
395
+ // The image's container may run as any user.
396
+ `chmod -R a+rX ${IMAGE_AGENT_DIR}`,
397
+ ].join(" && "),
398
+ ])}`,
399
+ ].join("\n")}\n`;
400
+
401
+ /** The generated entry of an {@link ImageAgent}: import the module and run it. */
402
+ const agentEntry = (importPath: string) =>
403
+ `import { bootstrap } from ${JSON.stringify(importPath)};\nawait bootstrap();\n`;
404
+
234
405
  /**
235
406
  * Resolve the Dockerfile path for a `context` source: always relative to the
236
407
  * cwd (absolute paths pass through), defaulting to `${context}/Dockerfile`.
@@ -287,24 +458,103 @@ export const computeStaticSourceHash = Effect.fn(function* (
287
458
  return (yield* sha256Object({
288
459
  dockerfile: source.dockerfile.content,
289
460
  platform: platform ?? "linux/amd64",
461
+ // Absent when unset, so existing hashes hold. Secrets are excluded.
462
+ buildArgs: source.build?.args,
290
463
  })).slice(0, 16);
291
464
  }
292
- const fs = yield* FileSystem.FileSystem;
293
465
  const { context, dockerfile } = yield* resolveContextPaths({
294
466
  context: source.context!,
295
467
  dockerfile: source.dockerfile,
296
468
  });
297
- const contextHash = yield* hashDirectory({ cwd: context });
298
- const dockerfileContent = yield* fs.readFileString(dockerfile);
299
- return (yield* sha256Object({
300
- contextHash,
301
- dockerfile: dockerfileContent,
302
- platform: platform ?? "linux/amd64",
303
- })).slice(0, 16);
469
+ // Hash what Docker receives: the context `.dockerignore` keeps (never a
470
+ // lockfile above it), the Dockerfile, platform and build args.
471
+ return (yield* hashDockerBuildInputs(
472
+ {
473
+ context,
474
+ dockerfile,
475
+ platform: platform ?? "linux/amd64",
476
+ buildArgs: source.build?.args,
477
+ },
478
+ "effective",
479
+ )).slice(0, 16);
304
480
  }
305
481
  return undefined;
306
482
  });
307
483
 
484
+ /**
485
+ * The exact rolldown input/output options `bundleMainProgram` hands to
486
+ * `Bundle.build` — shared with {@link watchMain} so a dev watcher's
487
+ * `Bundle.watch` observes the identical module graph.
488
+ */
489
+ export const mainBundleOptions = (
490
+ source: BundledImageSource,
491
+ entry: string,
492
+ cwd: string,
493
+ plugins?: rolldown.RolldownPluginOption,
494
+ ): {
495
+ inputOptions: rolldown.InputOptions;
496
+ outputOptions: rolldown.OutputOptions;
497
+ } => ({
498
+ inputOptions: {
499
+ ...source.build?.input,
500
+ input: entry,
501
+ cwd,
502
+ platform: "node",
503
+ // The container runs on `bun`; keep `bun`/`bun:*` external (the
504
+ // runtime provides them) and resolve the `bun` export condition
505
+ // so `@effect/platform-bun` picks its Bun implementations.
506
+ external: ["bun", "bun:*", ...((source.build?.input?.external as string[] | undefined) ?? [])],
507
+ resolve: {
508
+ conditionNames: [...Bundle.BUN_CONDITION_NAMES],
509
+ ...source.build?.input?.resolve,
510
+ },
511
+ plugins: [source.build?.input?.plugins, plugins],
512
+ },
513
+ outputOptions: {
514
+ ...source.build?.output,
515
+ format: "esm",
516
+ sourcemap: source.build?.output?.sourcemap ?? false,
517
+ minify: source.build?.output?.minify ?? false,
518
+ entryFileNames: "index.mjs",
519
+ },
520
+ });
521
+
522
+ /**
523
+ * Bundle the Effect program behind a `main` source: every emitted file
524
+ * (entry + shared chunks) and the bundle hash. Needs no Docker engine, so
525
+ * builders that are not Docker (`BuildPlan`) share it.
526
+ */
527
+ export const bundleMainProgram = Effect.fn(function* (options: {
528
+ source: BundledImageSource;
529
+ isExternal?: boolean;
530
+ bootstrap: (importPath: string) => string;
531
+ }) {
532
+ const { source } = options;
533
+ const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
534
+ const realMain = yield* resolveMainPath(source.main);
535
+ const cwd = yield* findCwdForBundle(realMain);
536
+
537
+ const buildBundle = Effect.fn(function* (entry: string, plugins?: rolldown.RolldownPluginOption) {
538
+ const opts = mainBundleOptions(source, entry, cwd, plugins);
539
+ return yield* Bundle.build(opts.inputOptions, opts.outputOptions, source.build);
540
+ });
541
+
542
+ const bundleOutput = options.isExternal
543
+ ? yield* buildBundle(realMain)
544
+ : yield* buildBundle(realMain, virtualEntryPlugin(options.bootstrap));
545
+
546
+ // Return every emitted file (entry + shared chunks). Dynamic imports in
547
+ // the Bun HTTP server / AWS SDK split into chunks; dropping any of them
548
+ // crashes the container with `Cannot find module './chunk-XXX.js'`.
549
+ const files = bundleOutput.files.map((file) => ({
550
+ path: file.path,
551
+ content:
552
+ typeof file.content === "string" ? new TextEncoder().encode(file.content) : file.content,
553
+ }));
554
+
555
+ return { files, hash: bundleOutput.hash };
556
+ });
557
+
308
558
  /**
309
559
  * Init-time constructor for the image-source resolver. Resolves the services
310
560
  * that are only available at provider-layer construction (Docker, the
@@ -317,82 +567,6 @@ export const makeContainerImageSource = Effect.gen(function* () {
317
567
  const { dotAlchemy } = yield* AlchemyContext;
318
568
  const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
319
569
 
320
- /**
321
- * The exact rolldown input/output options `bundleProgram` hands to
322
- * `Bundle.build` — shared with {@link watchMain} so a dev watcher's
323
- * `Bundle.watch` observes the identical module graph.
324
- */
325
- const mainBundleOptions = (
326
- source: BundledImageSource,
327
- entry: string,
328
- cwd: string,
329
- plugins?: rolldown.RolldownPluginOption,
330
- ): {
331
- inputOptions: rolldown.InputOptions;
332
- outputOptions: rolldown.OutputOptions;
333
- } => ({
334
- inputOptions: {
335
- ...source.build?.input,
336
- input: entry,
337
- cwd,
338
- platform: "node",
339
- // The container runs on `bun`; keep `bun`/`bun:*` external (the
340
- // runtime provides them) and resolve the `bun` export condition
341
- // so `@effect/platform-bun` picks its Bun implementations.
342
- external: [
343
- "bun",
344
- "bun:*",
345
- ...((source.build?.input?.external as string[] | undefined) ?? []),
346
- ],
347
- resolve: {
348
- conditionNames: [...Bundle.BUN_CONDITION_NAMES],
349
- ...source.build?.input?.resolve,
350
- },
351
- plugins: [source.build?.input?.plugins, plugins],
352
- },
353
- outputOptions: {
354
- ...source.build?.output,
355
- format: "esm",
356
- sourcemap: source.build?.output?.sourcemap ?? false,
357
- minify: source.build?.output?.minify ?? false,
358
- entryFileNames: "index.mjs",
359
- },
360
- });
361
-
362
- /** Bundle the Effect program behind a `main` source. */
363
- const bundleProgram = Effect.fn(function* (options: {
364
- source: BundledImageSource;
365
- isExternal?: boolean;
366
- bootstrap: (importPath: string) => string;
367
- }) {
368
- const { source } = options;
369
- const realMain = yield* resolveMainPath(source.main);
370
- const cwd = yield* findCwdForBundle(realMain);
371
-
372
- const buildBundle = Effect.fn(function* (
373
- entry: string,
374
- plugins?: rolldown.RolldownPluginOption,
375
- ) {
376
- const opts = mainBundleOptions(source, entry, cwd, plugins);
377
- return yield* Bundle.build(opts.inputOptions, opts.outputOptions, source.build);
378
- });
379
-
380
- const bundleOutput = options.isExternal
381
- ? yield* buildBundle(realMain)
382
- : yield* buildBundle(realMain, virtualEntryPlugin(options.bootstrap));
383
-
384
- // Return every emitted file (entry + shared chunks). Dynamic imports in
385
- // the Bun HTTP server / AWS SDK split into chunks; dropping any of them
386
- // crashes the container with `Cannot find module './chunk-XXX.js'`.
387
- const files = bundleOutput.files.map((file) => ({
388
- path: file.path,
389
- content:
390
- typeof file.content === "string" ? new TextEncoder().encode(file.content) : file.content,
391
- }));
392
-
393
- return { files, hash: bundleOutput.hash };
394
- });
395
-
396
570
  /**
397
571
  * Bundle a `main` source and compute its content-addressed code hash.
398
572
  *
@@ -409,7 +583,7 @@ export const makeContainerImageSource = Effect.gen(function* () {
409
583
  port?: number;
410
584
  platform: string;
411
585
  }) {
412
- const bundled = yield* bundleProgram({
586
+ const bundled = yield* bundleMainProgram({
413
587
  source: options.source,
414
588
  isExternal: options.isExternal,
415
589
  bootstrap: options.bootstrap,
@@ -423,14 +597,15 @@ export const makeContainerImageSource = Effect.gen(function* () {
423
597
  const isPathEnv = df !== undefined && !isInlineDockerfile(df);
424
598
  let envIdentity: Record<string, string> = {};
425
599
  if (isPathEnv) {
426
- const fs = yield* FileSystem.FileSystem;
427
600
  const { context, dockerfile } = yield* resolveContextPaths({
428
601
  context: options.source.context!,
429
602
  dockerfile: df,
430
603
  });
431
604
  envIdentity = {
432
- envContextHash: yield* hashDirectory({ cwd: context }),
433
- envDockerfile: yield* fs.readFileString(dockerfile),
605
+ envContextHash: yield* hashDockerBuildInputs(
606
+ { context, dockerfile, platform: options.platform },
607
+ "effective",
608
+ ),
434
609
  };
435
610
  }
436
611
  const dockerfile = generateDockerfile(
@@ -460,10 +635,11 @@ export const makeContainerImageSource = Effect.gen(function* () {
460
635
  envFrom !== undefined
461
636
  ? `FROM ${envFrom}`
462
637
  : source.dockerfile !== undefined && isInlineDockerfile(source.dockerfile)
463
- ? String(source.dockerfile.content).trimEnd()
638
+ ? (source.dockerfile.content as string).trimEnd()
464
639
  : `FROM ${source.image ?? "oven/bun:1"}`;
465
640
  const lines = [
466
641
  preamble,
642
+ ...(source.install ?? []),
467
643
  `WORKDIR /app`,
468
644
  `COPY index.mjs /app/index.mjs`,
469
645
  // Copy any additional rolldown chunks (`chunk-XXX.js`,
@@ -478,6 +654,30 @@ export const makeContainerImageSource = Effect.gen(function* () {
478
654
  return `${lines.join("\n")}\n`;
479
655
  };
480
656
 
657
+ /**
658
+ * Bundle an {@link ImageAgent} and compute the content-addressed hash of
659
+ * its image: the bundle, the Dockerfile and the platform. `resolve` and
660
+ * `hash` share this, so the plan-time hash and the pushed tag agree.
661
+ */
662
+ const bundleAgent = Effect.fn(function* (agent: ImageAgent, platform: string) {
663
+ const bundled = yield* bundleMainProgram({
664
+ source: { main: agent.main },
665
+ isExternal: false,
666
+ bootstrap: agentEntry,
667
+ });
668
+ const hash = (yield* sha256Object({
669
+ bundleHash: bundled.hash,
670
+ dockerfile: AGENT_DOCKERFILE,
671
+ platform,
672
+ })).slice(0, 16);
673
+ return { bundled, hash };
674
+ });
675
+
676
+ /** One hash for an image and the agent published beside it. */
677
+ const hashWithAgent = Effect.fn(function* (environment: string, agent: string) {
678
+ return (yield* sha256Object({ environment, agent })).slice(0, 16);
679
+ });
680
+
481
681
  /**
482
682
  * Build (or mirror) and push an image to `target`, pushing with the
483
683
  * target's credentials or — when it has none — the build machine's own
@@ -514,6 +714,7 @@ export const makeContainerImageSource = Effect.gen(function* () {
514
714
  context: string;
515
715
  dockerfile?: string;
516
716
  platform?: string;
717
+ build?: DockerfileBuild;
517
718
  },
518
719
  ) {
519
720
  const credentials = yield* target.credentials;
@@ -522,17 +723,20 @@ export const makeContainerImageSource = Effect.gen(function* () {
522
723
  context: options.context,
523
724
  file: options.dockerfile,
524
725
  platform: options.platform,
726
+ "build-arg": options.build?.args,
727
+ secrets: options.build?.secrets,
728
+ ...imageBuildCacheOptions(options.build, options.imageUri, credentials),
525
729
  });
526
730
  yield* push(options.imageUri, credentials, options.platform);
527
731
  return options.imageUri;
528
732
  });
529
733
 
530
734
  /**
531
- * Resolve the image for a props bag: resolve the target repository,
735
+ * Resolve the image of a props bag's own source: resolve the target repository,
532
736
  * compute the content-addressed tag, then build/mirror + push only when
533
737
  * that exact tag is not already in the registry (crash-safe convergence).
534
738
  */
535
- const resolve = Effect.fnUntraced(function* <R>(
739
+ const resolveSource = Effect.fnUntraced(function* <R>(
536
740
  options: ResolveContainerImageOptions,
537
741
  target: ImageRegistryTarget<R>,
538
742
  ) {
@@ -587,11 +791,18 @@ export const makeContainerImageSource = Effect.gen(function* () {
587
791
  });
588
792
  envFrom = `alchemy-env-${id.toLowerCase()}:${codeHash}`;
589
793
  yield* session.note(`Building environment image for ${id}...`);
794
+ // The user's Dockerfile holds the expensive steps, so the layer
795
+ // cache applies to this stage rather than the bundle layered on top.
590
796
  yield* docker.image.build({
591
797
  context: env.context,
592
798
  file: env.dockerfile,
593
799
  tag: envFrom,
594
800
  platform,
801
+ ...imageBuildCacheOptions(
802
+ source.build,
803
+ imageUri,
804
+ source.build?.cache ? yield* target.credentials : undefined,
805
+ ),
595
806
  });
596
807
  }
597
808
  const finalDockerfile =
@@ -616,6 +827,7 @@ export const makeContainerImageSource = Effect.gen(function* () {
616
827
  imageUri,
617
828
  context: contextDir,
618
829
  platform,
830
+ build: envFrom === undefined ? { cache: source.build?.cache } : undefined,
619
831
  });
620
832
  yield* session.note(`Pushed ${imageUri}`);
621
833
  return { imageUri, repositoryUri, codeHash };
@@ -676,6 +888,7 @@ export const makeContainerImageSource = Effect.gen(function* () {
676
888
  imageUri,
677
889
  context: contextDir,
678
890
  platform,
891
+ build: source.build,
679
892
  });
680
893
  yield* session.note(`Pushed ${imageUri}`);
681
894
  return { imageUri, repositoryUri, codeHash };
@@ -698,11 +911,80 @@ export const makeContainerImageSource = Effect.gen(function* () {
698
911
  context,
699
912
  dockerfile,
700
913
  platform,
914
+ build: source.build,
701
915
  });
702
916
  yield* session.note(`Pushed ${imageUri}`);
703
917
  return { imageUri, repositoryUri, codeHash };
704
918
  });
705
919
 
920
+ /**
921
+ * Publish an {@link ImageAgent} as an image of its own, `agent-<hash>` in
922
+ * the target repository, unless that tag is already there.
923
+ */
924
+ const resolveAgent = Effect.fnUntraced(function* <R>(
925
+ options: {
926
+ id: string;
927
+ agent: ImageAgent;
928
+ platform: string;
929
+ repositoryUri: string;
930
+ session: ResolveContainerImageOptions["session"];
931
+ },
932
+ target: ImageRegistryTarget<R>,
933
+ ) {
934
+ const { id, platform, session } = options;
935
+ yield* session.note(`Bundling ${id} sandbox agent...`);
936
+ const { bundled, hash } = yield* bundleAgent(options.agent, platform);
937
+ const tag = `agent-${hash}`;
938
+ const imageUri = `${options.repositoryUri}:${tag}`;
939
+ if (yield* target.hasTag(tag)) {
940
+ return { imageUri, hash };
941
+ }
942
+ const contextDir = yield* getStableContextDir(dotAlchemy, dotAlchemy, `${id}-agent`);
943
+ yield* docker.materialize({
944
+ context: contextDir,
945
+ dockerfile: AGENT_DOCKERFILE,
946
+ files: bundled.files.map((file, index) => ({
947
+ path: index === 0 ? "index.mjs" : file.path,
948
+ content: file.content,
949
+ })),
950
+ });
951
+ yield* session.note(`Building sandbox agent image ${imageUri}...`);
952
+ yield* buildAndPush(target, { imageUri, context: contextDir, platform });
953
+ yield* session.note(`Pushed ${imageUri}`);
954
+ return { imageUri, hash };
955
+ });
956
+
957
+ /**
958
+ * Resolve the image for a props bag, and the {@link ImageAgent} published
959
+ * beside it when one is asked for. The image is the user's own, unchanged;
960
+ * `codeHash` then covers both images.
961
+ */
962
+ const resolve = Effect.fnUntraced(function* <R>(
963
+ options: ResolveContainerImageOptions,
964
+ target: ImageRegistryTarget<R>,
965
+ ) {
966
+ const image = yield* resolveSource(options, target);
967
+ if (options.agent === undefined || imageSourceKind(options.source) === "main") {
968
+ return { ...image, agent: undefined };
969
+ }
970
+ const published = yield* resolveAgent(
971
+ {
972
+ id: options.id,
973
+ agent: options.agent,
974
+ platform: options.platform ?? "linux/amd64",
975
+ repositoryUri: image.repositoryUri,
976
+ session: options.session,
977
+ },
978
+ target,
979
+ );
980
+ return {
981
+ imageUri: image.imageUri,
982
+ repositoryUri: image.repositoryUri,
983
+ codeHash: yield* hashWithAgent(image.codeHash, published.hash),
984
+ agent: { imageUri: published.imageUri },
985
+ };
986
+ });
987
+
706
988
  /**
707
989
  * Content hash for ANY source kind without building or pushing an image.
708
990
  *
@@ -719,9 +1001,19 @@ export const makeContainerImageSource = Effect.gen(function* () {
719
1001
  port?: number;
720
1002
  isExternal?: boolean;
721
1003
  bootstrap: (importPath: string) => string;
1004
+ agent?: ImageAgent;
722
1005
  }) {
723
1006
  const platform = options.platform ?? "linux/amd64";
724
- if (imageSourceKind(options.source) === "main") {
1007
+ const kind = imageSourceKind(options.source);
1008
+ if (options.agent !== undefined && kind !== undefined && kind !== "main") {
1009
+ // The image and its agent are two images. One hash covers both, so a
1010
+ // new agent plans an update just as a new image does.
1011
+ const environment = yield* computeStaticSourceHash(options.source, platform);
1012
+ if (environment === undefined) return undefined;
1013
+ const agent = yield* bundleAgent(options.agent, platform);
1014
+ return yield* hashWithAgent(environment, agent.hash);
1015
+ }
1016
+ if (kind === "main") {
725
1017
  // Unresolved inline environment content (an Output) can't be hashed
726
1018
  // at plan time — return undefined so the diff defers to reconcile.
727
1019
  const df = options.source.dockerfile;