@alchemy.run/docker 2.0.0-beta.85 → 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 (93) 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 +9 -1
  10. package/lib/BuildHash.d.ts.map +1 -1
  11. package/lib/BuildHash.js +51 -19
  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 +24 -1
  18. package/lib/Container.d.ts.map +1 -1
  19. package/lib/Container.js +14 -0
  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 +33 -0
  26. package/lib/Docker.d.ts.map +1 -1
  27. package/lib/Docker.js +23 -2
  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 +40 -12
  39. package/lib/Image.d.ts.map +1 -1
  40. package/lib/Image.js +41 -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 +100 -11
  47. package/lib/ImageSource.d.ts.map +1 -1
  48. package/lib/ImageSource.js +219 -73
  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.map +1 -1
  62. package/lib/Service.d.ts.map +1 -1
  63. package/lib/Service.js.map +1 -1
  64. package/lib/ServiceImage.d.ts.map +1 -1
  65. package/lib/ServiceImage.js.map +1 -1
  66. package/lib/ShellWord.d.ts +28 -0
  67. package/lib/ShellWord.d.ts.map +1 -0
  68. package/lib/ShellWord.js +301 -0
  69. package/lib/ShellWord.js.map +1 -0
  70. package/lib/Swarm.d.ts.map +1 -1
  71. package/lib/Swarm.js.map +1 -1
  72. package/lib/Volume.d.ts +24 -1
  73. package/lib/Volume.d.ts.map +1 -1
  74. package/lib/Volume.js +16 -0
  75. package/lib/Volume.js.map +1 -1
  76. package/lib/index.d.ts.map +1 -1
  77. package/lib/index.js.map +1 -1
  78. package/package.json +9 -9
  79. package/src/Bootstrap.ts +2 -0
  80. package/src/BuildCache.ts +111 -0
  81. package/src/BuildHash.ts +73 -24
  82. package/src/BuildPlan.ts +758 -0
  83. package/src/Container.ts +31 -1
  84. package/src/Context.ts +27 -1
  85. package/src/Docker.ts +56 -2
  86. package/src/Dockerfile.ts +0 -2
  87. package/src/DockerfileParser.ts +1152 -0
  88. package/src/Image.ts +45 -14
  89. package/src/ImageArchive.ts +77 -0
  90. package/src/ImageSource.ts +322 -103
  91. package/src/Network.ts +432 -10
  92. package/src/ShellWord.ts +326 -0
  93. package/src/Volume.ts +33 -1
@@ -1,7 +1,6 @@
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";
6
5
  import * as Data from "effect/Data";
7
6
  import * as Effect from "effect/Effect";
@@ -10,6 +9,8 @@ import * as Path from "effect/Path";
10
9
  import type * as Redacted from "effect/Redacted";
11
10
  import * as Schedule from "effect/Schedule";
12
11
  import type * as rolldown from "rolldown";
12
+ import { type BuildCacheOptions, imageBuildCacheOptions } from "./BuildCache.ts";
13
+ import { hashDockerBuildInputs } from "./BuildHash.ts";
13
14
  import { Docker, type RegistryCredentials } from "./Docker.ts";
14
15
  import { isInlineDockerfile, type InlineDockerfile } from "./Dockerfile.ts";
15
16
 
@@ -30,6 +31,9 @@ import { isInlineDockerfile, type InlineDockerfile } from "./Dockerfile.ts";
30
31
  * ENTRYPOINT) and pushed as a derived image. `main` absent → the
31
32
  * environment runs verbatim: `image` is mirrored (docker pull → tag →
32
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.
33
37
  *
34
38
  * Where images land is an {@link ImageRegistryTarget}: ECR supplies one that
35
39
  * auto-creates a private repository; a plain registry (a local `registry`
@@ -48,6 +52,13 @@ export interface BundledImageSource {
48
52
  * `import.meta.url` from an inline Effect program.
49
53
  */
50
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>;
51
62
  /**
52
63
  * Environment image: used as the generated Dockerfile's `FROM`. Any
53
64
  * registry ref works (private non-ECR registries require docker
@@ -79,16 +90,17 @@ export interface BundledImageSource {
79
90
  * Bundler configuration for the entrypoint. Unused code is tree-shaken.
80
91
  * `effect`, alchemy, and `@distilled.cloud` are marked pure so unused
81
92
  * parts prune more aggressively. List extra packages with
82
- * `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.
83
95
  */
84
- build?: Bundle.BundleConfig;
96
+ build?: Bundle.BundleConfig & BuildCacheOptions;
85
97
  }
86
98
 
87
99
  /**
88
- * Build arguments and BuildKit secrets for an image built from the user's
89
- * own Dockerfile (`context` / `dockerfile`).
100
+ * Build arguments, BuildKit secrets and the layer cache for an image built
101
+ * from the user's own Dockerfile (`context` / `dockerfile`).
90
102
  */
91
- export interface DockerfileBuild {
103
+ export interface DockerfileBuild extends BuildCacheOptions {
92
104
  /**
93
105
  * Docker build arguments (`--build-arg`), read by `ARG` instructions.
94
106
  * They are part of the image's content hash, so changing one rebuilds the
@@ -110,7 +122,7 @@ export interface DockerfileBuild {
110
122
 
111
123
  /** The `build` prop of a source built from the user's own Dockerfile. */
112
124
  export interface DockerfileBuildOptions {
113
- /** Build arguments and BuildKit secrets for the Dockerfile build. */
125
+ /** Build arguments, BuildKit secrets and the layer cache for the Dockerfile build. */
114
126
  build?: DockerfileBuild;
115
127
  }
116
128
 
@@ -160,6 +172,8 @@ export interface ImageSourceLike {
160
172
  context?: string;
161
173
  dockerfile?: string | InlineDockerfile;
162
174
  image?: string;
175
+ /** @internal Mount-binding install lines (see {@link BundledImageSource.install}). */
176
+ install?: ReadonlyArray<string>;
163
177
  }
164
178
 
165
179
  export type ImageSourceKind = "main" | "context" | "image";
@@ -181,20 +195,25 @@ export const imageSourceKind = (source: ImageSourceLike): ImageSourceKind | unde
181
195
 
182
196
  /**
183
197
  * `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`.
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.
185
200
  */
186
201
  export class DockerfileBuildConflict extends Data.TaggedError("DockerfileBuildConflict")<{
187
202
  readonly id: string;
188
203
  readonly source: "main" | "image";
204
+ readonly option?: "cache";
189
205
  }> {
190
206
  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}'`;
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}'`;
192
210
  }
193
211
  }
194
212
 
195
213
  /**
196
214
  * Dies with {@link DockerfileBuildConflict} when `build.args` or
197
- * `build.secrets` are set alongside `main` or `image`.
215
+ * `build.secrets` are set alongside `main` or `image`, or `build.cache`
216
+ * alongside `image`.
198
217
  */
199
218
  export const validateDockerfileBuild = (
200
219
  id: string,
@@ -208,7 +227,9 @@ export const validateDockerfileBuild = (
208
227
  source: source.main !== undefined ? "main" : "image",
209
228
  }),
210
229
  )
211
- : Effect.void;
230
+ : source.main === undefined && source.image !== undefined && source.build?.cache
231
+ ? Effect.die(new DockerfileBuildConflict({ id, source: "image", option: "cache" }))
232
+ : Effect.void;
212
233
 
213
234
  /**
214
235
  * Validate environment-source exclusivity. Dies (plan-time defect) on
@@ -292,10 +313,95 @@ export interface ResolveContainerImageOptions {
292
313
  * generated entry module source.
293
314
  */
294
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;
295
322
  /** Plan-status session used to emit build/push progress notes. */
296
323
  session: { note: (message: string) => Effect.Effect<void> };
297
324
  }
298
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
+
299
405
  /**
300
406
  * Resolve the Dockerfile path for a `context` source: always relative to the
301
407
  * cwd (absolute paths pass through), defaulting to `${context}/Dockerfile`.
@@ -356,23 +462,99 @@ export const computeStaticSourceHash = Effect.fn(function* (
356
462
  buildArgs: source.build?.args,
357
463
  })).slice(0, 16);
358
464
  }
359
- const fs = yield* FileSystem.FileSystem;
360
465
  const { context, dockerfile } = yield* resolveContextPaths({
361
466
  context: source.context!,
362
467
  dockerfile: source.dockerfile,
363
468
  });
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);
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);
372
480
  }
373
481
  return undefined;
374
482
  });
375
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
+
376
558
  /**
377
559
  * Init-time constructor for the image-source resolver. Resolves the services
378
560
  * that are only available at provider-layer construction (Docker, the
@@ -385,82 +567,6 @@ export const makeContainerImageSource = Effect.gen(function* () {
385
567
  const { dotAlchemy } = yield* AlchemyContext;
386
568
  const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
387
569
 
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
570
  /**
465
571
  * Bundle a `main` source and compute its content-addressed code hash.
466
572
  *
@@ -477,7 +583,7 @@ export const makeContainerImageSource = Effect.gen(function* () {
477
583
  port?: number;
478
584
  platform: string;
479
585
  }) {
480
- const bundled = yield* bundleProgram({
586
+ const bundled = yield* bundleMainProgram({
481
587
  source: options.source,
482
588
  isExternal: options.isExternal,
483
589
  bootstrap: options.bootstrap,
@@ -491,14 +597,15 @@ export const makeContainerImageSource = Effect.gen(function* () {
491
597
  const isPathEnv = df !== undefined && !isInlineDockerfile(df);
492
598
  let envIdentity: Record<string, string> = {};
493
599
  if (isPathEnv) {
494
- const fs = yield* FileSystem.FileSystem;
495
600
  const { context, dockerfile } = yield* resolveContextPaths({
496
601
  context: options.source.context!,
497
602
  dockerfile: df,
498
603
  });
499
604
  envIdentity = {
500
- envContextHash: yield* hashDirectory({ cwd: context }),
501
- envDockerfile: yield* fs.readFileString(dockerfile),
605
+ envContextHash: yield* hashDockerBuildInputs(
606
+ { context, dockerfile, platform: options.platform },
607
+ "effective",
608
+ ),
502
609
  };
503
610
  }
504
611
  const dockerfile = generateDockerfile(
@@ -532,6 +639,7 @@ export const makeContainerImageSource = Effect.gen(function* () {
532
639
  : `FROM ${source.image ?? "oven/bun:1"}`;
533
640
  const lines = [
534
641
  preamble,
642
+ ...(source.install ?? []),
535
643
  `WORKDIR /app`,
536
644
  `COPY index.mjs /app/index.mjs`,
537
645
  // Copy any additional rolldown chunks (`chunk-XXX.js`,
@@ -546,6 +654,30 @@ export const makeContainerImageSource = Effect.gen(function* () {
546
654
  return `${lines.join("\n")}\n`;
547
655
  };
548
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
+
549
681
  /**
550
682
  * Build (or mirror) and push an image to `target`, pushing with the
551
683
  * target's credentials or — when it has none — the build machine's own
@@ -593,17 +725,18 @@ export const makeContainerImageSource = Effect.gen(function* () {
593
725
  platform: options.platform,
594
726
  "build-arg": options.build?.args,
595
727
  secrets: options.build?.secrets,
728
+ ...imageBuildCacheOptions(options.build, options.imageUri, credentials),
596
729
  });
597
730
  yield* push(options.imageUri, credentials, options.platform);
598
731
  return options.imageUri;
599
732
  });
600
733
 
601
734
  /**
602
- * 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,
603
736
  * compute the content-addressed tag, then build/mirror + push only when
604
737
  * that exact tag is not already in the registry (crash-safe convergence).
605
738
  */
606
- const resolve = Effect.fnUntraced(function* <R>(
739
+ const resolveSource = Effect.fnUntraced(function* <R>(
607
740
  options: ResolveContainerImageOptions,
608
741
  target: ImageRegistryTarget<R>,
609
742
  ) {
@@ -658,11 +791,18 @@ export const makeContainerImageSource = Effect.gen(function* () {
658
791
  });
659
792
  envFrom = `alchemy-env-${id.toLowerCase()}:${codeHash}`;
660
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.
661
796
  yield* docker.image.build({
662
797
  context: env.context,
663
798
  file: env.dockerfile,
664
799
  tag: envFrom,
665
800
  platform,
801
+ ...imageBuildCacheOptions(
802
+ source.build,
803
+ imageUri,
804
+ source.build?.cache ? yield* target.credentials : undefined,
805
+ ),
666
806
  });
667
807
  }
668
808
  const finalDockerfile =
@@ -687,6 +827,7 @@ export const makeContainerImageSource = Effect.gen(function* () {
687
827
  imageUri,
688
828
  context: contextDir,
689
829
  platform,
830
+ build: envFrom === undefined ? { cache: source.build?.cache } : undefined,
690
831
  });
691
832
  yield* session.note(`Pushed ${imageUri}`);
692
833
  return { imageUri, repositoryUri, codeHash };
@@ -776,6 +917,74 @@ export const makeContainerImageSource = Effect.gen(function* () {
776
917
  return { imageUri, repositoryUri, codeHash };
777
918
  });
778
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
+
779
988
  /**
780
989
  * Content hash for ANY source kind without building or pushing an image.
781
990
  *
@@ -792,9 +1001,19 @@ export const makeContainerImageSource = Effect.gen(function* () {
792
1001
  port?: number;
793
1002
  isExternal?: boolean;
794
1003
  bootstrap: (importPath: string) => string;
1004
+ agent?: ImageAgent;
795
1005
  }) {
796
1006
  const platform = options.platform ?? "linux/amd64";
797
- 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") {
798
1017
  // Unresolved inline environment content (an Output) can't be hashed
799
1018
  // at plan time — return undefined so the diff defers to reconcile.
800
1019
  const df = options.source.dockerfile;