1import * as Config from "effect/Config";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import { Base64 } from "effect/encoding";
5import * as FileSystem from "effect/FileSystem";
6import { flow } from "effect/Function";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import {
11 PlatformError,
12 SystemError,
13 type SystemErrorTag,
14} from "effect/PlatformError";
15import * as Redacted from "effect/Redacted";
16import * as Result from "effect/Result";
17import * as Schema from "effect/Schema";
18import * as Sink from "effect/Sink";
19import * as Stream from "effect/Stream";
20import * as ChildProcess from "effect/process/ChildProcess";
21import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
22import type { ScopedPlanStatusSession } from "../Report.ts";
23import { createPhysicalName } from "../PhysicalName.ts";
24import {
25 classifyDockerRegistryError,
26 type DockerImagePublicationError,
27} from "./RegistryError.ts";
28
29/** Credentials for a single image registry, scoped to a Docker command. */
30export interface RegistryCredentials {
31 /** Registry hostname, without a URL scheme. */
32 server: string;
33 /** Registry authentication username. */
34 username: string;
35 /** Registry password or access token. */
36 password: string | Redacted.Redacted<string>;
37}
38
39const RegistryAuth = Schema.String.pipe(
40 Schema.check(
41 Schema.makeFilter((value) => {
42 const decoded = Base64.decode(value);
43 return Result.isSuccess(decoded) && decoded.success.indexOf(58) > 0;
44 }),
45 ),
46);
47
48const decodeRegistryAuthConfig = Schema.Struct({
49 auths: Schema.Record(
50 Schema.String,
51 Schema.Struct({ auth: RegistryAuth }),
52 ).pipe(Schema.NullOr, Schema.optional),
53}).pipe(Schema.fromJsonString, (schema) =>
54 Schema.decodeEffect(schema, { onExcessProperty: "error" }),
55);
56
57export class Docker extends Context.Service<
58 Docker,
59 {
60 /** Runs a Docker command and returns the output. Use this to run a command that doesn't have a dedicated method. */
61 readonly run: (
62 args: Array<string>,
63 ) => Effect.Effect<CommandOutput, PlatformError>;
64 /** Writes build files and an inline Dockerfile to the given context directory. */
65 readonly materialize: (options: {
66 context: string;
67 dockerfile: string;
68 files: ReadonlyArray<{
69 path: string;
70 content: string | Uint8Array;
71 }>;
72 }) => Effect.Effect<void, PlatformError>;
73 readonly container: {
74 /** Creates a new container. */
75 readonly create: (options: {
76 name: string;
77 image: string;
78 volume: Array<string> | undefined;
79 env: Record<string, string> | undefined;
80 restart: "no" | "always" | "on-failure" | "unless-stopped";
81 rm: boolean;
82 "health-cmd": string | undefined;
83 "health-interval": string | undefined;
84 "health-timeout": string | undefined;
85 "health-retries": number | undefined;
86 "health-start-period": string | undefined;
87 "health-start-interval": string | undefined;
88 "stop-timeout": string | undefined;
89 p: Array<string> | undefined;
90 /** `--add-host` entries, each `hostname:address`. */
91 "add-host"?: Array<string> | undefined;
92 command: Array<string> | undefined;
93 label?: Record<string, string>;
94 context?: string;
95 }) => Effect.Effect<CommandOutput, PlatformError>;
96 /** Inspects a container. */
97 readonly inspect: (
98 name: string,
99 context?: string,
100 ) => Effect.Effect<Docker.Container, PlatformError>;
101 /** Removes a container. */
102 readonly remove: (
103 name: string,
104 force?: boolean,
105 context?: string,
106 ) => Effect.Effect<CommandOutput, PlatformError>;
107 /** Starts a container. */
108 readonly start: (
109 name: string,
110 context?: string,
111 ) => Effect.Effect<CommandOutput, PlatformError>;
112 /** Stops a container. */
113 readonly stop: (
114 name: string,
115 context?: string,
116 ) => Effect.Effect<CommandOutput, PlatformError>;
117 };
118 readonly image: {
119 /**
120 * Builds locally, or publishes to a registry when credentials are
121 * supplied. With Buildx 0.26.0 or newer the image is exported straight
122 * from BuildKit (`buildx build --push`); older plugins build into the
123 * local image store and then `push`. If a session is provided, build
124 * logs will be emitted as session notes.
125 */
126 readonly build: (
127 options: {
128 context: string;
129 /** Image tags to build and publish together. */
130 tag: string | [string, ...string[]];
131 file?: string;
132 platform?: string;
133 target?: string;
134 "build-arg"?: Record<string, string>;
135 "cache-from"?: Array<string>;
136 "cache-to"?: Array<string>;
137 args?: Array<string>;
138 engineContext?: string;
139 },
140 session?: ScopedPlanStatusSession,
141 registry?: RegistryCredentials,
142 ) => Effect.Effect<CommandOutput, DockerImagePublicationError>;
143 /** Pulls an image. */
144 readonly pull: (
145 ref: string,
146 platform?: string,
147 context?: string,
148 ) => Effect.Effect<CommandOutput, PlatformError>;
149 /**
150 * Pushes an image to a registry. When `platform` is given, only that
151 * platform's manifest is pushed (`docker push --platform`) — with the
152 * containerd image store, a bare push sends every locally-present
153 * variant of a multi-arch tag, so a stale other-arch variant in the
154 * local cache would otherwise reach the registry. Engines whose store
155 * doesn't support the flag fall back to a plain push (their local tag
156 * is already narrowed by `pull --platform`).
157 */
158 readonly push: (
159 ref: string,
160 credentials: RegistryCredentials,
161 platform?: string,
162 context?: string,
163 ) => Effect.Effect<CommandOutput, DockerImagePublicationError>;
164 /** Tags an image. */
165 readonly tag: (
166 source: string,
167 target: string,
168 context?: string,
169 ) => Effect.Effect<CommandOutput, PlatformError>;
170 /** Inspects an image. */
171 readonly inspect: (
172 ref: string,
173 context?: string,
174 ) => Effect.Effect<Docker.Image, PlatformError>;
175 /** Removes an image. */
176 readonly remove: (
177 ref: string | Array<string>,
178 force?: boolean,
179 context?: string,
180 ) => Effect.Effect<CommandOutput, PlatformError>;
181 };
182 readonly volume: {
183 /** Creates a new volume. */
184 readonly create: (options: {
185 name: string;
186 driver?: string;
187 opt?: Record<string, string>;
188 label?: Record<string, string>;
189 context?: string;
190 }) => Effect.Effect<CommandOutput, PlatformError>;
191 /** Removes a volume. */
192 readonly remove: (
193 name: string,
194 context?: string,
195 ) => Effect.Effect<CommandOutput, PlatformError>;
196 /** Inspects a volume. */
197 readonly inspect: (
198 name: string,
199 context?: string,
200 ) => Effect.Effect<Docker.Volume, PlatformError>;
201 };
202 readonly context: {
203 /** Creates a new Docker context. */
204 readonly create: (options: {
205 name: string;
206 description?: string;
207 /** Raw value for `--docker`, for example `host=ssh://user@host`. */
208 docker?: string;
209 }) => Effect.Effect<CommandOutput, PlatformError>;
210 /** Updates an existing Docker context. */
211 readonly update: (options: {
212 name: string;
213 description?: string;
214 /** Raw value for `--docker`, for example `host=ssh://user@host`. */
215 docker?: string;
216 }) => Effect.Effect<CommandOutput, PlatformError>;
217 /** Inspects a Docker context. */
218 readonly inspect: (
219 name: string,
220 ) => Effect.Effect<Docker.Context, PlatformError>;
221 /** Removes a Docker context. */
222 readonly remove: (
223 name: string,
224 force?: boolean,
225 ) => Effect.Effect<CommandOutput, PlatformError>;
226 };
227 readonly network: {
228 /** Creates a new network. */
229 readonly create: (options: {
230 name: string;
231 driver: string;
232 ipv6?: boolean;
233 label?: Record<string, string>;
234 context?: string;
235 }) => Effect.Effect<CommandOutput, PlatformError>;
236 /** Connects a container to a network. */
237 readonly connect: (options: {
238 network: string;
239 container: string;
240 alias?: string[];
241 context?: string;
242 }) => Effect.Effect<CommandOutput, PlatformError>;
243 /** Disconnects a container from a network. */
244 readonly disconnect: (options: {
245 network: string;
246 container: string;
247 context?: string;
248 }) => Effect.Effect<CommandOutput, PlatformError>;
249 /** Inspects a network. */
250 readonly inspect: (
251 name: string,
252 context?: string,
253 ) => Effect.Effect<Docker.Network, PlatformError>;
254 /** Removes a network. */
255 readonly remove: (
256 id: string,
257 context?: string,
258 ) => Effect.Effect<CommandOutput, PlatformError>;
259 };
260 readonly swarm: {
261 /** Initializes swarm mode on the engine (`docker swarm init`). */
262 readonly init: (options: {
263 context?: string;
264 "advertise-addr"?: string;
265 "listen-addr"?: string;
266 "default-addr-pool"?: string[];
267 "default-addr-pool-mask-length"?: number;
268 }) => Effect.Effect<CommandOutput, PlatformError>;
269 /** Reads the engine's swarm state (`docker info --format '{{json .Swarm}}'`). */
270 readonly info: (
271 context?: string,
272 ) => Effect.Effect<Docker.SwarmInfo, PlatformError>;
273 /** Leaves the swarm (`docker swarm leave`). */
274 readonly leave: (
275 force?: boolean,
276 context?: string,
277 ) => Effect.Effect<CommandOutput, PlatformError>;
278 };
279 readonly service: {
280 /** Creates a new service. */
281 readonly create: (options: {
282 name: string;
283 image: string;
284 context?: string;
285 replicas?: number;
286 "endpoint-mode"?: "vip" | "dnsrr";
287 network?: string[];
288 constraint?: string[];
289 "replicas-max-per-node"?: number;
290 "placement-pref"?: string[];
291 "update-parallelism"?: number;
292 "update-delay"?: string;
293 "update-monitor"?: string;
294 "update-failure-action"?: "pause" | "continue" | "rollback";
295 "update-max-failure-ratio"?: number;
296 "update-order"?: "stop-first" | "start-first";
297 "rollback-parallelism"?: number;
298 "rollback-delay"?: string;
299 "rollback-monitor"?: string;
300 "rollback-failure-action"?: "pause" | "continue" | "rollback";
301 "rollback-max-failure-ratio"?: number;
302 "rollback-order"?: "stop-first" | "start-first";
303 "restart-condition"?: "none" | "on-failure" | "any";
304 "restart-delay"?: string;
305 "restart-max-attempts"?: number;
306 "restart-window"?: string;
307 "health-cmd"?: string;
308 "health-interval"?: string;
309 "health-timeout"?: string;
310 "health-retries"?: number;
311 "health-start-period"?: string;
312 "stop-grace-period"?: string;
313 mount?: string[];
314 secret?: string[];
315 config?: string[];
316 "read-only"?: boolean;
317 publish?: string[];
318 label?: Record<string, string>;
319 env?: Record<string, string>;
320 command?: string[];
321 args?: string[];
322 }) => Effect.Effect<CommandOutput, PlatformError>;
323 /** Updates an existing service. */
324 readonly update: (options: {
325 id: string;
326 image: string;
327 context?: string;
328 replicas?: number;
329 "endpoint-mode"?: "vip" | "dnsrr";
330 "constraint-add"?: string[];
331 "constraint-rm"?: string[];
332 "replicas-max-per-node"?: number;
333 "placement-pref"?: string[];
334 "update-parallelism"?: number;
335 "update-delay"?: string;
336 "update-monitor"?: string;
337 "update-failure-action"?: "pause" | "continue" | "rollback";
338 "update-max-failure-ratio"?: number;
339 "update-order"?: "stop-first" | "start-first";
340 "rollback-parallelism"?: number;
341 "rollback-delay"?: string;
342 "rollback-monitor"?: string;
343 "rollback-failure-action"?: "pause" | "continue" | "rollback";
344 "rollback-max-failure-ratio"?: number;
345 "rollback-order"?: "stop-first" | "start-first";
346 "restart-condition"?: "none" | "on-failure" | "any";
347 "restart-delay"?: string;
348 "restart-max-attempts"?: number;
349 "restart-window"?: string;
350 "health-cmd"?: string;
351 "health-interval"?: string;
352 "health-timeout"?: string;
353 "health-retries"?: number;
354 "health-start-period"?: string;
355 "stop-grace-period"?: string;
356 "mount-add"?: string[];
357 "secret-add"?: string[];
358 "config-add"?: string[];
359 "read-only"?: boolean;
360 "publish-add"?: string[];
361 "label-add"?: Record<string, string>;
362 "label-rm"?: string[];
363 "env-add"?: Record<string, string>;
364 args?: string;
365 }) => Effect.Effect<CommandOutput, PlatformError>;
366 /** Inspects a service. */
367 readonly inspect: (
368 id: string,
369 context?: string,
370 ) => Effect.Effect<CommandOutput, PlatformError>;
371 /** Removes a service. */
372 readonly remove: (
373 id: string,
374 context?: string,
375 ) => Effect.Effect<CommandOutput, PlatformError>;
376 };
377 }
378>()("@alchemy/Docker") {}
379
380export declare namespace Docker {
381 export type ContextRef = string | { name: string };
382
383 /**
384 * A reference to the Docker engine an operation runs against: a context
385 * name, a `Docker.Context` resource, or a `Docker.Swarm` resource
386 * (narrowed structurally by its `nodeId` attribute) — passing the swarm
387 * also orders the operation after the swarm is initialized.
388 */
389 export type EngineRef = ContextRef | { nodeId: string; context?: string };
390
391 export interface SwarmInfo {
392 NodeID: string;
393 LocalNodeState:
394 | "inactive"
395 | "pending"
396 | "active"
397 | "error"
398 | "locked"
399 | (string & {});
400 ControlAvailable: boolean;
401 Cluster?: { ID: string; CreatedAt?: string } | null;
402 RemoteManagers?: Array<{ NodeID: string; Addr: string }> | null;
403 Managers?: number;
404 Nodes?: number;
405 }
406
407 export type ContainerStatus =
408 | "created"
409 | "running"
410 | "paused"
411 | "restarting"
412 | "removing"
413 | "exited"
414 | "dead";
415
416 export interface Container {
417 Id: string;
418 Name?: string;
419 State: { Status: ContainerStatus };
420 Created: string;
421 Config: {
422 Image: string;
423 Cmd: string[] | null;
424 Env: string[] | null;
425 Labels: Record<string, string> | null;
426 StopTimeout?: number;
427 Healthcheck?: {
428 Test: string[] | null;
429 Interval?: number;
430 Timeout?: number;
431 Retries?: number;
432 StartPeriod?: number;
433 StartInterval?: number;
434 } | null;
435 };
436 HostConfig: {
437 PortBindings: Record<
438 string,
439 Array<{ HostIp: string; HostPort: string }> | null
440 > | null;
441 Binds: string[] | null;
442 ExtraHosts: string[] | null;
443 RestartPolicy: {
444 Name: string;
445 MaximumRetryCount: number;
446 };
447 AutoRemove: boolean;
448 };
449 NetworkSettings: {
450 Networks: Record<
451 string,
452 {
453 NetworkID: string;
454 Aliases: string[] | null;
455 }
456 > | null;
457 Ports?: Record<
458 string,
459 Array<{ HostIp: string; HostPort: string }> | null
460 > | null;
461 };
462 }
463
464 export interface Image {
465 Id: string;
466 Created?: string;
467 RepoTags?: string[] | null;
468 RepoDigests?: string[] | null;
469 }
470
471 export interface Volume {
472 CreatedAt: string;
473 Driver: string;
474 Labels: Record<string, string> | null;
475 Mountpoint: string;
476 Name: string;
477 Options: Record<string, string> | null;
478 Scope: string;
479 }
480
481 export interface Context {
482 Name: string;
483 Metadata?: {
484 Description?: string;
485 };
486 Endpoints?: {
487 docker?: string;
488 };
489 }
490
491 export interface Network {
492 Name: string;
493 Id: string;
494 Created: string;
495 Scope: string;
496 Driver: string;
497 EnableIPv6: boolean;
498 Labels: Record<string, string> | null;
499 }
500}
501
502export interface CommandOutput {
503 exitCode: ChildProcessSpawner.ExitCode;
504 stdout: string;
505 stderr: string;
506}
507
508const DockerBin = Config.String("DOCKER_BIN").pipe(
509 Effect.orElseSucceed(() => "docker"),
510);
511
512export const DockerLive = Layer.effect(
513 Docker,
514 Effect.gen(function* () {
515 const fs = yield* FileSystem.FileSystem;
516 const path = yield* Path.Path;
517 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
518 const bin = yield* DockerBin;
519
520 const run = (
521 args: Array<string>,
522 env?: Record<string, string>,
523 tap: (
524 stream: Stream.Stream<string, PlatformError, never>,
525 ) => Stream.Stream<string, PlatformError, never> = Stream.tap(
526 Effect.logDebug,
527 ),
528 ) =>
529 ChildProcess.make(bin, args, {
530 stdin: "ignore",
531 stdout: "pipe",
532 stderr: "pipe",
533 detached: false,
534 env,
535 extendEnv: true,
536 }).pipe(
537 spawner.spawn,
538 Effect.flatMap((child) =>
539 Effect.all(
540 {
541 exitCode: child.exitCode,
542 stdout: child.stdout.pipe(
543 Stream.decodeText,
544 tap,
545 Stream.mkString,
546 Effect.map((stdout) => stdout.trim()),
547 ),
548 stderr: child.stderr.pipe(
549 Stream.decodeText,
550 tap,
551 Stream.mkString,
552 Effect.map((stderr) => stderr.trim()),
553 ),
554 },
555 { concurrency: "unbounded" },
556 ),
557 ),
558 Effect.mapError((error) =>
559 systemError({
560 _tag: "Unknown",
561 args,
562 description: "The command failed unexpectedly.",
563 cause: error.reason,
564 }),
565 ),
566 Effect.tap((result) => {
567 if (result.exitCode === 0) return Effect.void;
568 const stderr = result.stderr.replace(
569 /^Error response from daemon: /,
570 "",
571 );
572 if (stderr.match(/no such/i) || stderr.match(/not found/i)) {
573 return systemError({
574 _tag: "NotFound",
575 args,
576 description: stderr,
577 });
578 }
579 if (stderr.match(/already exists/i)) {
580 return systemError({
581 _tag: "AlreadyExists",
582 args,
583 description: stderr,
584 });
585 }
586 return systemError({
587 _tag: "Unknown",
588 args,
589 description: `Command exited with code ${result.exitCode}: ${stderr}`,
590 });
591 }),
592 Effect.scoped,
593 );
594
595 const runInspect = <T>(args: Array<string>) =>
596 run(args).pipe(
597 Effect.map((result) => {
598 const [item] = JSON.parse(result.stdout) as T[];
599 return item;
600 }),
601 );
602
603 const registryEnvironment = Effect.fn("registryEnvironment")(
604 (credentials: RegistryCredentials) =>
605 Config.Redacted("DOCKER_AUTH_CONFIG").pipe(
606 Config.withDefault(Redacted.make("{}")),
607 Effect.map((value) => Redacted.value(value) || "{}"),
608 Effect.flatMap(decodeRegistryAuthConfig),
609 // Schema diagnostics may include credential input. Do not retain them.
610 Effect.mapError(() =>
611 systemError({
612 _tag: "InvalidData",
613 args: ["buildx", "build"],
614 description: "Invalid DOCKER_AUTH_CONFIG; expected an auths map.",
615 }),
616 ),
617 Effect.map((current) => {
618 const password = Redacted.isRedacted(credentials.password)
619 ? Redacted.value(credentials.password)
620 : credentials.password;
621 const auth = Base64.encode(`${credentials.username}:${password}`);
622 // Preserve Docker's file/helper fallback, contexts, and builders.
623 return {
624 DOCKER_AUTH_CONFIG: JSON.stringify({
625 auths: { ...current.auths, [credentials.server]: { auth } },
626 }),
627 };
628 }),
629 ),
630 );
631
632 // How a build reaches a registry. Buildx 0.26 is the first release
633 // embedding Docker CLI 28.3's DOCKER_AUTH_CONFIG credential store, so it
634 // can `buildx build --push` straight from BuildKit. Older plugins (e.g.
635 // the buildx bundled with Docker Desktop < 4.44) silently ignore the
636 // variable, so the image is instead `--load`ed into the local store and
637 // published with the isolated-config `push`. Without any buildx plugin
638 // the legacy builder always loads, so a plain `image build` suffices.
639 // Probed once per Docker service instance.
640 const publication = yield* Effect.cached(
641 Effect.gen(function* () {
642 const version = yield* run(["buildx", "version"]).pipe(Effect.option);
643 if (Option.isNone(version)) return "legacy" as const;
644 // Numeric template captures can consume version separators as decimals.
645 const match =
646 /^github\.com\/docker\/buildx v(\d+)\.(\d+)\.\d+(?:[-+][^\s]+)?(?:\s.*)?$/.exec(
647 version.value.stdout,
648 );
649 if (!match) return "load" as const;
650 const major = Number(match[1]);
651 const minor = Number(match[2]);
652 return major >= 1 || minor >= 26
653 ? ("export" as const)
654 : ("load" as const);
655 }),
656 );
657
658 const push: Docker["Service"]["image"]["push"] = Effect.fn(
659 function* (ref, credentials, platform, context) {
660 // Write the registry credentials directly into an isolated docker config
661 // as a plaintext `auths` entry and skip `docker login` entirely.
662 //
663 // `docker login` is the wrong tool here: on macOS Docker Desktop it routes
664 // through the shared `osxkeychain`/`desktop` credential helper *regardless*
665 // of an isolated DOCKER_CONFIG, so concurrent deploys either race the system
666 // keychain (`The specified item already exists in the keychain (-25299)`) or
667 // land the credential in the helper — leaving this isolated config without
668 // an `auths` entry, so the subsequent `docker push` fails with "no basic
669 // auth credentials". Embedding the base64 `auth` inline (the same thing
670 // `docker login` would write when no credsStore is configured) makes each
671 // deploy fully self-contained: no credential helper, no keychain, no login
672 // race. Only `push` reads this config; `build`/`pull`/`tag` keep using the
673 // global docker config (buildx builders, `docker context`, etc. intact).
674 const dir = yield* fs.makeTempDirectoryScoped({
675 prefix: "alchemy-docker-",
676 });
677 const config = yield* Effect.sync(() => {
678 const password = Redacted.isRedacted(credentials.password)
679 ? Redacted.value(credentials.password)
680 : credentials.password;
681 const auth = Buffer.from(
682 `${credentials.username}:${password}`,
683 ).toString("base64");
684 return JSON.stringify({
685 auths: {
686 [credentials.server]: { auth },
687 },
688 });
689 });
690 yield* fs.writeFileString(path.join(dir, "config.json"), config);
691 if (platform === undefined) {
692 return yield* run([...formatArgs({ context }), "push", ref], {
693 DOCKER_CONFIG: dir,
694 });
695 }
696 return yield* run(
697 [...formatArgs({ context }), "push", "--platform", platform, ref],
698 { DOCKER_CONFIG: dir },
699 ).pipe(
700 // Engines without the containerd image store reject `--platform`
701 // on push; their local tag is already narrowed to the requested
702 // platform by `pull --platform`, so a plain push is equivalent.
703 Effect.catchIf(
704 (error) =>
705 /--platform|unknown flag|containerd/i.test(String(error)),
706 () =>
707 run([...formatArgs({ context }), "push", ref], {
708 DOCKER_CONFIG: dir,
709 }),
710 ),
711 );
712 },
713 Effect.scoped,
714 Effect.mapError(classifyDockerRegistryError),
715 );
716
717 return Docker.of({
718 run,
719 materialize: Effect.fn((options) =>
720 Effect.forEach(
721 [
722 ...options.files,
723 { path: "Dockerfile", content: options.dockerfile },
724 ],
725 (file) => {
726 const fullPath = path.join(options.context, file.path);
727 return fs
728 .makeDirectory(path.dirname(fullPath), { recursive: true })
729 .pipe(
730 Effect.andThen(
731 typeof file.content === "string"
732 ? fs.writeFileString(fullPath, file.content)
733 : fs.writeFile(fullPath, file.content),
734 ),
735 );
736 },
737 { concurrency: "unbounded" },
738 ),
739 ),
740 container: {
741 create: ({ image, env, command, context, ...options }) =>
742 // `--env KEY` (name-only) keeps values off CLI arguments; Docker
743 // copies each value from the CLI process environment, so the
744 // normalized container env must be passed to the child process.
745 run(
746 [
747 ...formatArgs({ context }),
748 "container",
749 "create",
750 ...formatArgs({
751 ...options,
752 env: env ? Object.keys(env) : undefined,
753 }),
754 image,
755 ...(command ?? []),
756 ],
757 env,
758 ),
759 inspect: (name, context) =>
760 runInspect<Docker.Container>([
761 ...formatArgs({ context }),
762 "container",
763 "inspect",
764 name,
765 ]),
766 remove: (name, force, context) =>
767 run([
768 ...formatArgs({ context }),
769 "container",
770 "rm",
771 name,
772 ...(force ? ["-f"] : []),
773 ]),
774 start: (name, context) =>
775 run([...formatArgs({ context }), "container", "start", name]),
776 stop: (name, context) =>
777 run([...formatArgs({ context }), "container", "stop", name]),
778 },
779 image: {
780 build: Effect.fn("Docker.image.build")(function* (
781 { context: buildContext, engineContext, args, ...options },
782 session,
783 registry,
784 ) {
785 const tap = session
786 ? Stream.tapSink(
787 Sink.make<string>()(
788 flow(
789 Stream.splitLines,
790 Stream.runForEach((line) =>
791 session.note(line, { kind: "output" }),
792 ),
793 ),
794 ),
795 )
796 : undefined;
797 const buildArgs = [
798 buildContext,
799 ...formatArgs(options),
800 ...(args ?? []),
801 ];
802 const engine = formatArgs({ context: engineContext });
803 if (registry === undefined) {
804 return yield* run(
805 [...engine, "image", "build", ...buildArgs],
806 undefined,
807 tap,
808 );
809 }
810 const mode = yield* publication;
811 if (mode === "export") {
812 // Export straight from BuildKit to the registry; the image never
813 // round-trips through the local image store.
814 const env = yield* registryEnvironment(registry);
815 return yield* run(
816 [...engine, "buildx", "build", "--push", ...buildArgs],
817 env,
818 tap,
819 ).pipe(Effect.mapError(classifyDockerRegistryError));
820 }
821 // Older Buildx ignores DOCKER_AUTH_CONFIG, so a `--push` export
822 // would fail with "no basic auth credentials". Build into the local
823 // store and publish with the isolated-config `push` instead.
824 // `--load` matters for non-loading builders (docker-container):
825 // without it the result stays in the build cache and `push` sees no
826 // such tag.
827 yield* run(
828 [
829 ...engine,
830 ...(mode === "load"
831 ? ["buildx", "build", "--load"]
832 : ["image", "build"]),
833 ...buildArgs,
834 ],
835 undefined,
836 tap,
837 );
838 const [tag, ...tags] =
839 typeof options.tag === "string"
840 ? ([options.tag] as const)
841 : options.tag;
842 return yield* push(
843 tag,
844 registry,
845 options.platform,
846 engineContext,
847 ).pipe(
848 Effect.tap(() =>
849 Effect.forEach(tags, (tag) =>
850 push(tag, registry, options.platform, engineContext),
851 ),
852 ),
853 );
854 }),
855 pull: (ref, platform, context) =>
856 run([
857 ...formatArgs({ context }),
858 "image",
859 "pull",
860 ref,
861 ...(platform ? ["--platform", platform] : []),
862 ]),
863 inspect: (ref, context) =>
864 runInspect<Docker.Image>([
865 ...formatArgs({ context }),
866 "image",
867 "inspect",
868 ref,
869 ]),
870 remove: (ref, force, context) =>
871 run([
872 ...formatArgs({ context }),
873 "image",
874 "rm",
875 ...(Array.isArray(ref) ? ref : [ref]),
876 ...(force ? ["-f"] : []),
877 ]),
878 tag: (source, target, context) =>
879 run([...formatArgs({ context }), "image", "tag", source, target]),
880 push,
881 },
882 volume: {
883 create: ({ context, ...options }) =>
884 run([
885 ...formatArgs({ context }),
886 "volume",
887 "create",
888 ...formatArgs(options),
889 ]),
890 remove: (name, context) =>
891 run([...formatArgs({ context }), "volume", "rm", name]),
892 inspect: (name, context) =>
893 runInspect<Docker.Volume>([
894 ...formatArgs({ context }),
895 "volume",
896 "inspect",
897 name,
898 ]),
899 },
900 context: {
901 create: ({ name, description, docker }) =>
902 run([
903 "context",
904 "create",
905 name,
906 ...formatArgs({ description, docker }),
907 ]),
908 update: ({ name, description, docker }) =>
909 run([
910 "context",
911 "update",
912 name,
913 ...formatArgs({ description, docker }),
914 ]),
915 inspect: (name) =>
916 runInspect<Docker.Context>(["context", "inspect", name]),
917 remove: (name, force) =>
918 run(["context", "rm", ...(force ? ["-f"] : []), name]),
919 },
920 network: {
921 create: ({ name, driver, ipv6, label, context }) =>
922 run([
923 ...formatArgs({ context }),
924 "network",
925 "create",
926 name,
927 ...formatArgs({ driver, ipv6, label }),
928 ]),
929 connect: ({ network, container, alias, context }) =>
930 run([
931 ...formatArgs({ context }),
932 "network",
933 "connect",
934 network,
935 container,
936 ...(alias ? alias.flatMap((a) => ["--alias", a]) : []),
937 ]),
938 disconnect: ({ network, container, context }) =>
939 run([
940 ...formatArgs({ context }),
941 "network",
942 "disconnect",
943 network,
944 container,
945 ]),
946 inspect: (name, context) =>
947 runInspect<Docker.Network>([
948 ...formatArgs({ context }),
949 "network",
950 "inspect",
951 name,
952 ]),
953 remove: (id, context) =>
954 run([...formatArgs({ context }), "network", "rm", id]),
955 },
956 swarm: {
957 init: ({ context, ...options }) =>
958 run([
959 ...formatArgs({ context }),
960 "swarm",
961 "init",
962 ...formatArgs(options),
963 ]),
964 info: (context) =>
965 run([
966 ...formatArgs({ context }),
967 "info",
968 "--format",
969 "{{json .Swarm}}",
970 ]).pipe(
971 Effect.map(
972 (result) => JSON.parse(result.stdout) as Docker.SwarmInfo,
973 ),
974 ),
975 leave: (force, context) =>
976 run([
977 ...formatArgs({ context }),
978 "swarm",
979 "leave",
980 ...(force ? ["--force"] : []),
981 ]),
982 },
983 service: {
984 create: ({ context, image, command, args, ...options }) =>
985 run([
986 ...formatArgs({ context }),
987 "service",
988 "create",
989 ...formatArgs({ ...options }),
990 image,
991 ...(command ?? []),
992 ...(args ?? []),
993 ]),
994 update: ({ context, id, ...options }) =>
995 run([
996 ...formatArgs({ context }),
997 "service",
998 "update",
999 "--detach=false",
1000 ...formatArgs({ ...options }),
1001 id,
1002 ]),
1003 inspect: (id, context) =>
1004 runInspect([...formatArgs({ context }), "service", "inspect", id]),
1005 remove: (id, context) =>
1006 run([...formatArgs({ context }), "service", "rm", id]),
1007 },
1008 });
1009 }),
1010);
1011
1012export const dockerContextName = (
1013 context: Docker.ContextRef | undefined,
1014): string | undefined => {
1015 const value = typeof context === "string" ? context : context?.name;
1016 const normalized = value?.trim();
1017 return normalized && normalized.length > 0 ? normalized : undefined;
1018};
1019
1020/**
1021 * Resolve an {@link Docker.EngineRef} to a context name. A `Docker.Swarm`
1022 * reference (narrowed by its `nodeId` attribute) contributes the context the
1023 * swarm was initialized on.
1024 */
1025export const dockerEngineContextName = (
1026 ref: Docker.EngineRef | undefined,
1027): string | undefined => {
1028 if (typeof ref === "object" && ref !== null && "nodeId" in ref) {
1029 return dockerContextName((ref as { context?: string }).context);
1030 }
1031 return dockerContextName(ref as Docker.ContextRef | undefined);
1032};
1033
1034export const dockerPhysicalName = (
1035 id: string,
1036 props: { name?: string } | undefined,
1037 instanceId: string,
1038 // Swarm service names are capped at 63 characters; other Docker object
1039 // names accept much longer values.
1040 maxLength = 128,
1041) =>
1042 props?.name
1043 ? Effect.succeed(props.name)
1044 : createPhysicalName({
1045 id,
1046 instanceId,
1047 maxLength,
1048 lowercase: true,
1049 });
1050
1051/** Constructs a PlatformError from a command execution result. */
1052const systemError = (input: {
1053 _tag: SystemErrorTag;
1054 args: Array<string>;
1055 description?: string;
1056 cause?: unknown;
1057}) =>
1058 new PlatformError(
1059 new SystemError({
1060 _tag: input._tag,
1061 module: "Docker",
1062 method: input.args.slice(0, 2).join("."),
1063 pathOrDescriptor: input.args.join(" "),
1064 description: input.description,
1065 cause: input.cause,
1066 }),
1067 );
1068
1069/** Formats a set of options into a list of command-line arguments. */
1070const formatArgs = (
1071 options: Record<
1072 string,
1073 | boolean
1074 | string
1075 | number
1076 | undefined
1077 | Record<string, string>
1078 | Array<string>
1079 >,
1080) => {
1081 const args: Array<string> = [];
1082 for (const [key, value] of Object.entries(options)) {
1083 if (!value) continue;
1084 const prefix = key.length > 1 ? `--${key}` : `-${key}`;
1085 if (value === true) {
1086 args.push(prefix);
1087 } else if (typeof value === "string") {
1088 args.push(prefix, value);
1089 } else if (typeof value === "number") {
1090 args.push(prefix, String(value));
1091 } else if (Array.isArray(value)) {
1092 for (const item of value) {
1093 args.push(prefix, item);
1094 }
1095 } else if (value !== null && typeof value === "object") {
1096 for (const [k, v] of Object.entries(value)) {
1097 args.push(prefix, `${k}=${v}`);
1098 }
1099 }
1100 }
1101 return args;
1102};