| 1 | import * as Duration from "effect/Duration"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Equal from "effect/Equal"; |
| 4 | import type { PlatformError } from "effect/PlatformError"; |
| 5 | import * as Redacted from "effect/Redacted"; |
| 6 | import { Unowned } from "../AdoptPolicy.ts"; |
| 7 | import { isResolved } from "../Diff.ts"; |
| 8 | import * as Provider from "../Provider.ts"; |
| 9 | import { Resource } from "../Resource.ts"; |
| 10 | import { createInternalTags, hasAlchemyTags } from "../Tags.ts"; |
| 11 | import { toSeconds } from "../Util/Duration.ts"; |
| 12 | import { Docker, dockerContextName, dockerPhysicalName } from "./Docker.ts"; |
| 13 | import type { Providers } from "./Providers.ts"; |
| 14 | |
| 15 | export interface ContainerProps { |
| 16 | /** Image reference or Docker image resource. */ |
| 17 | image: Container.Image; |
| 18 | /** Docker context name or context resource. */ |
| 19 | context?: Docker.ContextRef; |
| 20 | /** |
| 21 | * Container name. |
| 22 | * |
| 23 | * @default Generated from stack, stage, logical id, and instance id. |
| 24 | */ |
| 25 | name?: string; |
| 26 | /** Command to run in the container. */ |
| 27 | command?: string[]; |
| 28 | /** Container environment variables. Use Redacted for secrets. */ |
| 29 | environment?: Record<string, string | Redacted.Redacted<string>>; |
| 30 | /** Host/container port mappings. */ |
| 31 | ports?: Container.PortMapping[]; |
| 32 | /** Volume or bind mounts. */ |
| 33 | volumes?: Container.VolumeMapping[]; |
| 34 | /** Restart policy. */ |
| 35 | restart?: "no" | "always" | "on-failure" | "unless-stopped"; |
| 36 | /** |
| 37 | * Container labels. Alchemy's internal ownership labels are added |
| 38 | * automatically. |
| 39 | */ |
| 40 | labels?: Record<string, string>; |
| 41 | /** |
| 42 | * Grace period before Docker forcefully kills the container after stopping |
| 43 | * it. |
| 44 | */ |
| 45 | stopTimeout?: Duration.Input; |
| 46 | /** Networks to connect after create. */ |
| 47 | networks?: Container.NetworkMapping[]; |
| 48 | /** |
| 49 | * Extra `/etc/hosts` entries, each `hostname:address`. Docker's |
| 50 | * `host-gateway` alias resolves to the host machine, so |
| 51 | * `"host.docker.internal:host-gateway"` reaches services listening on the |
| 52 | * developer's machine from inside the container. |
| 53 | * |
| 54 | * On Linux `host-gateway` is the bridge gateway address, so those packets |
| 55 | * traverse the host's `INPUT` chain — under a default-deny firewall the |
| 56 | * name resolves and the connection then times out. See the Host Access |
| 57 | * examples. |
| 58 | */ |
| 59 | extraHosts?: string[]; |
| 60 | /** Remove the container when it exits. @default false */ |
| 61 | removeOnExit?: boolean; |
| 62 | /** Start the container after creation/reconciliation. @default false */ |
| 63 | start?: boolean; |
| 64 | /** Docker healthcheck configuration. */ |
| 65 | healthcheck?: Container.Healthcheck; |
| 66 | } |
| 67 | |
| 68 | export declare namespace Container { |
| 69 | type Status = |
| 70 | | "created" |
| 71 | | "running" |
| 72 | | "paused" |
| 73 | | "restarting" |
| 74 | | "removing" |
| 75 | | "exited" |
| 76 | | "dead"; |
| 77 | type Image = string | { imageRef: string }; |
| 78 | interface PortMapping { |
| 79 | /** External port on the host. */ |
| 80 | external: number | string; |
| 81 | /** Internal port inside the container. */ |
| 82 | internal: number | string; |
| 83 | /** Protocol used for the mapping. @default "tcp" */ |
| 84 | protocol?: "tcp" | "udp"; |
| 85 | } |
| 86 | interface VolumeMapping { |
| 87 | /** Host path or named volume source. */ |
| 88 | hostPath: string; |
| 89 | /** Container path. */ |
| 90 | containerPath: string; |
| 91 | /** Mount read-only. @default false */ |
| 92 | readOnly?: boolean; |
| 93 | } |
| 94 | interface NetworkMapping { |
| 95 | /** Network name or ID. */ |
| 96 | name: string; |
| 97 | /** Network aliases for the container. */ |
| 98 | aliases?: string[]; |
| 99 | } |
| 100 | interface Healthcheck { |
| 101 | /** Command to run for health checks. */ |
| 102 | cmd: string[] | string; |
| 103 | /** Time between checks. */ |
| 104 | interval?: Duration.Input; |
| 105 | /** Maximum time a check may run. */ |
| 106 | timeout?: Duration.Input; |
| 107 | /** Consecutive failures before unhealthy. */ |
| 108 | retries?: number; |
| 109 | /** Startup grace period. */ |
| 110 | startPeriod?: Duration.Input; |
| 111 | /** Check interval during startup. Requires Docker API 1.44+. */ |
| 112 | startInterval?: Duration.Input; |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | export interface Container extends Resource< |
| 117 | "Docker.Container", |
| 118 | ContainerProps, |
| 119 | { |
| 120 | /** Docker container id. */ |
| 121 | id: string; |
| 122 | /** Docker container name. */ |
| 123 | name: string; |
| 124 | /** Docker container state. */ |
| 125 | status: Container.Status; |
| 126 | /** Creation timestamp in milliseconds since epoch. */ |
| 127 | createdAt: number; |
| 128 | /** Image reference used to create the container. */ |
| 129 | imageRef: string; |
| 130 | /** |
| 131 | * Map of internal container ports to their bound host ports. |
| 132 | * Format: `"80/tcp" -> 8080`. |
| 133 | */ |
| 134 | ports: Record<string, number>; |
| 135 | }, |
| 136 | never, |
| 137 | Providers |
| 138 | > {} |
| 139 | |
| 140 | /** |
| 141 | * A Docker container managed through the active Docker context. |
| 142 | * |
| 143 | * This resource creates, starts, stops, inspects, and removes containers through |
| 144 | * the Docker CLI. It is not interchangeable with `Cloudflare.Container`, which |
| 145 | * manages Cloudflare's container platform; use pushed image references to bridge |
| 146 | * Docker-built images into cloud container runtimes. |
| 147 | * |
| 148 | * |
| 149 | * ### Running Containers |
| 150 | * **Example:** Nginx with a published port |
| 151 | * ```typescript |
| 152 | * const nginx = yield* Docker.Container("nginx", { |
| 153 | * image: "nginx:alpine", |
| 154 | * ports: [{ external: 8080, internal: 80 }], |
| 155 | * start: true, |
| 156 | * }); |
| 157 | * ``` |
| 158 | * |
| 159 | * ### Secret Environment |
| 160 | * **Example:** Redacted env var |
| 161 | * ```typescript |
| 162 | * const password = yield* Config.Redacted("POSTGRES_PASSWORD"); |
| 163 | * const db = yield* Docker.Container("postgres", { |
| 164 | * image: "postgres:18-alpine", |
| 165 | * environment: { |
| 166 | * POSTGRES_PASSWORD: password, |
| 167 | * }, |
| 168 | * start: true, |
| 169 | * }); |
| 170 | * ``` |
| 171 | * |
| 172 | * ### Networks and Volumes |
| 173 | * **Example:** PostgreSQL with persistent storage |
| 174 | * ```typescript |
| 175 | * const network = yield* Docker.Network("app-network"); |
| 176 | * const data = yield* Docker.Volume("postgres-data"); |
| 177 | * const postgresName = "app-postgres"; |
| 178 | * yield* Docker.Container("postgres", { |
| 179 | * name: postgresName, |
| 180 | * image: "postgres:18-alpine", |
| 181 | * ports: [{ external: 15432, internal: 5432 }], |
| 182 | * volumes: [{ hostPath: data.name, containerPath: "/var/lib/postgresql/data" }], |
| 183 | * networks: [{ name: network.name, aliases: ["postgres"] }], |
| 184 | * start: true, |
| 185 | * }); |
| 186 | * const runtime = yield* Docker.inspectContainer(postgresName); |
| 187 | * ``` |
| 188 | * |
| 189 | * ### Host Access |
| 190 | * `extraHosts` writes lines into the container's `/etc/hosts`; it changes name |
| 191 | * resolution and nothing else. Docker's `host-gateway` alias resolves to the |
| 192 | * host machine, which is how a container reaches a service on the developer's |
| 193 | * loopback. |
| 194 | * |
| 195 | * On Linux `host-gateway` is the Docker bridge gateway (typically |
| 196 | * `172.17.0.1`), so a container's packets to it arrive on the host's `INPUT` |
| 197 | * chain. Under a default-deny firewall — ufw ships |
| 198 | * `DEFAULT_INPUT_POLICY="DROP"` — the hostname resolves correctly and the |
| 199 | * connection then times out, which reads like an application bug rather than a |
| 200 | * firewall one. Allow the bridge subnet to fix it: |
| 201 | * `sudo ufw allow from 172.16.0.0/12`. |
| 202 | * |
| 203 | * **Example:** Reach a service on the developer's machine |
| 204 | * ```typescript |
| 205 | * const api = yield* Docker.Container("api", { |
| 206 | * image: "ghcr.io/acme/api:latest", |
| 207 | * // `host-gateway` resolves to the host machine, so a database listening |
| 208 | * // on the developer's loopback is reachable from inside the container. |
| 209 | * extraHosts: ["host.docker.internal:host-gateway"], |
| 210 | * environment: { |
| 211 | * DATABASE_URL: "postgres://postgres@host.docker.internal:5432/app", |
| 212 | * }, |
| 213 | * start: true, |
| 214 | * }); |
| 215 | * ``` |
| 216 | * |
| 217 | * **Example:** Pin a hostname to a fixed address |
| 218 | * ```typescript |
| 219 | * const api = yield* Docker.Container("api", { |
| 220 | * image: "ghcr.io/acme/api:latest", |
| 221 | * // Any `hostname:address` pair — host access is just the common case. |
| 222 | * extraHosts: ["payments.internal:10.1.2.3"], |
| 223 | * start: true, |
| 224 | * }); |
| 225 | * ``` |
| 226 | * |
| 227 | * **Example:** Publish on any free host port |
| 228 | * ```typescript |
| 229 | * const api = yield* Docker.Container("api", { |
| 230 | * image: "ghcr.io/acme/api:latest", |
| 231 | * // `external: 0` lets Docker choose; the assigned port is reported back. |
| 232 | * ports: [{ external: 0, internal: 3000 }], |
| 233 | * start: true, |
| 234 | * }); |
| 235 | * const hostPort = api.ports["3000/tcp"]; |
| 236 | * ``` |
| 237 | * |
| 238 | * ### Traefik |
| 239 | * **Example:** Route a container through Traefik |
| 240 | * ```typescript |
| 241 | * const api = yield* Docker.Container("api", { |
| 242 | * image: "ghcr.io/acme/api:latest", |
| 243 | * networks: [{ name: "traefik" }], |
| 244 | * labels: { |
| 245 | * "traefik.enable": "true", |
| 246 | * "traefik.http.routers.api.rule": "Host(`api.example.com`)", |
| 247 | * "traefik.http.services.api.loadbalancer.server.port": "3000", |
| 248 | * }, |
| 249 | * stopTimeout: "30 seconds", |
| 250 | * start: true, |
| 251 | * }); |
| 252 | * ``` |
| 253 | * |
| 254 | * **Example:** Use a Docker.Context resource |
| 255 | * ```typescript |
| 256 | * const remote = yield* Docker.Context("remote", { |
| 257 | * name: "remote-build", |
| 258 | * docker: "host=ssh://docker@example.com", |
| 259 | * }); |
| 260 | * |
| 261 | * const api = yield* Docker.Container("api", { |
| 262 | * image: "nginx:alpine", |
| 263 | * context: remote, |
| 264 | * }); |
| 265 | * ``` |
| 266 | * |
| 267 | * @resource |
| 268 | * @product Container |
| 269 | */ |
| 270 | export const Container = Resource<Container>("Docker.Container"); |
| 271 | |
| 272 | /** |
| 273 | * Inspect a Docker container by name and return normalized runtime details. |
| 274 | * |
| 275 | * This is a small public wrapper around Docker's raw inspect output. It returns |
| 276 | * the stable data Alchemy callers typically need, including bound host ports. |
| 277 | */ |
| 278 | export const inspectContainer = ( |
| 279 | name: string, |
| 280 | context?: Docker.ContextRef, |
| 281 | ): Effect.Effect<Container["Attributes"], PlatformError, Docker> => |
| 282 | Docker.pipe( |
| 283 | Effect.flatMap((docker) => |
| 284 | docker.container.inspect(name, dockerContextName(context)), |
| 285 | ), |
| 286 | Effect.map((container) => |
| 287 | toContainerAttributes(container, container.Config.Image), |
| 288 | ), |
| 289 | ); |
| 290 | |
| 291 | export const ContainerProvider = () => |
| 292 | Provider.effect( |
| 293 | Container, |
| 294 | Effect.gen(function* () { |
| 295 | const docker = yield* Docker; |
| 296 | |
| 297 | const reconcileNetworks = Effect.fn(function* ( |
| 298 | live: Docker.Container, |
| 299 | news: ContainerProps, |
| 300 | olds: ContainerProps | undefined, |
| 301 | ) { |
| 302 | const context = dockerContextName(news.context); |
| 303 | const connect = new Map<string, Container.NetworkMapping>(); |
| 304 | const disconnect = new Set<string>(); |
| 305 | for (const network of news.networks ?? []) { |
| 306 | const entry = live.NetworkSettings.Networks?.[network.name]; |
| 307 | if (!entry) { |
| 308 | connect.set(network.name, network); |
| 309 | } else if ( |
| 310 | !Equal.equals(entry.Aliases ?? [], network.aliases ?? []) |
| 311 | ) { |
| 312 | connect.set(network.name, network); |
| 313 | disconnect.add(network.name); |
| 314 | } |
| 315 | } |
| 316 | // Only networks alchemy itself attached are alchemy's to detach, and |
| 317 | // `olds.networks` is the sole record of which those are — the live |
| 318 | // container cannot say who connected a network. Sweeping every live |
| 319 | // network instead tore off the default `bridge` and anything a user, |
| 320 | // compose file, or another tool had attached out of band (#1386). |
| 321 | const desired = new Set((news.networks ?? []).map((n) => n.name)); |
| 322 | for (const network of olds?.networks ?? []) { |
| 323 | if ( |
| 324 | !desired.has(network.name) && |
| 325 | live.NetworkSettings.Networks?.[network.name] |
| 326 | ) { |
| 327 | disconnect.add(network.name); |
| 328 | } |
| 329 | } |
| 330 | yield* Effect.forEach( |
| 331 | disconnect, |
| 332 | (network) => |
| 333 | docker.network.disconnect({ network, container: live.Id, context }), |
| 334 | { concurrency: "unbounded" }, |
| 335 | ); |
| 336 | yield* Effect.forEach( |
| 337 | connect.values(), |
| 338 | (network) => |
| 339 | docker.network.connect({ |
| 340 | network: network.name, |
| 341 | container: live.Id, |
| 342 | alias: network.aliases, |
| 343 | context, |
| 344 | }), |
| 345 | { concurrency: "unbounded" }, |
| 346 | ); |
| 347 | }); |
| 348 | |
| 349 | return Container.Provider.of({ |
| 350 | list: () => Effect.succeed([]), |
| 351 | read: Effect.fn(function* ({ id, instanceId, olds, output }) { |
| 352 | const context = dockerContextName(olds.context); |
| 353 | const name = yield* dockerPhysicalName(id, olds, instanceId); |
| 354 | const info = yield* docker.container |
| 355 | .inspect(name, context) |
| 356 | .pipe( |
| 357 | Effect.catchReason( |
| 358 | "PlatformError", |
| 359 | "NotFound", |
| 360 | () => Effect.undefined, |
| 361 | ), |
| 362 | ); |
| 363 | if (!info) return undefined; |
| 364 | // `olds.image` may be `undefined` when a `creating` row was |
| 365 | // persisted before upstream Outputs resolved — fall back to the |
| 366 | // live container's actual image. |
| 367 | const attrs = toContainerAttributes( |
| 368 | info, |
| 369 | olds.image !== undefined |
| 370 | ? normalizeImageRef(olds.image) |
| 371 | : info.Config.Image, |
| 372 | ); |
| 373 | if (output) return attrs; |
| 374 | // Without prior state, only adopt a container that carries our |
| 375 | // branding; anything else is foreign and gated behind `--adopt`. |
| 376 | const owned = yield* hasAlchemyTags( |
| 377 | id, |
| 378 | info.Config.Labels ?? undefined, |
| 379 | ); |
| 380 | return owned ? attrs : Unowned(attrs); |
| 381 | }), |
| 382 | diff: Effect.fn(function* ({ id, instanceId, news, olds }) { |
| 383 | if (!isResolved(news)) return undefined; |
| 384 | // An Output-valued `image` doesn't survive a `creating`-state |
| 385 | // round-trip (it deserializes as `undefined`) — without comparable |
| 386 | // prior create args, let the engine apply its default update logic. |
| 387 | if (olds.image === undefined) return undefined; |
| 388 | if ( |
| 389 | dockerContextName(olds.context) !== dockerContextName(news.context) |
| 390 | ) { |
| 391 | return { action: "replace" as const, deleteFirst: true }; |
| 392 | } |
| 393 | const oldArgs = yield* makeCreateArgs(id, olds, instanceId); |
| 394 | const newArgs = yield* makeCreateArgs(id, news, instanceId); |
| 395 | if (!Equal.equals(oldArgs, newArgs)) { |
| 396 | return { action: "replace" as const, deleteFirst: true }; |
| 397 | } |
| 398 | if ( |
| 399 | !Equal.equals(olds.networks ?? [], news.networks ?? []) || |
| 400 | (olds.start ?? false) !== (news.start ?? false) |
| 401 | ) { |
| 402 | return { action: "update" as const }; |
| 403 | } |
| 404 | }), |
| 405 | reconcile: Effect.fn(function* ({ id, instanceId, news, olds }) { |
| 406 | const context = dockerContextName(news.context); |
| 407 | const args = yield* makeCreateArgs(id, news, instanceId); |
| 408 | const live = yield* docker.container |
| 409 | .inspect(args.name, context) |
| 410 | .pipe( |
| 411 | Effect.catchReason( |
| 412 | "PlatformError", |
| 413 | "NotFound", |
| 414 | () => Effect.undefined, |
| 415 | ), |
| 416 | ); |
| 417 | |
| 418 | if (live) { |
| 419 | yield* reconcileNetworks(live, news, olds); |
| 420 | if (news.start && live.State.Status !== "running") { |
| 421 | yield* docker.container.start(live.Id, context); |
| 422 | } else if (!news.start && live.State.Status === "running") { |
| 423 | yield* docker.container.stop(live.Id, context); |
| 424 | } |
| 425 | return yield* docker.container |
| 426 | .inspect(live.Id, context) |
| 427 | .pipe( |
| 428 | Effect.map((info) => toContainerAttributes(info, args.image)), |
| 429 | ); |
| 430 | } |
| 431 | |
| 432 | const internalTags = yield* createInternalTags(id); |
| 433 | const { stdout: containerId } = yield* docker.container.create({ |
| 434 | ...args, |
| 435 | context, |
| 436 | label: { ...args.label, ...internalTags }, |
| 437 | }); |
| 438 | yield* Effect.forEach( |
| 439 | news.networks ?? [], |
| 440 | (network) => |
| 441 | docker.network.connect({ |
| 442 | network: network.name, |
| 443 | container: containerId, |
| 444 | alias: network.aliases, |
| 445 | context, |
| 446 | }), |
| 447 | { concurrency: "unbounded" }, |
| 448 | ); |
| 449 | if (news.start) { |
| 450 | yield* docker.container.start(containerId, context); |
| 451 | } |
| 452 | const info = yield* docker.container.inspect(containerId, context); |
| 453 | return toContainerAttributes(info, args.image); |
| 454 | }), |
| 455 | delete: Effect.fn(({ olds, output }) => |
| 456 | docker.container |
| 457 | .stop(output.name, dockerContextName(olds.context)) |
| 458 | .pipe( |
| 459 | Effect.andThen( |
| 460 | docker.container.remove( |
| 461 | output.name, |
| 462 | true, |
| 463 | dockerContextName(olds.context), |
| 464 | ), |
| 465 | ), |
| 466 | Effect.catchReason( |
| 467 | "PlatformError", |
| 468 | "NotFound", |
| 469 | () => Effect.void, |
| 470 | ), |
| 471 | ), |
| 472 | ), |
| 473 | }); |
| 474 | }), |
| 475 | ); |
| 476 | |
| 477 | const normalizeImageRef = (image: Container.Image): string => |
| 478 | typeof image === "string" ? image : image.imageRef; |
| 479 | |
| 480 | const makeCreateArgs = (id: string, news: ContainerProps, instanceId: string) => |
| 481 | dockerPhysicalName(id, news, instanceId).pipe( |
| 482 | Effect.map( |
| 483 | (name): Parameters<Docker["Service"]["container"]["create"]>[0] => ({ |
| 484 | name, |
| 485 | image: normalizeImageRef(news.image), |
| 486 | command: news.command, |
| 487 | env: normalizeEnvironment(news.environment), |
| 488 | volume: news.volumes?.map( |
| 489 | (v) => `${v.hostPath}:${v.containerPath}${v.readOnly ? ":ro" : ""}`, |
| 490 | ), |
| 491 | p: news.ports?.map((port) => { |
| 492 | const target = `${port.internal}/${port.protocol ?? "tcp"}`; |
| 493 | // `external: 0` means "any free host port". Docker spells that as a |
| 494 | // bare container port (`-p 80/tcp`); `-p 0:80/tcp` instead asks for |
| 495 | // host port 0 literally, which the daemon accepts and then reports |
| 496 | // back as 0. |
| 497 | return isRandomHostPort(port.external) |
| 498 | ? target |
| 499 | : `${port.external}:${target}`; |
| 500 | }), |
| 501 | "add-host": news.extraHosts, |
| 502 | restart: news.restart ?? "no", |
| 503 | label: news.labels, |
| 504 | "stop-timeout": toSeconds(news.stopTimeout)?.toString(), |
| 505 | rm: news.removeOnExit ?? false, |
| 506 | ...(news.healthcheck |
| 507 | ? { |
| 508 | "health-cmd": Array.isArray(news.healthcheck.cmd) |
| 509 | ? news.healthcheck.cmd.join(" ") |
| 510 | : news.healthcheck.cmd, |
| 511 | "health-interval": normalizeDuration(news.healthcheck.interval), |
| 512 | "health-timeout": normalizeDuration(news.healthcheck.timeout), |
| 513 | "health-retries": news.healthcheck.retries ?? 0, |
| 514 | "health-start-period": normalizeDuration( |
| 515 | news.healthcheck.startPeriod, |
| 516 | ), |
| 517 | "health-start-interval": normalizeDuration( |
| 518 | news.healthcheck.startInterval, |
| 519 | ), |
| 520 | } |
| 521 | : { |
| 522 | "health-cmd": undefined, |
| 523 | "health-interval": undefined, |
| 524 | "health-timeout": undefined, |
| 525 | "health-retries": undefined, |
| 526 | "health-start-period": undefined, |
| 527 | "health-start-interval": undefined, |
| 528 | }), |
| 529 | }), |
| 530 | ), |
| 531 | ); |
| 532 | |
| 533 | const toContainerAttributes = ( |
| 534 | info: Docker.Container, |
| 535 | imageRef: string, |
| 536 | ): Container["Attributes"] => ({ |
| 537 | id: info.Id, |
| 538 | name: typeof info.Name === "string" ? info.Name.replace(/^\//, "") : info.Id, |
| 539 | status: info.State.Status, |
| 540 | createdAt: Date.parse(info.Created) || Date.now(), |
| 541 | imageRef, |
| 542 | ports: toPortAttributes(info), |
| 543 | }); |
| 544 | |
| 545 | /** First binding that carries a real (non-zero) host port. */ |
| 546 | const boundHostPort = ( |
| 547 | bindings: ReadonlyArray<{ HostPort?: string }> | null | undefined, |
| 548 | ): number | undefined => { |
| 549 | for (const binding of bindings ?? []) { |
| 550 | if (!binding.HostPort) continue; |
| 551 | const port = Number.parseInt(binding.HostPort, 10); |
| 552 | if (Number.isInteger(port) && port > 0) return port; |
| 553 | } |
| 554 | return undefined; |
| 555 | }; |
| 556 | |
| 557 | /** |
| 558 | * `HostConfig.PortBindings` is what was *requested*, `NetworkSettings.Ports` |
| 559 | * what Docker actually *assigned* — so the assignment wins wherever both |
| 560 | * exist. A container published with `external: 0` (or any random-publish |
| 561 | * mapping) has no requested host port at all, and reading the request over |
| 562 | * the assignment reported 0 instead of the port the container is reachable |
| 563 | * on. The request is still the fallback: a created-but-not-yet-started |
| 564 | * container has empty `NetworkSettings.Ports`. |
| 565 | */ |
| 566 | const toPortAttributes = (info: Docker.Container): Record<string, number> => { |
| 567 | const ports: Record<string, number> = {}; |
| 568 | for (const [internal, bindings] of Object.entries( |
| 569 | info.HostConfig.PortBindings ?? {}, |
| 570 | )) { |
| 571 | const port = boundHostPort(bindings); |
| 572 | if (port !== undefined) ports[internal] = port; |
| 573 | } |
| 574 | for (const [internal, bindings] of Object.entries( |
| 575 | info.NetworkSettings.Ports ?? {}, |
| 576 | )) { |
| 577 | const port = boundHostPort(bindings); |
| 578 | if (port !== undefined) ports[internal] = port; |
| 579 | } |
| 580 | return ports; |
| 581 | }; |
| 582 | |
| 583 | /** `external: 0` / `"0"` asks Docker to pick any free host port. */ |
| 584 | const isRandomHostPort = (external: number | string): boolean => |
| 585 | Number.parseInt(String(external), 10) === 0; |
| 586 | |
| 587 | const normalizeEnvironment = ( |
| 588 | environment: Record<string, string | Redacted.Redacted<string>> | undefined, |
| 589 | ): Record<string, string> => |
| 590 | Object.fromEntries( |
| 591 | Object.entries(environment ?? {}).map(([key, value]) => [ |
| 592 | key, |
| 593 | Redacted.isRedacted(value) ? Redacted.value(value) : value, |
| 594 | ]), |
| 595 | ); |
| 596 | |
| 597 | const normalizeDuration = ( |
| 598 | input: Duration.Input | undefined, |
| 599 | ): string | undefined => { |
| 600 | if (!input) return undefined; |
| 601 | const duration = Duration.fromInputUnsafe(input); |
| 602 | // Docker parses `--health-*` durations with Go's `time.ParseDuration`, which |
| 603 | // requires a unit suffix — a bare nanosecond count is rejected with "missing |
| 604 | // unit in duration". `ns` is the lossless Go-duration rendering of the nanos. |
| 605 | return `${Duration.toNanosUnsafe(duration).toString()}ns`; |
| 606 | }; |