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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Docker.ts CHANGED
@@ -85,6 +85,42 @@ export class Docker extends Context.Service<
85
85
  network?: string | undefined;
86
86
  /** Linux capabilities to add. */
87
87
  "cap-add"?: Array<string> | undefined;
88
+ /** Linux capabilities to drop. */
89
+ "cap-drop"?: Array<string> | undefined;
90
+ /** Hard memory limit in Docker's byte-suffix format. */
91
+ memory?: string | undefined;
92
+ /** Combined memory-and-swap limit in Docker's byte-suffix format. */
93
+ "memory-swap"?: string | undefined;
94
+ /** Soft memory reservation for memory contention. */
95
+ "memory-reservation"?: string | undefined;
96
+ /** CPU quota, in cores. */
97
+ cpus?: number | undefined;
98
+ /** Per-container process and thread limit. */
99
+ "pids-limit"?: number | undefined;
100
+ /** Mount the root filesystem read-only. */
101
+ "read-only"?: boolean | undefined;
102
+ /** Security options such as `no-new-privileges` and seccomp/AppArmor profiles. */
103
+ "security-opt"?: Array<string> | undefined;
104
+ /** Per-container user namespace; "host" disables daemon user remapping. */
105
+ userns?: string | undefined;
106
+ /** Supplemental Linux groups. */
107
+ "group-add"?: Array<string> | undefined;
108
+ /** Per-process soft/hard resource limits. */
109
+ ulimit?: Array<string> | undefined;
110
+ /** Docker log driver. */
111
+ "log-driver"?: string | undefined;
112
+ /** Driver-specific Docker logging options. */
113
+ "log-opt"?: Array<string> | undefined;
114
+ /** Kernel OOM score adjustment. */
115
+ "oom-score-adj"?: number | undefined;
116
+ /** Signal used on container stop. */
117
+ "stop-signal"?: string | undefined;
118
+ /** Run Docker's tiny init process. */
119
+ init?: boolean | undefined;
120
+ /** Runtime UID/GID or named user. */
121
+ user?: string | undefined;
122
+ /** Docker tmpfs mount expressions (target[:options]). */
123
+ tmpfs?: Array<string> | undefined;
88
124
  /** Host devices in Docker's `host:container[:permissions]` form. */
89
125
  device?: Array<string> | undefined;
90
126
  command: Array<string> | undefined;
@@ -130,6 +166,12 @@ export class Docker extends Context.Service<
130
166
  platform?: string;
131
167
  target?: string;
132
168
  "build-arg"?: Record<string, string>;
169
+ /**
170
+ * BuildKit secrets, keyed by id (`RUN --mount=type=secret,id=<id>`).
171
+ * Values reach `docker` through its environment, never its argv,
172
+ * and are not written to the image.
173
+ */
174
+ secrets?: Record<string, string | Redacted.Redacted<string>>;
133
175
  "cache-from"?: Array<string>;
134
176
  "cache-to"?: Array<string>;
135
177
  args?: Array<string>;
@@ -327,10 +369,12 @@ export class Docker extends Context.Service<
327
369
  context?: string;
328
370
  replicas?: number;
329
371
  "endpoint-mode"?: "vip" | "dnsrr";
372
+ /** Networks to detach, by name or id; applied before `network-add`. */
373
+ "network-rm"?: string[];
374
+ "network-add"?: string[];
330
375
  "constraint-add"?: string[];
331
376
  "constraint-rm"?: string[];
332
377
  "replicas-max-per-node"?: number;
333
- "placement-pref"?: string[];
334
378
  "update-parallelism"?: number;
335
379
  "update-delay"?: string;
336
380
  "update-monitor"?: string;
@@ -356,13 +400,23 @@ export class Docker extends Context.Service<
356
400
  "health-start-period"?: string;
357
401
  "stop-grace-period"?: string;
358
402
  "mount-add"?: string[];
403
+ /** Mount targets to remove. */
404
+ "mount-rm"?: string[];
359
405
  "secret-add"?: string[];
406
+ /** Secret names to remove; applied before `secret-add`. */
407
+ "secret-rm"?: string[];
360
408
  "config-add"?: string[];
409
+ /** Config names to remove; applied before `config-add`. */
410
+ "config-rm"?: string[];
361
411
  "read-only"?: boolean;
362
412
  "publish-add"?: string[];
413
+ /** Published ports to remove, as `target=<port>,protocol=<p>,mode=<m>`. */
414
+ "publish-rm"?: string[];
363
415
  "label-add"?: Record<string, string>;
364
416
  "label-rm"?: string[];
365
417
  "env-add"?: Record<string, string>;
418
+ /** Environment variable names to remove. */
419
+ "env-rm"?: string[];
366
420
  args?: string;
367
421
  }) => Effect.Effect<CommandOutput, PlatformError>;
368
422
  /** Inspects a service. */
@@ -423,6 +477,8 @@ export declare namespace Docker {
423
477
  Image: string;
424
478
  Cmd: string[] | null;
425
479
  Env: string[] | null;
480
+ User?: string;
481
+ StopSignal?: string;
426
482
  Labels: Record<string, string> | null;
427
483
  StopTimeout?: number;
428
484
  Healthcheck?: {
@@ -442,12 +498,28 @@ export declare namespace Docker {
442
498
  AutoRemove: boolean;
443
499
  NetworkMode?: string;
444
500
  CapAdd?: string[] | null;
501
+ CapDrop?: string[] | null;
502
+ Memory?: number;
503
+ MemorySwap?: number;
504
+ MemoryReservation?: number;
505
+ NanoCpus?: number;
506
+ PidsLimit?: number;
507
+ ReadonlyRootfs?: boolean;
508
+ SecurityOpt?: string[] | null;
509
+ UsernsMode?: string;
510
+ GroupAdd?: string[] | null;
511
+ Ulimits?: Array<{ Name: string; Soft: number; Hard: number }> | null;
512
+ LogConfig?: { Type: string; Config: Record<string, string> | null };
513
+ OomScoreAdj?: number;
514
+ Init?: boolean | null;
515
+ Tmpfs?: Record<string, string> | null;
445
516
  Devices?: Array<{
446
517
  PathOnHost: string;
447
518
  PathInContainer: string;
448
519
  CgroupPermissions: string;
449
520
  }> | null;
450
521
  };
522
+ AppArmorProfile?: string;
451
523
  NetworkSettings: {
452
524
  Networks: Record<string, { NetworkID: string; Aliases: string[] | null }> | null;
453
525
  Ports?: Record<string, Array<{ HostIp: string; HostPort: string }> | null> | null;
@@ -832,17 +904,21 @@ const makeDocker = (options: DockerOptions) =>
832
904
  },
833
905
  image: {
834
906
  build: Effect.fn("Docker.image.build")(function* (
835
- { context: buildContext, engineContext, args, credentials, ...options },
907
+ { context: buildContext, engineContext, args, credentials, secrets, ...options },
836
908
  session,
837
909
  registry,
838
910
  ) {
839
911
  const tap = outputTap(session);
840
- const buildArgs = [buildContext, ...formatArgs(options), ...(args ?? [])];
912
+ const secret = buildSecrets(secrets);
913
+ const buildArgs = [buildContext, ...formatArgs(options), ...secret.args, ...(args ?? [])];
841
914
  const engine = formatArgs({ context: engineContext });
842
915
  if (registry === undefined) {
843
916
  return yield* run(
844
917
  [...engine, "image", "build", ...buildArgs],
845
- credentials ? yield* registryEnvironment(credentials) : undefined,
918
+ {
919
+ ...(credentials ? yield* registryEnvironment(credentials) : undefined),
920
+ ...secret.env,
921
+ },
846
922
  tap,
847
923
  );
848
924
  }
@@ -853,7 +929,7 @@ const makeDocker = (options: DockerOptions) =>
853
929
  const env = yield* registryEnvironment(registry);
854
930
  return yield* run(
855
931
  [...engine, "buildx", "build", "--push", ...buildArgs],
856
- env,
932
+ { ...env, ...secret.env },
857
933
  tap,
858
934
  ).pipe(Effect.mapError(classifyDockerRegistryError));
859
935
  }
@@ -869,7 +945,7 @@ const makeDocker = (options: DockerOptions) =>
869
945
  ...(mode === "load" ? ["buildx", "build", "--load"] : ["image", "build"]),
870
946
  ...buildArgs,
871
947
  ],
872
- undefined,
948
+ secret.env,
873
949
  tap,
874
950
  );
875
951
  const [tag, ...tags] =
@@ -1048,6 +1124,22 @@ const systemError = (input: {
1048
1124
  }),
1049
1125
  );
1050
1126
 
1127
+ /**
1128
+ * `--secret id=<id>,env=<var>` per secret, with each value under a generated
1129
+ * variable name: an id is the Dockerfile author's choice and may collide with
1130
+ * the environment Docker itself reads (`PATH`, `DOCKER_HOST`).
1131
+ */
1132
+ const buildSecrets = (secrets: Record<string, string | Redacted.Redacted<string>> | undefined) => {
1133
+ const args: Array<string> = [];
1134
+ const env: Record<string, string> = {};
1135
+ Object.entries(secrets ?? {}).forEach(([id, value], index) => {
1136
+ const variable = `ALCHEMY_BUILD_SECRET_${index}`;
1137
+ args.push("--secret", `id=${id},env=${variable}`);
1138
+ env[variable] = Redacted.isRedacted(value) ? Redacted.value(value) : value;
1139
+ });
1140
+ return { args, env };
1141
+ };
1142
+
1051
1143
  /** Formats a set of options into a list of command-line arguments. */
1052
1144
  const formatArgs = (
1053
1145
  options: Record<
@@ -1057,7 +1149,9 @@ const formatArgs = (
1057
1149
  ) => {
1058
1150
  const args: Array<string> = [];
1059
1151
  for (const [key, value] of Object.entries(options)) {
1060
- if (!value) continue;
1152
+ // `0` is meaningful (`--replicas 0`, `--update-parallelism 0`), so only
1153
+ // absent, `false` and empty values are omitted.
1154
+ if (value === undefined || value === false || value === "") continue;
1061
1155
  const prefix = key.length > 1 ? `--${key}` : `-${key}`;
1062
1156
  if (value === true) {
1063
1157
  args.push(prefix);
package/src/Image.ts CHANGED
@@ -5,6 +5,7 @@ import { Resource } from "alchemy/Resource";
5
5
  import * as Effect from "effect/Effect";
6
6
  import * as FileSystem from "effect/FileSystem";
7
7
  import * as Path from "effect/Path";
8
+ import type * as Redacted from "effect/Redacted";
8
9
  import { Docker, dockerContextName, dockerPhysicalName } from "./Docker.ts";
9
10
  import type { Providers } from "./Providers.ts";
10
11
  import {
@@ -30,8 +31,18 @@ export interface DockerBuildOptions {
30
31
  dockerfile?: string;
31
32
  /** Target platform, e.g. `"linux/amd64"`. */
32
33
  platform?: string;
33
- /** Docker build arguments. */
34
+ /**
35
+ * Docker build arguments (`--build-arg`). Never pass secrets here: build
36
+ * args are visible in the image history. Use {@link secrets} instead.
37
+ */
34
38
  args?: Record<string, string>;
39
+ /**
40
+ * BuildKit secrets, keyed by id. Mount one in a `RUN` step with
41
+ * `--mount=type=secret,id=<id>` and read it from `/run/secrets/<id>`, or
42
+ * add `env=<NAME>` to expose it as an environment variable for that step.
43
+ * A secret never enters the image, its build history, or `docker`'s argv.
44
+ */
45
+ secrets?: Record<string, Redacted.Redacted<string>>;
35
46
  /** Multi-stage build target. */
36
47
  target?: string;
37
48
  /** Cache sources passed as `--cache-from`. */
@@ -117,6 +128,23 @@ export interface Image extends Resource<
117
128
  * });
118
129
  * ```
119
130
  *
131
+ * ### Build Secrets
132
+ * **Example:** Install from a private npm registry
133
+ * ```typescript
134
+ * // Dockerfile:
135
+ * // RUN --mount=type=secret,id=NPM_TOKEN,env=NPM_TOKEN npm ci
136
+ * const image = yield* Docker.Image("app", {
137
+ * name: "my-app",
138
+ * build: {
139
+ * context: "./app",
140
+ * secrets: { NPM_TOKEN: Config.Redacted("NPM_TOKEN") },
141
+ * },
142
+ * });
143
+ * ```
144
+ *
145
+ * The secret is mounted only into the `RUN` step that asks for it; it never
146
+ * lands in an image layer or the build history.
147
+ *
120
148
  * ### Registry Push
121
149
  * **Example:** Push with Redacted credentials
122
150
  * ```typescript
@@ -192,6 +220,7 @@ export const ImageProvider = () =>
192
220
  platform: props.build.platform,
193
221
  target: props.build.target,
194
222
  "build-arg": props.build.args,
223
+ secrets: props.build.secrets,
195
224
  "cache-from": props.build.cacheFrom,
196
225
  "cache-to": props.build.cacheTo,
197
226
  args: props.build.options,
@@ -3,9 +3,11 @@ import * as Bundle from "alchemy/Bundle/Bundle";
3
3
  import { findCwdForBundle, getStableContextDir, resolveMainPath } from "alchemy/Bundle/TempRoot";
4
4
  import { hashDirectory } from "alchemy/Command/Memo";
5
5
  import { sha256Object } from "alchemy/Util/sha256";
6
+ import * as Data from "effect/Data";
6
7
  import * as Effect from "effect/Effect";
7
8
  import * as FileSystem from "effect/FileSystem";
8
9
  import * as Path from "effect/Path";
10
+ import type * as Redacted from "effect/Redacted";
9
11
  import * as Schedule from "effect/Schedule";
10
12
  import type * as rolldown from "rolldown";
11
13
  import { Docker, type RegistryCredentials } from "./Docker.ts";
@@ -82,11 +84,41 @@ export interface BundledImageSource {
82
84
  build?: Bundle.BundleConfig;
83
85
  }
84
86
 
87
+ /**
88
+ * Build arguments and BuildKit secrets for an image built from the user's
89
+ * own Dockerfile (`context` / `dockerfile`).
90
+ */
91
+ export interface DockerfileBuild {
92
+ /**
93
+ * Docker build arguments (`--build-arg`), read by `ARG` instructions.
94
+ * They are part of the image's content hash, so changing one rebuilds the
95
+ * image. Never pass secrets here: build args are visible in the image
96
+ * history. Use {@link secrets} instead.
97
+ */
98
+ args?: Record<string, string>;
99
+ /**
100
+ * BuildKit secrets, keyed by id. Mount one in a `RUN` step with
101
+ * `--mount=type=secret,id=<id>` and read it from `/run/secrets/<id>`, or
102
+ * add `env=<NAME>` to expose it as an environment variable for that step.
103
+ *
104
+ * A secret never enters the image, its build history, or `docker`'s argv.
105
+ * Secrets are left out of the image's content hash, so rotating one does
106
+ * not rebuild the image.
107
+ */
108
+ secrets?: Record<string, Redacted.Redacted<string>>;
109
+ }
110
+
111
+ /** The `build` prop of a source built from the user's own Dockerfile. */
112
+ export interface DockerfileBuildOptions {
113
+ /** Build arguments and BuildKit secrets for the Dockerfile build. */
114
+ build?: DockerfileBuild;
115
+ }
116
+
85
117
  /**
86
118
  * Build the image from the user's own Dockerfile and build context — no
87
119
  * Effect program is bundled.
88
120
  */
89
- export interface DockerfileImageSource {
121
+ export interface DockerfileImageSource extends DockerfileBuildOptions {
90
122
  /**
91
123
  * Docker build context directory. Optional when {@link dockerfile} is
92
124
  * inline content (which builds with an empty context).
@@ -123,7 +155,8 @@ export type ImageSourceProps = BundledImageSource | DockerfileImageSource | Regi
123
155
  export interface ImageSourceLike {
124
156
  main?: string;
125
157
  handler?: string;
126
- build?: BundledImageSource["build"];
158
+ /** Bundler config for `main`; Docker build args and secrets for `context`. */
159
+ build?: Bundle.BundleConfig & DockerfileBuild;
127
160
  context?: string;
128
161
  dockerfile?: string | InlineDockerfile;
129
162
  image?: string;
@@ -146,9 +179,41 @@ export const imageSourceKind = (source: ImageSourceLike): ImageSourceKind | unde
146
179
  ? "context"
147
180
  : undefined;
148
181
 
182
+ /**
183
+ * `build.args` or `build.secrets` set on an image that is not built from the
184
+ * user's own Dockerfile: a `main` program or a pre-built `image`.
185
+ */
186
+ export class DockerfileBuildConflict extends Data.TaggedError("DockerfileBuildConflict")<{
187
+ readonly id: string;
188
+ readonly source: "main" | "image";
189
+ }> {
190
+ override get message() {
191
+ return `'${this.id}': 'build.args' and 'build.secrets' apply to an image built from your own Dockerfile ('context' / 'dockerfile'), not to '${this.source}'`;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Dies with {@link DockerfileBuildConflict} when `build.args` or
197
+ * `build.secrets` are set alongside `main` or `image`.
198
+ */
199
+ export const validateDockerfileBuild = (
200
+ id: string,
201
+ source: { main?: string; image?: string; build?: DockerfileBuild },
202
+ ): Effect.Effect<void> =>
203
+ (source.main !== undefined || source.image !== undefined) &&
204
+ (source.build?.args !== undefined || source.build?.secrets !== undefined)
205
+ ? Effect.die(
206
+ new DockerfileBuildConflict({
207
+ id,
208
+ source: source.main !== undefined ? "main" : "image",
209
+ }),
210
+ )
211
+ : Effect.void;
212
+
149
213
  /**
150
214
  * Validate environment-source exclusivity. Dies (plan-time defect) on
151
- * `image`+`dockerfile`, `image`+`context`, or inline-`dockerfile`+`context`.
215
+ * `image`+`dockerfile`, `image`+`context`, inline-`dockerfile`+`context`, or
216
+ * build args/secrets without a Dockerfile build.
152
217
  */
153
218
  export const validateImageSource = (id: string, source: ImageSourceLike): Effect.Effect<void> => {
154
219
  if (source.image !== undefined && source.dockerfile !== undefined) {
@@ -176,7 +241,7 @@ export const validateImageSource = (id: string, source: ImageSourceLike): Effect
176
241
  ),
177
242
  );
178
243
  }
179
- return Effect.void;
244
+ return validateDockerfileBuild(id, source);
180
245
  };
181
246
 
182
247
  /**
@@ -287,6 +352,8 @@ export const computeStaticSourceHash = Effect.fn(function* (
287
352
  return (yield* sha256Object({
288
353
  dockerfile: source.dockerfile.content,
289
354
  platform: platform ?? "linux/amd64",
355
+ // Absent when unset, so existing hashes hold. Secrets are excluded.
356
+ buildArgs: source.build?.args,
290
357
  })).slice(0, 16);
291
358
  }
292
359
  const fs = yield* FileSystem.FileSystem;
@@ -300,6 +367,7 @@ export const computeStaticSourceHash = Effect.fn(function* (
300
367
  contextHash,
301
368
  dockerfile: dockerfileContent,
302
369
  platform: platform ?? "linux/amd64",
370
+ buildArgs: source.build?.args,
303
371
  })).slice(0, 16);
304
372
  }
305
373
  return undefined;
@@ -460,7 +528,7 @@ export const makeContainerImageSource = Effect.gen(function* () {
460
528
  envFrom !== undefined
461
529
  ? `FROM ${envFrom}`
462
530
  : source.dockerfile !== undefined && isInlineDockerfile(source.dockerfile)
463
- ? String(source.dockerfile.content).trimEnd()
531
+ ? (source.dockerfile.content as string).trimEnd()
464
532
  : `FROM ${source.image ?? "oven/bun:1"}`;
465
533
  const lines = [
466
534
  preamble,
@@ -514,6 +582,7 @@ export const makeContainerImageSource = Effect.gen(function* () {
514
582
  context: string;
515
583
  dockerfile?: string;
516
584
  platform?: string;
585
+ build?: DockerfileBuild;
517
586
  },
518
587
  ) {
519
588
  const credentials = yield* target.credentials;
@@ -522,6 +591,8 @@ export const makeContainerImageSource = Effect.gen(function* () {
522
591
  context: options.context,
523
592
  file: options.dockerfile,
524
593
  platform: options.platform,
594
+ "build-arg": options.build?.args,
595
+ secrets: options.build?.secrets,
525
596
  });
526
597
  yield* push(options.imageUri, credentials, options.platform);
527
598
  return options.imageUri;
@@ -676,6 +747,7 @@ export const makeContainerImageSource = Effect.gen(function* () {
676
747
  imageUri,
677
748
  context: contextDir,
678
749
  platform,
750
+ build: source.build,
679
751
  });
680
752
  yield* session.note(`Pushed ${imageUri}`);
681
753
  return { imageUri, repositoryUri, codeHash };
@@ -698,6 +770,7 @@ export const makeContainerImageSource = Effect.gen(function* () {
698
770
  context,
699
771
  dockerfile,
700
772
  platform,
773
+ build: source.build,
701
774
  });
702
775
  yield* session.note(`Pushed ${imageUri}`);
703
776
  return { imageUri, repositoryUri, codeHash };
@@ -140,7 +140,7 @@ export const RemoteImageProvider = () =>
140
140
  const inspectImage = (ref: string, context: string | undefined) =>
141
141
  docker.image
142
142
  .inspect(ref, context)
143
- .pipe(Effect.catchIf(isMissingImageError, () => Effect.succeed(undefined)));
143
+ .pipe(Effect.catchIf(isMissingImageError, () => Effect.undefined));
144
144
 
145
145
  return RemoteImage.Provider.of({
146
146
  list: () => Effect.succeed([]),