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