1import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";
3import * as Equal from "effect/Equal";
4import type { PlatformError } from "effect/PlatformError";
5import * as Redacted from "effect/Redacted";
6import { Unowned } from "../AdoptPolicy.ts";
7import { isResolved } from "../Diff.ts";
8import * as Provider from "../Provider.ts";
9import { Resource } from "../Resource.ts";
10import { createInternalTags, hasAlchemyTags } from "../Tags.ts";
11import { toSeconds } from "../Util/Duration.ts";
12import { Docker, dockerContextName, dockerPhysicalName } from "./Docker.ts";
13import type { Providers } from "./Providers.ts";
14
15export 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
68export 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
116export 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 */
270export 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 */
278export 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
291export 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
477const normalizeImageRef = (image: Container.Image): string =>
478 typeof image === "string" ? image : image.imageRef;
479
480const 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
533const 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. */
546const 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 */
566const 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. */
584const isRandomHostPort = (external: number | string): boolean =>
585 Number.parseInt(String(external), 10) === 0;
586
587const 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
597const 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};