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

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 +47 -0
  8. package/lib/BuildHash.d.ts.map +1 -0
  9. package/lib/BuildHash.js +165 -0
  10. package/lib/BuildHash.js.map +1 -0
  11. package/lib/Container.d.ts +352 -0
  12. package/lib/Container.d.ts.map +1 -0
  13. package/lib/Container.js +531 -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 +448 -0
  20. package/lib/Docker.d.ts.map +1 -0
  21. package/lib/Docker.js +416 -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 +151 -0
  32. package/lib/Image.d.ts.map +1 -0
  33. package/lib/Image.js +192 -0
  34. package/lib/Image.js.map +1 -0
  35. package/lib/ImageSource.d.ts +234 -0
  36. package/lib/ImageSource.d.ts.map +1 -0
  37. package/lib/ImageSource.js +469 -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 +478 -0
  60. package/lib/Service.d.ts.map +1 -0
  61. package/lib/Service.js +588 -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 +255 -0
  82. package/src/Container.ts +792 -0
  83. package/src/Context.ts +215 -0
  84. package/src/Docker.ts +1079 -0
  85. package/src/Dockerfile.ts +81 -0
  86. package/src/HealthcheckCommand.ts +35 -0
  87. package/src/Image.ts +293 -0
  88. package/src/ImageSource.ts +769 -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 +1080 -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
@@ -0,0 +1,792 @@
1
+ import { Unowned } from "alchemy/AdoptPolicy";
2
+ import { isResolved } from "alchemy/Diff";
3
+ import * as Provider from "alchemy/Provider";
4
+ import { Resource } from "alchemy/Resource";
5
+ import { createInternalTags, hasAlchemyTags } from "alchemy/Tags";
6
+ import { toSeconds } from "alchemy/Util/Duration";
7
+ import { sha256, sha256Object } from "alchemy/Util/sha256";
8
+ import * as Data from "effect/Data";
9
+ import * as Duration from "effect/Duration";
10
+ import * as Effect from "effect/Effect";
11
+ import * as Equal from "effect/Equal";
12
+ import * as FileSystem from "effect/FileSystem";
13
+ import * as Path from "effect/Path";
14
+ import type { PlatformError } from "effect/PlatformError";
15
+ import * as Redacted from "effect/Redacted";
16
+ import { Docker, dockerContextName, dockerPhysicalName } from "./Docker.ts";
17
+ import { healthcheckCommand, isHealthcheckDisabled } from "./HealthcheckCommand.ts";
18
+ import type { Providers } from "./Providers.ts";
19
+
20
+ const CREATE_CONFIG_HASH_LABEL = "alchemy::container-config";
21
+
22
+ export interface ContainerProps {
23
+ /** Image reference or Docker image resource. */
24
+ image: Container.Image;
25
+ /** Docker context name or context resource. */
26
+ context?: Docker.ContextRef;
27
+ /**
28
+ * Container name.
29
+ *
30
+ * @default Generated from stack, stage, logical id, and instance id.
31
+ */
32
+ name?: string;
33
+ /** Command to run in the container. */
34
+ command?: string[];
35
+ /** Container environment variables. Use Redacted for secrets. */
36
+ environment?: Record<string, string | Redacted.Redacted<string>>;
37
+ /**
38
+ * Paths to Docker env files, forwarded in the declared order as repeated
39
+ * `--env-file` options. Explicit `environment` values are forwarded after
40
+ * these files and take precedence in Docker. Editing a file's contents
41
+ * replaces the container on the next deploy. Only a digest of the contents
42
+ * is kept, on the container's own label; neither contents nor digest are
43
+ * written to Alchemy state. Docker itself exposes the resolved values
44
+ * through `docker inspect`, so treat env files as secrets.
45
+ */
46
+ envFiles?: string[];
47
+ /** Host/container port mappings. */
48
+ ports?: Container.PortMapping[];
49
+ /** Volume or bind mounts. */
50
+ volumes?: Container.VolumeMapping[];
51
+ /** Restart policy. */
52
+ restart?: "no" | "always" | "on-failure" | "unless-stopped";
53
+ /**
54
+ * Container labels. Alchemy's internal ownership labels are added
55
+ * automatically.
56
+ */
57
+ labels?: Record<string, string>;
58
+ /**
59
+ * Grace period before Docker forcefully kills the container after stopping
60
+ * it.
61
+ */
62
+ stopTimeout?: Duration.Input;
63
+ /** Networks to connect after create. */
64
+ networks?: Container.NetworkMapping[];
65
+ /** Network namespace. Use `{ container: id }` to share another container's namespace. */
66
+ networkMode?: Container.NetworkMode;
67
+ /** Linux capabilities to add, for example `SYS_ADMIN`. */
68
+ capAdd?: string[];
69
+ /** Host devices to expose to the container. */
70
+ devices?: Container.DeviceMapping[];
71
+ /**
72
+ * Extra `/etc/hosts` entries, each `hostname:address`. Docker's
73
+ * `host-gateway` alias resolves to the host machine, so
74
+ * `"host.docker.internal:host-gateway"` reaches services listening on the
75
+ * developer's machine from inside the container.
76
+ *
77
+ * On Linux `host-gateway` is the bridge gateway address, so those packets
78
+ * traverse the host's `INPUT` chain — under a default-deny firewall the
79
+ * name resolves and the connection then times out. See the Host Access
80
+ * examples.
81
+ */
82
+ extraHosts?: string[];
83
+ /** Remove the container when it exits. @default false */
84
+ removeOnExit?: boolean;
85
+ /** Start the container after creation/reconciliation. @default false */
86
+ start?: boolean;
87
+ /** Docker healthcheck configuration. */
88
+ healthcheck?: Container.Healthcheck;
89
+ }
90
+
91
+ export declare namespace Container {
92
+ type Status = "created" | "running" | "paused" | "restarting" | "removing" | "exited" | "dead";
93
+ type Image = string | { imageRef: string; imageId?: string };
94
+ interface PortMapping {
95
+ /** External port on the host. */
96
+ external: number | string;
97
+ /** Internal port inside the container. */
98
+ internal: number | string;
99
+ /** Protocol used for the mapping. @default "tcp" */
100
+ protocol?: "tcp" | "udp";
101
+ }
102
+ interface VolumeMapping {
103
+ /** Host path or named volume source. */
104
+ hostPath: string;
105
+ /** Container path. */
106
+ containerPath: string;
107
+ /** Mount read-only. @default false */
108
+ readOnly?: boolean;
109
+ }
110
+ interface NetworkMapping {
111
+ /** Network name or ID. */
112
+ name: string;
113
+ /** Network aliases for the container. */
114
+ aliases?: string[];
115
+ }
116
+ type NetworkMode = string | { container: string };
117
+ interface DeviceMapping {
118
+ /** Host device path. */
119
+ hostPath: string;
120
+ /** Container device path. */
121
+ containerPath: string;
122
+ /** Cgroup permissions. @default "rwm" */
123
+ permissions?: string;
124
+ }
125
+ interface Healthcheck {
126
+ /**
127
+ * Command to run for health checks. A string runs in the container's
128
+ * shell. An array follows Docker's healthcheck `Test` form:
129
+ * `["CMD-SHELL", "pg_isready -U app"]`, `["CMD", "pg_isready", "-U", "app"]`,
130
+ * or `["NONE"]` to disable the image's healthcheck.
131
+ */
132
+ cmd: string[] | string;
133
+ /** Time between checks. */
134
+ interval?: Duration.Input;
135
+ /** Maximum time a check may run. */
136
+ timeout?: Duration.Input;
137
+ /** Consecutive failures before unhealthy. */
138
+ retries?: number;
139
+ /** Startup grace period. */
140
+ startPeriod?: Duration.Input;
141
+ /** Check interval during startup. Requires Docker API 1.44+. */
142
+ startInterval?: Duration.Input;
143
+ }
144
+ }
145
+
146
+ export interface Container extends Resource<
147
+ "Docker.Container",
148
+ ContainerProps,
149
+ {
150
+ /** Docker container id. */
151
+ id: string;
152
+ /** Docker container name. */
153
+ name: string;
154
+ /** Docker container state. */
155
+ status: Container.Status;
156
+ /** Creation timestamp in milliseconds since epoch. */
157
+ createdAt: number;
158
+ /** Image reference used to create the container. */
159
+ imageRef: string;
160
+ /**
161
+ * Map of internal container ports to their bound host ports.
162
+ * Format: `"80/tcp" -> 8080`.
163
+ */
164
+ ports: Record<string, number>;
165
+ /** Configured network namespace, when reported by Docker. */
166
+ networkMode?: string;
167
+ /** Added Linux capabilities, when reported by Docker. */
168
+ capAdd?: string[];
169
+ /** Configured host devices, when reported by Docker. */
170
+ devices?: Container.DeviceMapping[];
171
+ },
172
+ never,
173
+ Providers
174
+ > {}
175
+
176
+ /**
177
+ * A Docker container managed through the active Docker context.
178
+ *
179
+ * This resource creates, starts, stops, inspects, and removes containers through
180
+ * the Docker CLI. It is not interchangeable with `Cloudflare.Container`, which
181
+ * manages Cloudflare's container platform; use pushed image references to bridge
182
+ * Docker-built images into cloud container runtimes.
183
+ *
184
+ *
185
+ * ### Running Containers
186
+ * **Example:** Nginx with a published port
187
+ * ```typescript
188
+ * const nginx = yield* Docker.Container("nginx", {
189
+ * image: "nginx:alpine",
190
+ * ports: [{ external: 8080, internal: 80 }],
191
+ * start: true,
192
+ * });
193
+ * ```
194
+ *
195
+ * ### Secret Environment
196
+ * **Example:** Redacted env var
197
+ * ```typescript
198
+ * const password = yield* Config.Redacted("POSTGRES_PASSWORD");
199
+ * const db = yield* Docker.Container("postgres", {
200
+ * image: "postgres:18-alpine",
201
+ * environment: {
202
+ * POSTGRES_PASSWORD: password,
203
+ * },
204
+ * start: true,
205
+ * });
206
+ * ```
207
+ *
208
+ * ### Environment Files
209
+ * **Example:** Layered Docker env files
210
+ * ```typescript
211
+ * const app = yield* Docker.Container("app", {
212
+ * image: "ghcr.io/acme/app:latest",
213
+ * envFiles: ["./config/base.env", "./config/production.env"],
214
+ * // Explicit values are passed after env files and take precedence.
215
+ * environment: { LOG_LEVEL: "info" },
216
+ * });
217
+ * ```
218
+ *
219
+ * Editing an env file replaces the container on the next deploy, as does
220
+ * adopting a container that uses env files (once, so later edits are tracked).
221
+ * Alchemy reads
222
+ * the files at plan time but keeps only a digest of their contents, on the
223
+ * container's own label; neither the values nor the digest are written to
224
+ * Alchemy state. Docker exposes the resolved values through `docker inspect`,
225
+ * so treat env files as secrets.
226
+ *
227
+ * ### Networks and Volumes
228
+ * **Example:** PostgreSQL with persistent storage
229
+ * ```typescript
230
+ * const network = yield* Docker.Network("app-network");
231
+ * const data = yield* Docker.Volume("postgres-data");
232
+ * const postgresName = "app-postgres";
233
+ * yield* Docker.Container("postgres", {
234
+ * name: postgresName,
235
+ * image: "postgres:18-alpine",
236
+ * ports: [{ external: 15432, internal: 5432 }],
237
+ * volumes: [{ hostPath: data.name, containerPath: "/var/lib/postgresql/data" }],
238
+ * networks: [{ name: network.name, aliases: ["postgres"] }],
239
+ * start: true,
240
+ * });
241
+ * const runtime = yield* Docker.inspectContainer(postgresName);
242
+ * ```
243
+ *
244
+ * ### Host Access
245
+ * `extraHosts` writes lines into the container's `/etc/hosts`; it changes name
246
+ * resolution and nothing else. Docker's `host-gateway` alias resolves to the
247
+ * host machine, which is how a container reaches a service on the developer's
248
+ * loopback.
249
+ *
250
+ * On Linux `host-gateway` is the Docker bridge gateway (typically
251
+ * `172.17.0.1`), so a container's packets to it arrive on the host's `INPUT`
252
+ * chain. Under a default-deny firewall — ufw ships
253
+ * `DEFAULT_INPUT_POLICY="DROP"` — the hostname resolves correctly and the
254
+ * connection then times out, which reads like an application bug rather than a
255
+ * firewall one. Allow the bridge subnet to fix it:
256
+ * `sudo ufw allow from 172.16.0.0/12`.
257
+ *
258
+ * **Example:** Reach a service on the developer's machine
259
+ * ```typescript
260
+ * const api = yield* Docker.Container("api", {
261
+ * image: "ghcr.io/acme/api:latest",
262
+ * // `host-gateway` resolves to the host machine, so a database listening
263
+ * // on the developer's loopback is reachable from inside the container.
264
+ * extraHosts: ["host.docker.internal:host-gateway"],
265
+ * environment: {
266
+ * DATABASE_URL: "postgres://postgres@host.docker.internal:5432/app",
267
+ * },
268
+ * start: true,
269
+ * });
270
+ * ```
271
+ *
272
+ * **Example:** Pin a hostname to a fixed address
273
+ * ```typescript
274
+ * const api = yield* Docker.Container("api", {
275
+ * image: "ghcr.io/acme/api:latest",
276
+ * // Any `hostname:address` pair — host access is just the common case.
277
+ * extraHosts: ["service.example:192.0.2.10"],
278
+ * start: true,
279
+ * });
280
+ * ```
281
+ *
282
+ * ### Runtime Options
283
+ * **Example:** Share a donor container's network namespace
284
+ * ```typescript
285
+ * const donor = yield* Docker.Container("donor", { image: "redis:alpine" });
286
+ * const sidecar = yield* Docker.Container("sidecar", {
287
+ * image: "busybox:latest",
288
+ * networkMode: { container: donor.id },
289
+ * });
290
+ * ```
291
+ *
292
+ * **Example:** Add capabilities and devices
293
+ * ```typescript
294
+ * const worker = yield* Docker.Container("worker", {
295
+ * image: "ubuntu:latest",
296
+ * capAdd: ["SYS_ADMIN"],
297
+ * devices: [{ hostPath: "/dev/fuse", containerPath: "/dev/fuse" }],
298
+ * });
299
+ * ```
300
+ *
301
+ * **Example:** Publish on any free host port
302
+ * ```typescript
303
+ * const api = yield* Docker.Container("api", {
304
+ * image: "ghcr.io/acme/api:latest",
305
+ * // `external: 0` lets Docker choose; the assigned port is reported back.
306
+ * ports: [{ external: 0, internal: 3000 }],
307
+ * start: true,
308
+ * });
309
+ * const hostPort = api.ports["3000/tcp"];
310
+ * ```
311
+ *
312
+ * ### Traefik
313
+ * **Example:** Route a container through Traefik
314
+ * ```typescript
315
+ * const api = yield* Docker.Container("api", {
316
+ * image: "ghcr.io/acme/api:latest",
317
+ * networks: [{ name: "traefik" }],
318
+ * labels: {
319
+ * "traefik.enable": "true",
320
+ * "traefik.http.routers.api.rule": "Host(`api.example.com`)",
321
+ * "traefik.http.services.api.loadbalancer.server.port": "3000",
322
+ * },
323
+ * stopTimeout: "30 seconds",
324
+ * start: true,
325
+ * });
326
+ * ```
327
+ *
328
+ * **Example:** Use a Docker.Context resource
329
+ * ```typescript
330
+ * const remote = yield* Docker.Context("remote", {
331
+ * name: "remote-build",
332
+ * docker: "host=ssh://docker@example.com",
333
+ * });
334
+ *
335
+ * const api = yield* Docker.Container("api", {
336
+ * image: "nginx:alpine",
337
+ * context: remote,
338
+ * });
339
+ * ```
340
+ *
341
+ * @resource
342
+ * @product Container
343
+ */
344
+ export const Container = Resource<Container>("Docker.Container");
345
+
346
+ /**
347
+ * Inspect a Docker container by name and return normalized runtime details.
348
+ *
349
+ * This is a small public wrapper around Docker's raw inspect output. It returns
350
+ * the stable data Alchemy callers typically need, including bound host ports.
351
+ */
352
+ export const inspectContainer = (
353
+ name: string,
354
+ context?: Docker.ContextRef,
355
+ ): Effect.Effect<Container["Attributes"], PlatformError, Docker> =>
356
+ Docker.pipe(
357
+ Effect.flatMap((docker) => docker.container.inspect(name, dockerContextName(context))),
358
+ Effect.map((container) => toContainerAttributes(container, container.Config.Image)),
359
+ );
360
+
361
+ export const ContainerProvider = () =>
362
+ Provider.effect(
363
+ Container,
364
+ Effect.gen(function* () {
365
+ const docker = yield* Docker;
366
+ const fs = yield* FileSystem.FileSystem;
367
+ const path = yield* Path.Path;
368
+
369
+ // Env files are read so editing one rolls the container, but only a
370
+ // digest of their bytes enters the config hash, which lives solely in
371
+ // the container's own label (where `docker inspect` already exposes
372
+ // the resolved values). Neither contents nor digest reach alchemy state.
373
+ const envFilesDigest = Effect.fn(function* (envFiles: ReadonlyArray<string> | undefined) {
374
+ if (!envFiles?.length) return undefined;
375
+ const digests = yield* Effect.forEach(envFiles, (file) =>
376
+ fs.readFile(path.resolve(file)).pipe(
377
+ Effect.flatMap(sha256),
378
+ Effect.map((digest) => `${file}\0${digest}`),
379
+ ),
380
+ );
381
+ return yield* sha256(digests.join("\n"));
382
+ });
383
+
384
+ // Without env files the hash input is unchanged from before they were
385
+ // supported, so existing containers keep their label and are not rolled.
386
+ const desiredConfigHash = Effect.fn(function* (
387
+ args: Parameters<Docker["Service"]["container"]["create"]>[0],
388
+ news: ContainerProps,
389
+ ) {
390
+ const envFiles = yield* envFilesDigest(news.envFiles);
391
+ return yield* sha256Object({
392
+ ...args,
393
+ imageId: normalizeImageId(news.image),
394
+ ...(envFiles === undefined ? {} : { envFiles }),
395
+ });
396
+ });
397
+
398
+ const reconcileNetworks = Effect.fn(function* (
399
+ live: Docker.Container,
400
+ news: ContainerProps,
401
+ olds: ContainerProps | undefined,
402
+ ) {
403
+ const context = dockerContextName(news.context);
404
+ const connect = new Map<string, Container.NetworkMapping>();
405
+ const disconnect = new Set<string>();
406
+ for (const network of news.networks ?? []) {
407
+ const entry = live.NetworkSettings.Networks?.[network.name];
408
+ if (!entry) {
409
+ connect.set(network.name, network);
410
+ } else if (!Equal.equals(entry.Aliases ?? [], network.aliases ?? [])) {
411
+ connect.set(network.name, network);
412
+ disconnect.add(network.name);
413
+ }
414
+ }
415
+ // Only networks alchemy itself attached are alchemy's to detach, and
416
+ // `olds.networks` is the sole record of which those are — the live
417
+ // container cannot say who connected a network. Sweeping every live
418
+ // network instead tore off the default `bridge` and anything a user,
419
+ // compose file, or another tool had attached out of band (#1386).
420
+ const desired = new Set((news.networks ?? []).map((n) => n.name));
421
+ for (const network of olds?.networks ?? []) {
422
+ if (!desired.has(network.name) && live.NetworkSettings.Networks?.[network.name]) {
423
+ disconnect.add(network.name);
424
+ }
425
+ }
426
+ yield* Effect.forEach(
427
+ disconnect,
428
+ (network) => docker.network.disconnect({ network, container: live.Id, context }),
429
+ { concurrency: "unbounded" },
430
+ );
431
+ yield* Effect.forEach(
432
+ connect.values(),
433
+ (network) =>
434
+ docker.network.connect({
435
+ network: network.name,
436
+ container: live.Id,
437
+ alias: network.aliases,
438
+ context,
439
+ }),
440
+ { concurrency: "unbounded" },
441
+ );
442
+ });
443
+
444
+ const inspect = (name: string, context?: string) =>
445
+ docker.container
446
+ .inspect(name, context)
447
+ .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined));
448
+
449
+ const remove = (name: string, context?: string) =>
450
+ docker.container.stop(name, context).pipe(
451
+ Effect.andThen(docker.container.remove(name, true, context)),
452
+ Effect.catchReason("PlatformError", "NotFound", () => Effect.void),
453
+ );
454
+
455
+ return Container.Provider.of({
456
+ list: () => Effect.succeed([]),
457
+ read: Effect.fn(function* ({ id, instanceId, olds, output }) {
458
+ const context = dockerContextName(olds.context);
459
+ const name = yield* dockerPhysicalName(id, olds, instanceId);
460
+ const info = yield* inspect(name, context);
461
+ if (!info) return undefined;
462
+ // `olds.image` may be `undefined` when a `creating` row was
463
+ // persisted before upstream Outputs resolved — fall back to the
464
+ // live container's actual image.
465
+ const attrs = toContainerAttributes(
466
+ info,
467
+ olds.image !== undefined ? normalizeImageRef(olds.image) : info.Config.Image,
468
+ );
469
+ if (output) return attrs;
470
+ // Without prior state, only adopt a container that carries our
471
+ // branding; anything else is foreign and gated behind `--adopt`.
472
+ const owned = yield* hasAlchemyTags(id, info.Config.Labels ?? undefined);
473
+ return owned ? attrs : Unowned(attrs);
474
+ }),
475
+ diff: Effect.fn(function* ({ id, instanceId, news, olds }) {
476
+ if (!isResolved(news)) return undefined;
477
+ // An Output-valued `image` doesn't survive a `creating`-state
478
+ // round-trip (it deserializes as `undefined`) — without comparable
479
+ // prior create args, let the engine apply its default update logic.
480
+ if (olds.image === undefined) return undefined;
481
+ if (dockerContextName(olds.context) !== dockerContextName(news.context)) {
482
+ return { action: "replace" as const, deleteFirst: true };
483
+ }
484
+ const oldArgs = yield* makeCreateArgs(id, olds, instanceId);
485
+ const newArgs = yield* makeCreateArgs(id, news, instanceId);
486
+ if (!Equal.equals(oldArgs, newArgs)) {
487
+ return { action: "replace" as const, deleteFirst: true };
488
+ }
489
+ if (
490
+ !Equal.equals(olds.networks ?? [], news.networks ?? []) ||
491
+ (olds.start ?? false) !== (news.start ?? false)
492
+ ) {
493
+ return { action: "update" as const };
494
+ }
495
+ // Same env file paths, possibly new contents: compare against the
496
+ // hash stamped on the running container.
497
+ if (news.envFiles?.length) {
498
+ const live = yield* inspect(newArgs.name, dockerContextName(news.context));
499
+ const applied = live?.Config.Labels?.[CREATE_CONFIG_HASH_LABEL];
500
+ if (applied !== undefined && applied !== (yield* desiredConfigHash(newArgs, news))) {
501
+ return { action: "update" as const };
502
+ }
503
+ }
504
+ }),
505
+ reconcile: Effect.fn(function* ({ id, instanceId, news, olds, output }) {
506
+ const context = dockerContextName(news.context);
507
+ const args = yield* makeCreateArgs(id, news, instanceId);
508
+ const configHash = yield* desiredConfigHash(args, news);
509
+ // Adoption has output but no olds. In that case the observed
510
+ // container already lives in the desired context.
511
+ const oldContext = olds ? dockerContextName(olds.context) : context;
512
+ const desiredLive = yield* inspect(args.name, context);
513
+ const previous =
514
+ output && (output.name !== args.name || oldContext !== context)
515
+ ? yield* inspect(output.name, oldContext)
516
+ : undefined;
517
+ if (previous) {
518
+ yield* remove(previous.Id, oldContext);
519
+ }
520
+ const live = desiredLive?.Id === previous?.Id ? undefined : desiredLive;
521
+
522
+ const oldArgs =
523
+ live?.Config.Labels?.[CREATE_CONFIG_HASH_LABEL] === undefined && olds !== undefined
524
+ ? yield* makeCreateArgs(
525
+ id,
526
+ {
527
+ ...olds,
528
+ image: olds.image ?? output?.imageRef ?? news.image,
529
+ },
530
+ instanceId,
531
+ )
532
+ : undefined;
533
+ const recreate =
534
+ live !== undefined &&
535
+ (live.Config.Labels?.[CREATE_CONFIG_HASH_LABEL] === undefined
536
+ ? (oldArgs !== undefined && !Equal.equals(oldArgs, args)) ||
537
+ normalizeImageId(olds?.image) !== normalizeImageId(news.image) ||
538
+ !matchesLegacyConfig(live, args, news.image) ||
539
+ // Env file contents can't be compared without a label (e.g. an
540
+ // adopted container); recreate once so later edits are tracked.
541
+ (news.envFiles?.length ?? 0) > 0
542
+ : live.Config.Labels[CREATE_CONFIG_HASH_LABEL] !== configHash);
543
+ if (recreate) {
544
+ yield* remove(live.Id, context);
545
+ }
546
+
547
+ const current = recreate ? undefined : live;
548
+ if (!current) {
549
+ const internalTags = yield* createInternalTags(id);
550
+ const { stdout: containerId } = yield* docker.container.create({
551
+ ...args,
552
+ context,
553
+ label: {
554
+ ...args.label,
555
+ ...internalTags,
556
+ [CREATE_CONFIG_HASH_LABEL]: configHash,
557
+ },
558
+ });
559
+ yield* Effect.forEach(
560
+ news.networks ?? [],
561
+ (network) =>
562
+ docker.network.connect({
563
+ network: network.name,
564
+ container: containerId,
565
+ alias: network.aliases,
566
+ context,
567
+ }),
568
+ { concurrency: "unbounded" },
569
+ );
570
+ if (news.start) {
571
+ yield* docker.container.start(containerId, context);
572
+ }
573
+ const info = yield* docker.container.inspect(containerId, context);
574
+ return toContainerAttributes(info, args.image);
575
+ }
576
+
577
+ yield* reconcileNetworks(current, news, olds);
578
+ if (news.start && current.State.Status !== "running") {
579
+ yield* docker.container.start(current.Id, context);
580
+ } else if (!news.start && current.State.Status === "running") {
581
+ yield* docker.container.stop(current.Id, context);
582
+ }
583
+ return yield* docker.container
584
+ .inspect(current.Id, context)
585
+ .pipe(Effect.map((info) => toContainerAttributes(info, args.image)));
586
+ }),
587
+ delete: Effect.fn(({ olds, output }) =>
588
+ remove(output.name, dockerContextName(olds.context)),
589
+ ),
590
+ });
591
+ }),
592
+ );
593
+
594
+ const normalizeImageRef = (image: Container.Image): string =>
595
+ typeof image === "string" ? image : image.imageRef;
596
+
597
+ const normalizeImageId = (image: Container.Image | undefined) =>
598
+ typeof image === "string" ? undefined : image?.imageId;
599
+
600
+ type CreateArgs = Parameters<Docker["Service"]["container"]["create"]>[0];
601
+
602
+ // Containers created before config hashes were introduced are compared using
603
+ // the fields Docker reports without image-default normalization. A subsequent
604
+ // create stamps the complete resolved configuration hash.
605
+ const matchesLegacyConfig = (live: Docker.Container, desired: CreateArgs, image: Container.Image) =>
606
+ (normalizeImageId(image)
607
+ ? live.Image === normalizeImageId(image)
608
+ : live.Config.Image === desired.image) &&
609
+ (desired.command === undefined || Equal.equals(live.Config.Cmd, desired.command)) &&
610
+ Object.entries(desired.env ?? {}).every(([key, value]) =>
611
+ live.Config.Env?.includes(`${key}=${value}`),
612
+ );
613
+
614
+ const makeCreateArgs = (id: string, news: ContainerProps, instanceId: string) =>
615
+ dockerPhysicalName(id, news, instanceId).pipe(
616
+ Effect.tap(() => validateContainerOptions(news)),
617
+ Effect.map((name): Parameters<Docker["Service"]["container"]["create"]>[0] => ({
618
+ name,
619
+ image: normalizeImageRef(news.image),
620
+ command: news.command,
621
+ env: normalizeEnvironment(news.environment),
622
+ "env-file": news.envFiles?.length ? news.envFiles : undefined,
623
+ volume: news.volumes?.map(
624
+ (v) => `${v.hostPath}:${v.containerPath}${v.readOnly ? ":ro" : ""}`,
625
+ ),
626
+ p: news.ports?.map((port) => {
627
+ const target = `${port.internal}/${port.protocol ?? "tcp"}`;
628
+ // `external: 0` means "any free host port". Docker spells that as a
629
+ // bare container port (`-p 80/tcp`); `-p 0:80/tcp` instead asks for
630
+ // host port 0 literally, which the daemon accepts and then reports
631
+ // back as 0.
632
+ return isRandomHostPort(port.external) ? target : `${port.external}:${target}`;
633
+ }),
634
+ "add-host": news.extraHosts,
635
+ network: normalizeNetworkMode(news.networkMode),
636
+ "cap-add": normalizeCapabilities(news.capAdd),
637
+ device: normalizeDevices(news.devices),
638
+ restart: news.restart ?? "no",
639
+ label: news.labels,
640
+ "stop-timeout": toSeconds(news.stopTimeout)?.toString(),
641
+ rm: news.removeOnExit ?? false,
642
+ ...(news.healthcheck
643
+ ? {
644
+ "health-cmd": healthcheckCommand(news.healthcheck.cmd),
645
+ // Only when set: an always-present key would change every
646
+ // container's config hash and recreate it on upgrade.
647
+ ...(isHealthcheckDisabled(news.healthcheck.cmd) ? { "no-healthcheck": true } : {}),
648
+ "health-interval": normalizeDuration(news.healthcheck.interval),
649
+ "health-timeout": normalizeDuration(news.healthcheck.timeout),
650
+ "health-retries": news.healthcheck.retries ?? 0,
651
+ "health-start-period": normalizeDuration(news.healthcheck.startPeriod),
652
+ "health-start-interval": normalizeDuration(news.healthcheck.startInterval),
653
+ }
654
+ : {
655
+ "health-cmd": undefined,
656
+ "health-interval": undefined,
657
+ "health-timeout": undefined,
658
+ "health-retries": undefined,
659
+ "health-start-period": undefined,
660
+ "health-start-interval": undefined,
661
+ }),
662
+ })),
663
+ );
664
+
665
+ const toContainerAttributes = (
666
+ info: Docker.Container,
667
+ imageRef: string,
668
+ ): Container["Attributes"] => ({
669
+ id: info.Id,
670
+ name: typeof info.Name === "string" ? info.Name.replace(/^\//, "") : info.Id,
671
+ status: info.State.Status,
672
+ createdAt: Date.parse(info.Created) || Date.now(),
673
+ imageRef,
674
+ ports: toPortAttributes(info),
675
+ networkMode: info.HostConfig.NetworkMode,
676
+ capAdd: info.HostConfig.CapAdd ?? undefined,
677
+ devices: info.HostConfig.Devices?.map((device) => ({
678
+ hostPath: device.PathOnHost,
679
+ containerPath: device.PathInContainer,
680
+ permissions: device.CgroupPermissions,
681
+ })),
682
+ });
683
+
684
+ const normalizeNetworkMode = (mode: Container.NetworkMode | undefined): string | undefined =>
685
+ mode === undefined ? undefined : typeof mode === "string" ? mode : `container:${mode.container}`;
686
+
687
+ const normalizeCapabilities = (capAdd: string[] | undefined): string[] | undefined => {
688
+ if (!capAdd?.length) return undefined;
689
+ return [...new Set(capAdd.map((capability) => capability.trim()).filter(Boolean))].sort();
690
+ };
691
+
692
+ const normalizeDevices = (devices: Container.DeviceMapping[] | undefined): string[] | undefined => {
693
+ if (!devices?.length) return undefined;
694
+ const normalized = devices.map(
695
+ (device) => `${device.hostPath}:${device.containerPath}:${device.permissions ?? "rwm"}`,
696
+ );
697
+ return [...new Set(normalized)].sort();
698
+ };
699
+
700
+ /**
701
+ * Raised before Docker is called when a container's options cannot be
702
+ * combined, e.g. sharing another container's network namespace while
703
+ * publishing ports.
704
+ */
705
+ export class InvalidContainerOptions extends Data.TaggedError("InvalidContainerOptions")<{
706
+ readonly message: string;
707
+ }> {}
708
+
709
+ const validateContainerOptions = (news: ContainerProps) => {
710
+ if (
711
+ isContainerNetworkMode(news.networkMode) &&
712
+ ((news.ports?.length ?? 0) > 0 || (news.networks?.length ?? 0) > 0)
713
+ ) {
714
+ return Effect.fail(
715
+ new InvalidContainerOptions({
716
+ message: "Docker.Container networkMode.container cannot be combined with ports or networks",
717
+ }),
718
+ );
719
+ }
720
+ const targets = new Set<string>();
721
+ for (const device of news.devices ?? []) {
722
+ if (targets.has(device.containerPath)) {
723
+ return Effect.fail(
724
+ new InvalidContainerOptions({
725
+ message: `Docker.Container devices contain conflicting target path ${device.containerPath}`,
726
+ }),
727
+ );
728
+ }
729
+ targets.add(device.containerPath);
730
+ }
731
+ return Effect.void;
732
+ };
733
+
734
+ const isContainerNetworkMode = (mode: Container.NetworkMode | undefined): boolean =>
735
+ typeof mode === "string" ? mode.startsWith("container:") : mode !== undefined;
736
+
737
+ /** First binding that carries a real (non-zero) host port. */
738
+ const boundHostPort = (
739
+ bindings: ReadonlyArray<{ HostPort?: string }> | null | undefined,
740
+ ): number | undefined => {
741
+ for (const binding of bindings ?? []) {
742
+ if (!binding.HostPort) continue;
743
+ const port = Number.parseInt(binding.HostPort, 10);
744
+ if (Number.isInteger(port) && port > 0) return port;
745
+ }
746
+ return undefined;
747
+ };
748
+
749
+ /**
750
+ * `HostConfig.PortBindings` is what was *requested*, `NetworkSettings.Ports`
751
+ * what Docker actually *assigned* — so the assignment wins wherever both
752
+ * exist. A container published with `external: 0` (or any random-publish
753
+ * mapping) has no requested host port at all, and reading the request over
754
+ * the assignment reported 0 instead of the port the container is reachable
755
+ * on. The request is still the fallback: a created-but-not-yet-started
756
+ * container has empty `NetworkSettings.Ports`.
757
+ */
758
+ const toPortAttributes = (info: Docker.Container): Record<string, number> => {
759
+ const ports: Record<string, number> = {};
760
+ for (const [internal, bindings] of Object.entries(info.HostConfig.PortBindings ?? {})) {
761
+ const port = boundHostPort(bindings);
762
+ if (port !== undefined) ports[internal] = port;
763
+ }
764
+ for (const [internal, bindings] of Object.entries(info.NetworkSettings.Ports ?? {})) {
765
+ const port = boundHostPort(bindings);
766
+ if (port !== undefined) ports[internal] = port;
767
+ }
768
+ return ports;
769
+ };
770
+
771
+ /** `external: 0` / `"0"` asks Docker to pick any free host port. */
772
+ const isRandomHostPort = (external: number | string): boolean =>
773
+ Number.parseInt(String(external), 10) === 0;
774
+
775
+ const normalizeEnvironment = (
776
+ environment: Record<string, string | Redacted.Redacted<string>> | undefined,
777
+ ): Record<string, string> =>
778
+ Object.fromEntries(
779
+ Object.entries(environment ?? {}).map(([key, value]) => [
780
+ key,
781
+ Redacted.isRedacted(value) ? Redacted.value(value) : value,
782
+ ]),
783
+ );
784
+
785
+ const normalizeDuration = (input: Duration.Input | undefined): string | undefined => {
786
+ if (!input) return undefined;
787
+ const duration = Duration.fromInputUnsafe(input);
788
+ // Docker parses `--health-*` durations with Go's `time.ParseDuration`, which
789
+ // requires a unit suffix — a bare nanosecond count is rejected with "missing
790
+ // unit in duration". `ns` is the lossless Go-duration rendering of the nanos.
791
+ return `${Duration.toNanosUnsafe(duration).toString()}ns`;
792
+ };