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