@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
@@ -0,0 +1,758 @@
1
+ import * as crypto from "node:crypto";
2
+ import { normalizeRelativePath, walkIgnoring, type WalkEntryType } from "alchemy/Util/Ignore";
3
+ import { sha256Object } from "alchemy/Util/sha256";
4
+ import * as Data from "effect/Data";
5
+ import * as Effect from "effect/Effect";
6
+ import * as FileSystem from "effect/FileSystem";
7
+ import * as Path from "effect/Path";
8
+ import type * as Redacted from "effect/Redacted";
9
+ import * as Result from "effect/Result";
10
+ import * as Stream from "effect/Stream";
11
+ import { selectDockerBuildContext } from "./BuildHash.ts";
12
+ import { Docker } from "./Docker.ts";
13
+ import { isInlineDockerfile } from "./Dockerfile.ts";
14
+ import * as DockerfileParser from "./DockerfileParser.ts";
15
+ import type {
16
+ CopyInstruction,
17
+ Instruction,
18
+ InstructionName,
19
+ ParsedDockerfile,
20
+ } from "./DockerfileParser.ts";
21
+ import {
22
+ bundleMainProgram,
23
+ type BundledImageSource,
24
+ type DockerfileImageSource,
25
+ type ImageSourceProps,
26
+ } from "./ImageSource.ts";
27
+ import { publishedRepoDigest, type ImageRegistry } from "./Registry.ts";
28
+
29
+ /**
30
+ * A build plan: an image source (`image`, `context` + `dockerfile`, inline
31
+ * `dockerfile`, plus an optional bundled `main`) resolved into data that any
32
+ * builder can consume, Docker or not.
33
+ *
34
+ * Platforms with their own builders (E2B templates, Modal images, Daytona
35
+ * snapshots) lower a plan into their build API; the Docker path can render
36
+ * it back into a Dockerfile and build context. Every platform declares
37
+ * {@link Capabilities}, and a Dockerfile it cannot build fails at plan time
38
+ * with the offending lines, before any upload or remote build.
39
+ */
40
+ export interface BuildPlan {
41
+ /** Content hash of everything that determines the image: rebuild only when it changes. */
42
+ readonly hash: string;
43
+ /** Target platform, e.g. `linux/amd64`. */
44
+ readonly platform: string;
45
+ /** Every stage, including the generated `main` layer. */
46
+ readonly dockerfile: ParsedDockerfile;
47
+ /** The final stage's base: an image reference, an earlier stage's name, or `scratch`. */
48
+ readonly base: string;
49
+ /** Credentials for pulling a private base image. */
50
+ readonly pull: ImageRegistry | undefined;
51
+ /** The final stage's instructions. */
52
+ readonly instructions: ReadonlyArray<Instruction>;
53
+ /**
54
+ * Files the build reads from its context: everything a `COPY`/`ADD`
55
+ * source reaches (after `.dockerignore`), plus the bundled `main` under
56
+ * {@link BUNDLE_DIR}. Paths are relative to the context, `/`-separated.
57
+ */
58
+ readonly files: ReadonlyArray<ContextFile>;
59
+ /**
60
+ * Build arguments (`ARG` values) the builder must pass. Empty when the
61
+ * builder takes none ({@link Capabilities.buildArgs}): the values are then
62
+ * written into the `ARG` defaults of {@link dockerfile}.
63
+ */
64
+ readonly args: Readonly<Record<string, string>>;
65
+ /** BuildKit secrets, by id (`RUN --mount=type=secret,id=…`). */
66
+ readonly secrets: Readonly<Record<string, Redacted.Redacted<string>>>;
67
+ /** Instructions the platform ignores (see {@link Capabilities.instructions}). */
68
+ readonly warnings: ReadonlyArray<string>;
69
+ }
70
+
71
+ /** A file in a plan's build context. */
72
+ export interface ContextFile {
73
+ /** Context-relative, `/`-separated. */
74
+ readonly path: string;
75
+ /** Permission bits. */
76
+ readonly mode: number;
77
+ readonly size: number;
78
+ /** Hex SHA-256 of the contents. */
79
+ readonly sha256: string;
80
+ readonly source:
81
+ | { readonly _tag: "Path"; readonly absolutePath: string }
82
+ | { readonly _tag: "Bytes"; readonly bytes: Uint8Array };
83
+ }
84
+
85
+ /**
86
+ * What a builder supports. Anything not listed is supported.
87
+ */
88
+ export interface Capabilities {
89
+ /** The resource the plan is for, used in error messages, e.g. `"E2B.Template"`. */
90
+ readonly target: string;
91
+ /** More than one `FROM`, or `COPY --from`. @default true */
92
+ readonly multiStage?: boolean;
93
+ /** `RUN <<EOF` / `COPY <<EOF`. @default true */
94
+ readonly heredocs?: boolean;
95
+ /** `RUN --mount=…` (secrets, caches). @default true */
96
+ readonly runMounts?: boolean;
97
+ /** `ADD <url>`. @default true */
98
+ readonly remoteAdd?: boolean;
99
+ /**
100
+ * Build arguments passed to the builder. When `false`, the plan writes
101
+ * the values into the Dockerfile's `ARG` defaults instead. @default true
102
+ */
103
+ readonly buildArgs?: boolean;
104
+ /** BuildKit secrets (`build.secrets`, `RUN --mount=type=secret`). @default true */
105
+ readonly secrets?: boolean;
106
+ /** Platforms the builder targets, also checked against `FROM --platform=`. @default any */
107
+ readonly platforms?: ReadonlyArray<string>;
108
+ /** Instructions the builder ignores (a warning) or rejects (an error). */
109
+ readonly instructions?: Partial<Record<InstructionName, "ignored" | "unsupported">>;
110
+ }
111
+
112
+ /** One reason a builder cannot build a plan. */
113
+ export interface Violation {
114
+ /** 1-based Dockerfile line; `0` for the generated `main` layer or the platform. */
115
+ readonly line: number;
116
+ readonly instruction: string;
117
+ readonly reason: string;
118
+ }
119
+
120
+ /** The Dockerfile uses something the target builder cannot build. */
121
+ export class UnsupportedDockerfile extends Data.TaggedError("UnsupportedDockerfile")<{
122
+ readonly target: string;
123
+ readonly violations: ReadonlyArray<Violation>;
124
+ readonly message: string;
125
+ }> {}
126
+
127
+ /** The image source cannot be planned (a missing file, an unresolved input, an inline Dockerfile that copies from a context). */
128
+ export class BuildPlanError extends Data.TaggedError("BuildPlanError")<{
129
+ readonly message: string;
130
+ readonly cause?: unknown;
131
+ }> {}
132
+
133
+ /** The context directory a plan's bundled `main` files live under. */
134
+ export const BUNDLE_DIR = "__alchemy__";
135
+
136
+ /** Default base for a `main` program with no environment: Docker Hub's bun image. */
137
+ export const DEFAULT_MAIN_BASE = "oven/bun:1";
138
+
139
+ export interface PlanOptions {
140
+ readonly capabilities: Capabilities;
141
+ /** @default "linux/amd64" */
142
+ readonly platform?: string;
143
+ /** Port a `main` program listens on; written to `ENV PORT`. */
144
+ readonly port?: number;
145
+ /**
146
+ * Bootstrap wrapped around an Effect-native `main` (the host's virtual
147
+ * entry). Omit it to bundle `main` as-is.
148
+ */
149
+ readonly bootstrap?: (importPath: string) => string;
150
+ /** Credentials for pulling a private base image. */
151
+ readonly pull?: ImageRegistry;
152
+ /**
153
+ * Dockerfile lines a host's bindings contribute, e.g. the clients of
154
+ * `alchemy/Mount` capabilities (`mountInstallLines`). They go after the
155
+ * final stage's own instructions and before the bundled `main`, so a
156
+ * program change doesn't reinstall them.
157
+ */
158
+ readonly install?: ReadonlyArray<string>;
159
+ }
160
+
161
+ const isRemoteSource = (source: string) => /^(https?:\/\/|git@)/.test(source);
162
+ const isHeredocSource = (source: string) => source.startsWith("<<");
163
+
164
+ /** Sources that read from the build context (not another stage, a URL, or a heredoc). */
165
+ const contextSources = (instruction: CopyInstruction): string[] =>
166
+ instruction.flags.some((flag) => flag.name === "from")
167
+ ? []
168
+ : instruction.sources.filter((source) => !isRemoteSource(source) && !isHeredocSource(source));
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // COPY source matching (Go filepath.Match per segment, plus `**`)
172
+ // ---------------------------------------------------------------------------
173
+
174
+ const segmentPattern = (segment: string) =>
175
+ new RegExp(
176
+ `^${segment
177
+ .replace(/[.+^${}()|\\]/g, "\\$&")
178
+ .replace(/\[!/g, "[^")
179
+ .replace(/\*/g, "[^/]*")
180
+ .replace(/\?/g, "[^/]")}$`,
181
+ );
182
+
183
+ interface SourcePattern {
184
+ readonly all: boolean;
185
+ readonly segments: ReadonlyArray<string>;
186
+ readonly matchers: ReadonlyArray<RegExp | "**">;
187
+ }
188
+
189
+ const compileSource = (source: string): SourcePattern => {
190
+ const cleaned = normalizeRelativePath(source).replace(/^\/+/, "").replace(/\/+$/, "");
191
+ const segments = cleaned.split("/").filter((segment) => segment.length > 0 && segment !== ".");
192
+ return {
193
+ all: segments.length === 0,
194
+ segments,
195
+ matchers: segments.map((segment) => (segment === "**" ? "**" : segmentPattern(segment))),
196
+ };
197
+ };
198
+
199
+ const matchSegments = (
200
+ path: ReadonlyArray<string>,
201
+ matchers: ReadonlyArray<RegExp | "**">,
202
+ ): boolean => {
203
+ if (matchers.length === 0) return path.length === 0;
204
+ const [head, ...rest] = matchers;
205
+ if (head === "**") {
206
+ for (let skip = 0; skip <= path.length; skip++) {
207
+ if (matchSegments(path.slice(skip), rest)) return true;
208
+ }
209
+ return false;
210
+ }
211
+ return path.length > 0 && head!.test(path[0]!) && matchSegments(path.slice(1), rest);
212
+ };
213
+
214
+ /** The pattern names `path` or one of its directories (a directory copies its contents). */
215
+ const covers = (pattern: SourcePattern, path: string) => {
216
+ if (pattern.all) return true;
217
+ const segments = path.split("/");
218
+ for (let length = 1; length <= segments.length; length++) {
219
+ if (matchSegments(segments.slice(0, length), pattern.matchers)) return true;
220
+ }
221
+ return false;
222
+ };
223
+
224
+ /** A directory a walk must descend into: covered, or on the way to a match. */
225
+ const reaches = (pattern: SourcePattern, directory: string) => {
226
+ if (covers(pattern, directory)) return true;
227
+ const segments = directory.split("/");
228
+ for (let index = 0; index < segments.length; index++) {
229
+ const matcher = pattern.matchers[index];
230
+ if (matcher === undefined) return false;
231
+ if (matcher === "**") return true;
232
+ if (!matcher.test(segments[index]!)) return false;
233
+ }
234
+ return true;
235
+ };
236
+
237
+ /** The context files one `COPY`/`ADD` reads. */
238
+ export const filesFor = (plan: BuildPlan, instruction: CopyInstruction): ContextFile[] => {
239
+ const patterns = contextSources(instruction).map(compileSource);
240
+ return plan.files.filter((file) => patterns.some((pattern) => covers(pattern, file.path)));
241
+ };
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // planning
245
+ // ---------------------------------------------------------------------------
246
+
247
+ const hashFile = Effect.fn(function* (absolutePath: string) {
248
+ const fs = yield* FileSystem.FileSystem;
249
+ const hasher = yield* Effect.sync(() => crypto.createHash("sha256"));
250
+ yield* fs.stream(absolutePath).pipe(
251
+ Stream.runForEach((chunk) =>
252
+ Effect.sync(() => {
253
+ hasher.update(chunk);
254
+ }),
255
+ ),
256
+ );
257
+ return yield* Effect.sync(() => hasher.digest("hex"));
258
+ });
259
+
260
+ const hashBytes = (bytes: Uint8Array) =>
261
+ Effect.sync(() => crypto.createHash("sha256").update(bytes).digest("hex"));
262
+
263
+ /** Walk only the directories some source can reach, and keep what `.dockerignore` keeps. */
264
+ const collectContextFiles = Effect.fn(function* (options: {
265
+ readonly context: string;
266
+ readonly dockerfile: string;
267
+ readonly sources: ReadonlyArray<string>;
268
+ }) {
269
+ if (options.sources.length === 0) return [];
270
+ const fs = yield* FileSystem.FileSystem;
271
+ const path = yield* Path.Path;
272
+ const selection = yield* selectDockerBuildContext({
273
+ context: options.context,
274
+ dockerfile: options.dockerfile,
275
+ });
276
+ const patterns = options.sources.map(compileSource);
277
+
278
+ const entries = yield* walkIgnoring({
279
+ list: Effect.fn(function* (relativeDirectory: string) {
280
+ const directory = path.join(selection.context, relativeDirectory);
281
+ const names = yield* fs.readDirectory(directory);
282
+ const children = yield* Effect.forEach(names, (name) =>
283
+ Effect.gen(function* () {
284
+ const fullPath = path.join(directory, name);
285
+ const childPath = relativeDirectory.length === 0 ? name : `${relativeDirectory}/${name}`;
286
+ if (Result.isSuccess(yield* Effect.result(fs.readLink(fullPath)))) {
287
+ return { name, childPath, type: "SymbolicLink" as WalkEntryType };
288
+ }
289
+ const info = yield* fs.stat(fullPath);
290
+ const type: WalkEntryType =
291
+ info.type === "File" || info.type === "Directory" ? info.type : "Other";
292
+ return { name, childPath, type };
293
+ }),
294
+ );
295
+ return children.filter(
296
+ (child) =>
297
+ child.type !== "Directory" ||
298
+ patterns.some((pattern) => reaches(pattern, child.childPath)),
299
+ );
300
+ }),
301
+ });
302
+
303
+ const files: ContextFile[] = [];
304
+ for (const entry of entries) {
305
+ if (entry.type !== "File" && entry.type !== "SymbolicLink") continue;
306
+ if (!selection.includes(entry.path)) continue;
307
+ if (!patterns.some((pattern) => covers(pattern, entry.path))) continue;
308
+ const absolutePath = path.join(selection.context, entry.path);
309
+ // Docker copies a symlink's target into the image when the link is named directly.
310
+ const info = yield* fs.stat(absolutePath);
311
+ if (info.type !== "File") continue;
312
+ files.push({
313
+ path: entry.path,
314
+ mode: info.mode & 0o7777,
315
+ size: Number(info.size),
316
+ sha256: yield* hashFile(absolutePath),
317
+ source: { _tag: "Path", absolutePath },
318
+ });
319
+ }
320
+ return files;
321
+ });
322
+
323
+ const unsupported = (target: string, violations: ReadonlyArray<Violation>) =>
324
+ new UnsupportedDockerfile({
325
+ target,
326
+ violations,
327
+ message: [
328
+ `${target} cannot build this Dockerfile:`,
329
+ ...violations.map((violation) =>
330
+ violation.line > 0
331
+ ? ` line ${violation.line}: ${violation.instruction}: ${violation.reason}`
332
+ : ` ${violation.instruction}: ${violation.reason}`,
333
+ ),
334
+ ].join("\n"),
335
+ });
336
+
337
+ /** Check a parsed Dockerfile against a builder's capabilities. */
338
+ export const check = (
339
+ dockerfile: ParsedDockerfile,
340
+ capabilities: Capabilities,
341
+ platform: string,
342
+ ): { violations: Violation[]; warnings: string[] } => {
343
+ const violations: Violation[] = [];
344
+ const warnings: string[] = [];
345
+ if (capabilities.platforms !== undefined && !capabilities.platforms.includes(platform)) {
346
+ violations.push({
347
+ line: 0,
348
+ instruction: "platform",
349
+ reason: `${platform} is not supported (supported: ${capabilities.platforms.join(", ")})`,
350
+ });
351
+ }
352
+ if (capabilities.platforms !== undefined) {
353
+ for (const stage of dockerfile.stages) {
354
+ const pinned = stage.flags.find((flag) => flag.name === "platform")?.value;
355
+ // `$BUILDPLATFORM`-style values resolve at build time, to the builder's own platform.
356
+ if (pinned === undefined || pinned.includes("$")) continue;
357
+ if (!capabilities.platforms.includes(pinned)) {
358
+ violations.push({
359
+ line: stage.line,
360
+ instruction: "FROM --platform",
361
+ reason: `${pinned} is not supported (supported: ${capabilities.platforms.join(", ")})`,
362
+ });
363
+ }
364
+ }
365
+ }
366
+ if (capabilities.multiStage === false && dockerfile.stages.length > 1) {
367
+ for (const stage of dockerfile.stages.slice(1)) {
368
+ violations.push({
369
+ line: stage.line,
370
+ instruction: "FROM",
371
+ reason: "multi-stage builds are not supported",
372
+ });
373
+ }
374
+ }
375
+ for (const stage of dockerfile.stages) {
376
+ for (const instruction of stage.instructions) {
377
+ const status = capabilities.instructions?.[instruction._tag];
378
+ if (status === "unsupported") {
379
+ violations.push({
380
+ line: instruction.line,
381
+ instruction: instruction._tag,
382
+ reason: "not supported",
383
+ });
384
+ } else if (status === "ignored") {
385
+ warnings.push(
386
+ `${capabilities.target} ignores ${instruction._tag}${instruction.line > 0 ? ` (line ${instruction.line})` : ""}`,
387
+ );
388
+ }
389
+ if (instruction._tag === "COPY" || instruction._tag === "ADD") {
390
+ if (
391
+ capabilities.multiStage === false &&
392
+ instruction.flags.some((flag) => flag.name === "from")
393
+ ) {
394
+ violations.push({
395
+ line: instruction.line,
396
+ instruction: `${instruction._tag} --from`,
397
+ reason: "multi-stage builds are not supported",
398
+ });
399
+ }
400
+ if (capabilities.remoteAdd === false && instruction.sources.some(isRemoteSource)) {
401
+ violations.push({
402
+ line: instruction.line,
403
+ instruction: instruction._tag,
404
+ reason: "remote sources are not supported",
405
+ });
406
+ }
407
+ }
408
+ if (
409
+ (instruction._tag === "RUN" || instruction._tag === "COPY" || instruction._tag === "ADD") &&
410
+ capabilities.heredocs === false &&
411
+ instruction.heredocs.length > 0
412
+ ) {
413
+ violations.push({
414
+ line: instruction.line,
415
+ instruction: `${instruction._tag} <<`,
416
+ reason: "heredocs are not supported",
417
+ });
418
+ }
419
+ if (
420
+ instruction._tag === "RUN" &&
421
+ capabilities.runMounts === false &&
422
+ instruction.flags.some((flag) => flag.name === "mount")
423
+ ) {
424
+ violations.push({
425
+ line: instruction.line,
426
+ instruction: "RUN --mount",
427
+ reason: "build mounts are not supported",
428
+ });
429
+ } else if (
430
+ instruction._tag === "RUN" &&
431
+ capabilities.secrets === false &&
432
+ instruction.flags.some(
433
+ (flag) => flag.name === "mount" && /(^|,)type=secret(,|$)/.test(flag.value ?? ""),
434
+ )
435
+ ) {
436
+ violations.push({
437
+ line: instruction.line,
438
+ instruction: "RUN --mount=type=secret",
439
+ reason: "build secrets are not supported",
440
+ });
441
+ }
442
+ }
443
+ }
444
+ return { violations, warnings };
445
+ };
446
+
447
+ /** The generated layer that runs a bundled `main`: shared by every builder. */
448
+ const mainLayer = (port: number | undefined) =>
449
+ [
450
+ `WORKDIR /app`,
451
+ `COPY ${BUNDLE_DIR}/ /app/`,
452
+ ...(port === undefined ? [] : [`ENV PORT=${String(port)}`]),
453
+ `ENTRYPOINT ["bun", "/app/index.mjs"]`,
454
+ ].join("\n");
455
+
456
+ /** Synthesized instructions carry line 0: they are not in the user's Dockerfile. */
457
+ const synthesized = (instruction: Instruction): Instruction => ({ ...instruction, line: 0 });
458
+
459
+ /** Append generated instructions to the final stage. */
460
+ const appendToFinalStage = Effect.fn(function* (dockerfile: ParsedDockerfile, lines: string) {
461
+ const layer = yield* DockerfileParser.parse(`FROM scratch\n${lines}\n`);
462
+ const stages = [...dockerfile.stages];
463
+ const final = stages.at(-1)!;
464
+ stages[stages.length - 1] = {
465
+ ...final,
466
+ instructions: [...final.instructions, ...layer.stages[0]!.instructions.map(synthesized)],
467
+ };
468
+ return { ...dockerfile, stages } satisfies ParsedDockerfile;
469
+ });
470
+
471
+ /**
472
+ * Write build argument values into the `ARG` defaults that declare them, for
473
+ * builders that take no build arguments. Values are quoted so they are used
474
+ * as given, the way `--build-arg` passes them (no `$` expansion).
475
+ */
476
+ const bakeArgs = (
477
+ dockerfile: ParsedDockerfile,
478
+ args: Readonly<Record<string, string>>,
479
+ ): ParsedDockerfile => {
480
+ const escape = dockerfile.directives.escape ?? "\\";
481
+ // A baked value is literal, so `$` is escaped too; a default that was
482
+ // already there keeps its `$` references.
483
+ const quote = (value: string, literal: boolean) =>
484
+ `"${Array.from(value, (char) =>
485
+ char === escape || char === '"' || (literal && char === "$") ? `${escape}${char}` : char,
486
+ ).join("")}"`;
487
+ const bake = <I extends Instruction>(instruction: I): I => {
488
+ if (instruction._tag !== "ARG" || !instruction.vars.some(([name]) => name in args)) {
489
+ return instruction;
490
+ }
491
+ const vars = instruction.vars.map(([name, value]) =>
492
+ name in args ? ([name, args[name]] as const) : ([name, value] as const),
493
+ );
494
+ const raw = `ARG ${vars
495
+ .map(([name, value]) =>
496
+ value === undefined ? name : `${name}=${quote(value, name in args)}`,
497
+ )
498
+ .join(" ")}`;
499
+ return { ...instruction, vars, raw };
500
+ };
501
+ return {
502
+ ...dockerfile,
503
+ globalArgs: dockerfile.globalArgs.map(bake),
504
+ stages: dockerfile.stages.map((stage) => ({
505
+ ...stage,
506
+ instructions: stage.instructions.map(bake),
507
+ })),
508
+ };
509
+ };
510
+
511
+ /**
512
+ * Resolve an image source into a {@link BuildPlan} for one builder.
513
+ */
514
+ export const plan = Effect.fn(function* (source: ImageSourceProps, options: PlanOptions) {
515
+ const fs = yield* FileSystem.FileSystem;
516
+ const path = yield* Path.Path;
517
+ const platform = options.platform ?? "linux/amd64";
518
+ const main = "main" in source ? (source as BundledImageSource) : undefined;
519
+ const image = "image" in source ? source.image : undefined;
520
+ const dockerfileProp = "dockerfile" in source ? source.dockerfile : undefined;
521
+ const contextProp = "context" in source ? source.context : undefined;
522
+ const build = main === undefined ? (source as DockerfileImageSource).build : undefined;
523
+
524
+ // 1. The environment's Dockerfile text, and where its context lives.
525
+ let text: string;
526
+ let context: { context: string; dockerfile: string } | undefined;
527
+ if (dockerfileProp !== undefined && isInlineDockerfile(dockerfileProp)) {
528
+ if (typeof dockerfileProp.content !== "string") {
529
+ return yield* new BuildPlanError({
530
+ message: "inline dockerfile content is not resolved yet",
531
+ });
532
+ }
533
+ text = dockerfileProp.content;
534
+ } else if (dockerfileProp !== undefined || contextProp !== undefined) {
535
+ const contextDir = path.resolve(contextProp ?? ".");
536
+ const dockerfile =
537
+ dockerfileProp !== undefined
538
+ ? path.resolve(dockerfileProp)
539
+ : path.join(contextDir, "Dockerfile");
540
+ text = yield* fs
541
+ .readFileString(dockerfile)
542
+ .pipe(
543
+ Effect.mapError(
544
+ (cause) => new BuildPlanError({ message: `cannot read ${dockerfile}`, cause }),
545
+ ),
546
+ );
547
+ context = { context: contextDir, dockerfile };
548
+ } else if (image !== undefined) {
549
+ text = `FROM ${image}\n`;
550
+ } else if (main !== undefined) {
551
+ text = `FROM ${DEFAULT_MAIN_BASE}\n`;
552
+ } else {
553
+ return yield* new BuildPlanError({
554
+ message: "an image source needs `image`, `dockerfile`, `context`, or `main`",
555
+ });
556
+ }
557
+
558
+ let dockerfile = yield* DockerfileParser.parse(text);
559
+
560
+ // 2. Install lines from the host's bindings, then a bundled `main`, become
561
+ // generated layers on the final stage.
562
+ if (options.install !== undefined && options.install.length > 0) {
563
+ dockerfile = yield* appendToFinalStage(dockerfile, options.install.join("\n"));
564
+ }
565
+ const files: ContextFile[] = [];
566
+ if (main !== undefined) {
567
+ const bundled = yield* bundleMainProgram({
568
+ source: main,
569
+ isExternal: options.bootstrap === undefined,
570
+ bootstrap: options.bootstrap ?? ((importPath) => importPath),
571
+ });
572
+ for (const file of bundled.files) {
573
+ files.push({
574
+ path: `${BUNDLE_DIR}/${normalizeRelativePath(file.path)}`,
575
+ mode: 0o644,
576
+ size: file.content.byteLength,
577
+ sha256: yield* hashBytes(file.content),
578
+ source: { _tag: "Bytes", bytes: file.content },
579
+ });
580
+ }
581
+ dockerfile = yield* appendToFinalStage(dockerfile, mainLayer(options.port));
582
+ }
583
+
584
+ // 3. Context files every COPY/ADD reaches.
585
+ const sources = dockerfile.stages.flatMap((stage) =>
586
+ stage.instructions.flatMap((instruction) =>
587
+ (instruction._tag === "COPY" || instruction._tag === "ADD") && instruction.line > 0
588
+ ? contextSources(instruction)
589
+ : [],
590
+ ),
591
+ );
592
+ if (sources.length > 0) {
593
+ if (context === undefined) {
594
+ return yield* new BuildPlanError({
595
+ message: `an inline dockerfile cannot COPY from a build context (${sources.join(", ")}); use \`context\` + a Dockerfile path`,
596
+ });
597
+ }
598
+ files.push(...(yield* collectContextFiles({ ...context, sources })));
599
+ }
600
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
601
+
602
+ // 4. What the builder can do.
603
+ const secrets = build?.secrets ?? {};
604
+ const { violations, warnings } = check(dockerfile, options.capabilities, platform);
605
+ if (options.capabilities.secrets === false && Object.keys(secrets).length > 0) {
606
+ violations.push({
607
+ line: 0,
608
+ instruction: "build.secrets",
609
+ reason: "build secrets are not supported",
610
+ });
611
+ }
612
+ if (violations.length > 0) {
613
+ return yield* unsupported(options.capabilities.target, violations);
614
+ }
615
+
616
+ // 5. A builder that takes no build arguments gets them as `ARG` defaults.
617
+ let args = build?.args ?? {};
618
+ if (options.capabilities.buildArgs === false && Object.keys(args).length > 0) {
619
+ dockerfile = bakeArgs(dockerfile, args);
620
+ args = {};
621
+ }
622
+
623
+ const final = dockerfile.stages.at(-1)!;
624
+ const hash = (yield* sha256Object({
625
+ platform,
626
+ dockerfile: DockerfileParser.render(dockerfile),
627
+ files: files.map((file) => [file.path, file.mode, file.sha256]),
628
+ args,
629
+ secrets: Object.keys(secrets).sort(),
630
+ pull: options.pull?.server,
631
+ })).slice(0, 32);
632
+
633
+ return {
634
+ hash,
635
+ platform,
636
+ dockerfile,
637
+ base: final.image,
638
+ pull: options.pull,
639
+ instructions: final.instructions,
640
+ files,
641
+ args,
642
+ secrets,
643
+ warnings,
644
+ } satisfies BuildPlan;
645
+ });
646
+
647
+ /** A context file's contents. */
648
+ export const readContextFile = Effect.fn(function* (file: ContextFile) {
649
+ if (file.source._tag === "Bytes") return file.source.bytes;
650
+ const fs = yield* FileSystem.FileSystem;
651
+ return yield* fs.readFile(file.source.absolutePath);
652
+ });
653
+
654
+ /** The plan as Dockerfile text. */
655
+ export const render = (plan: BuildPlan): string => DockerfileParser.render(plan.dockerfile);
656
+
657
+ /** The plan as one entry per instruction (for APIs that take a list of Dockerfile commands). */
658
+ export const renderLines = (plan: BuildPlan): string[] =>
659
+ DockerfileParser.renderLines(plan.dockerfile);
660
+
661
+ /**
662
+ * The argv the image starts with, from the final stage's `ENTRYPOINT` and
663
+ * `CMD` (Docker's rules: a shell-form `ENTRYPOINT` ignores `CMD`).
664
+ * `undefined` when the final stage sets neither (the base image's applies).
665
+ */
666
+ export const startCommand = (plan: BuildPlan): ReadonlyArray<string> | undefined => {
667
+ const entrypoint = plan.instructions.findLast((instruction) => instruction._tag === "ENTRYPOINT");
668
+ const cmd = plan.instructions.findLast((instruction) => instruction._tag === "CMD");
669
+ const argv = (instruction: Instruction | undefined) =>
670
+ instruction === undefined || !("command" in instruction)
671
+ ? undefined
672
+ : instruction.command.form === "exec"
673
+ ? instruction.command.argv
674
+ : ["/bin/sh", "-c", instruction.command.script];
675
+ if (entrypoint !== undefined && "command" in entrypoint) {
676
+ return entrypoint.command.form === "shell"
677
+ ? argv(entrypoint)
678
+ : [...entrypoint.command.argv, ...(argv(cmd) ?? [])];
679
+ }
680
+ return argv(cmd);
681
+ };
682
+
683
+ /**
684
+ * Check Dockerfile text against a builder's capabilities, without reading a
685
+ * build context or bundling anything: usable where there is no filesystem
686
+ * (a Worker creating images at run time). Returns the warnings.
687
+ */
688
+ export const checkContent = Effect.fn(function* (
689
+ text: string,
690
+ options: Pick<PlanOptions, "capabilities" | "platform">,
691
+ ) {
692
+ const dockerfile = yield* DockerfileParser.parse(text);
693
+ const { violations, warnings } = check(
694
+ dockerfile,
695
+ options.capabilities,
696
+ options.platform ?? "linux/amd64",
697
+ );
698
+ if (violations.length > 0) {
699
+ return yield* unsupported(options.capabilities.target, violations);
700
+ }
701
+ return warnings;
702
+ });
703
+
704
+ /** Where {@link push} publishes a plan. */
705
+ export interface PushTarget {
706
+ /** Repository to push to, without a tag, e.g. `ghcr.io/acme/agent`. The plan's hash is the tag. */
707
+ readonly repository: string;
708
+ /** Credentials for the registry the repository lives in. */
709
+ readonly push: ImageRegistry;
710
+ }
711
+
712
+ /**
713
+ * Build a plan with the local Docker engine and push it, for platforms that
714
+ * only pull images (Archil). Tagged with the plan's hash, so an unchanged
715
+ * plan pushes the same tag. Returns the pushed reference, `repository@digest`
716
+ * when the registry reports a digest, else `repository:hash`.
717
+ */
718
+ export const push = Effect.fn(function* (plan: BuildPlan, target: PushTarget) {
719
+ const docker = yield* Docker;
720
+ const fs = yield* FileSystem.FileSystem;
721
+ const path = yield* Path.Path;
722
+ const context = yield* fs.makeTempDirectoryScoped({ prefix: "alchemy-build-plan-" });
723
+ yield* Effect.forEach(
724
+ plan.files,
725
+ Effect.fn(function* (file) {
726
+ const fullPath = path.join(context, file.path);
727
+ yield* fs.makeDirectory(path.dirname(fullPath), { recursive: true });
728
+ yield* fs.writeFile(fullPath, yield* readContextFile(file));
729
+ yield* fs.chmod(fullPath, file.mode);
730
+ }),
731
+ { concurrency: 16 },
732
+ );
733
+ const dockerfile = path.join(context, "Dockerfile.alchemy");
734
+ yield* fs.writeFileString(dockerfile, render(plan));
735
+ const ref = `${target.repository}:${plan.hash}`;
736
+ const output = yield* docker.image.build(
737
+ {
738
+ context,
739
+ file: dockerfile,
740
+ tag: ref,
741
+ platform: plan.platform,
742
+ "build-arg": { ...plan.args },
743
+ secrets: { ...plan.secrets },
744
+ credentials: plan.pull,
745
+ },
746
+ undefined,
747
+ target.push,
748
+ );
749
+ // buildx `--push` exports straight from BuildKit, so the image may not be
750
+ // in the local store to inspect.
751
+ const inspected = yield* Effect.result(docker.image.inspect(ref));
752
+ const pinned = publishedRepoDigest(
753
+ ref,
754
+ output,
755
+ Result.isSuccess(inspected) ? inspected.success.RepoDigests : undefined,
756
+ );
757
+ return { imageRef: pinned ?? ref };
758
+ });