| 1 | import * as Config from "effect/Config"; |
| 2 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import type * as Scope from "effect/Scope"; |
| 9 | import * as Stream from "effect/Stream"; |
| 10 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 11 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 12 | import * as NodeHttp from "node:http"; |
| 13 | import * as NodeStream from "node:stream"; |
| 14 | import { |
| 15 | attachLoopbackNetnsForwarder, |
| 16 | CONTAINER_LOOPBACK_ALIAS, |
| 17 | containerIdFromPath, |
| 18 | detachLoopbackNetnsForwarder, |
| 19 | ensureLoopbackUnixSockets, |
| 20 | isContainerStartPath, |
| 21 | loopbackPortsFromEnv, |
| 22 | mergeSidecarLoopbackHostConfig, |
| 23 | ufwAllowHint, |
| 24 | usesUnixSocketLoopback, |
| 25 | } from "./DockerLoopback.ts"; |
| 26 | import { getAddress } from "./internal/get-address.ts"; |
| 27 | import { ConfigError, SystemError } from "./RuntimeError.shared.ts"; |
| 28 | import type * as WorkerdConfig from "./workerd/Config.ts"; |
| 29 | |
| 30 | export { CONTAINER_LOOPBACK_ALIAS }; |
| 31 | |
| 32 | export class Docker extends Context.Service< |
| 33 | Docker, |
| 34 | { |
| 35 | readonly getWorkerdDockerConfiguration: Effect.Effect< |
| 36 | WorkerdConfig.Worker_ContainerEngine, |
| 37 | SystemError |
| 38 | >; |
| 39 | readonly generateImageTag: (className: string, suffix?: string) => string; |
| 40 | readonly registerImageEnv: ( |
| 41 | className: string, |
| 42 | tag: string, |
| 43 | env: Record<string, string>, |
| 44 | ) => Effect.Effect<string, never, Scope.Scope>; |
| 45 | readonly build: ( |
| 46 | tag: string, |
| 47 | image: ContainerImage.Build, |
| 48 | ) => Effect.Effect<void, SystemError>; |
| 49 | readonly pull: ( |
| 50 | tag: string, |
| 51 | image: ContainerImage.Pull, |
| 52 | ) => Effect.Effect<void, SystemError>; |
| 53 | readonly validate: (tag: string) => Effect.Effect<void, ConfigError>; |
| 54 | readonly removeImageTag: (tag: string) => Effect.Effect<void>; |
| 55 | readonly removeContainer: (tag: string) => Effect.Effect<void, SystemError>; |
| 56 | } |
| 57 | >()("cloudflare-runtime/Docker") {} |
| 58 | |
| 59 | export type ContainerImage = |
| 60 | | ContainerImage.Build |
| 61 | | ContainerImage.Pull |
| 62 | | ContainerImage.Ref; |
| 63 | |
| 64 | export declare namespace ContainerImage { |
| 65 | interface Base { |
| 66 | readonly env?: Record<string, string>; |
| 67 | } |
| 68 | export interface Build extends Base { |
| 69 | readonly dockerfile: string; |
| 70 | readonly context?: string; |
| 71 | readonly buildArgs?: Record<string, string>; |
| 72 | } |
| 73 | export interface Pull extends Base { |
| 74 | readonly imageUri: string; |
| 75 | } |
| 76 | export interface Ref extends Base { |
| 77 | readonly tag: string; |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | const DEFAULT_DOCKER_HOST = |
| 82 | process.platform === "win32" |
| 83 | ? "//./pipe/docker_engine" |
| 84 | : "unix:///var/run/docker.sock"; |
| 85 | const DEV_CONTAINER_PREFIX = "alchemy-dev"; |
| 86 | |
| 87 | const DockerHost = Config.String("DOCKER_HOST"); |
| 88 | const DockerBin = Config.String("DOCKER_BIN").pipe( |
| 89 | Config.orElse(() => Config.succeed("docker")), |
| 90 | ); |
| 91 | const ContainerEgressInterceptorImage = Config.String( |
| 92 | "CONTAINER_EGRESS_INTERCEPTOR_IMAGE", |
| 93 | ).pipe( |
| 94 | Config.orElse(() => |
| 95 | Config.succeed( |
| 96 | "cloudflare/proxy-everything:3cb1195@sha256:0ef6716c52430096900b150d84a3302057d6cd2319dae7987128c85d0733e3c8", |
| 97 | ), |
| 98 | ), |
| 99 | ); |
| 100 | |
| 101 | /** |
| 102 | * Docker's containerd image store rejects combined `repo:tag@digest` pull |
| 103 | * refs ("cannot overwrite digest"); the digest fully pins the image, so the |
| 104 | * tag is dropped when both are present. |
| 105 | */ |
| 106 | export const toPullRef = (imageUri: string) => |
| 107 | imageUri.replace(/:[^@/]+(?=@sha256:)/, ""); |
| 108 | |
| 109 | /** |
| 110 | * Stderr signatures of a docker CLI that cannot run our build. Image builds |
| 111 | * use BuildKit flags (`--load`, `--provenance=false`), which a CLI without |
| 112 | * the buildx plugin rejects outright. Its stderr names the flag rather than |
| 113 | * the missing component, and several distros package the two separately |
| 114 | * (Arch/CachyOS `docker` vs `docker-buildx`, Debian `docker.io` vs |
| 115 | * `docker-buildx`), so this is a first-run trap rather than a broken setup. |
| 116 | */ |
| 117 | const BUILDKIT_MISSING_MARKERS = [ |
| 118 | "unknown flag: --load", |
| 119 | "unknown flag: --provenance", |
| 120 | "'buildx' is not a docker command", |
| 121 | "buildx component is missing", |
| 122 | ]; |
| 123 | |
| 124 | /** Names the missing buildx plugin when docker's own stderr only names the flag. */ |
| 125 | export const buildFailureHint = (stderr: string): string | undefined => |
| 126 | BUILDKIT_MISSING_MARKERS.some((marker) => stderr.includes(marker)) |
| 127 | ? "This docker CLI has no BuildKit (buildx) plugin, which building container images requires. Install it and retry — e.g. `pacman -S docker-buildx` (Arch/CachyOS), `apt install docker-buildx` (Debian/Ubuntu), or Docker Desktop, which bundles it." |
| 128 | : undefined; |
| 129 | |
| 130 | /** |
| 131 | * Matches a loopback host (`localhost`, `127.0.0.1`, `0.0.0.0`, `[::1]`) |
| 132 | * where it denotes a connection target inside an env value: at the start of |
| 133 | * the value, after a `scheme://` (with optional userinfo), after `=` (DSN |
| 134 | * keyword form, `host=localhost`), or after whitespace/comma/semicolon |
| 135 | * delimiters — and followed by a port, path, delimiter, or the end. |
| 136 | */ |
| 137 | const LOOPBACK_HOST = |
| 138 | /(^|[\s,;=]|\/\/(?:[^/\s@]*@)?)(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?=[:/?#]|[\s,;]|$)/g; |
| 139 | |
| 140 | /** |
| 141 | * Rewrite loopback hosts in a container env value to |
| 142 | * {@link CONTAINER_LOOPBACK_ALIAS} so the value keeps meaning "the developer's |
| 143 | * machine" from inside the container. Prisma's `prisma+postgres://` client |
| 144 | * only speaks plain HTTP when the host looks local (contains `localhost`). |
| 145 | * Applied at container create; production URLs are cloud hosts, so this |
| 146 | * only fires against local emulators. |
| 147 | * |
| 148 | * Native Linux: the alias is `/etc/hosts`-mapped to `127.0.0.1` in the |
| 149 | * sidecar netns, and a unix-socket tunnel reaches the host process — a |
| 150 | * SYN to Docker's bridge IP would hit UFW INPUT. Docker Desktop: mapped |
| 151 | * to `host-gateway`, which already forwards to host loopback. |
| 152 | */ |
| 153 | export const rewriteLoopbackHosts = (value: string) => |
| 154 | value.replace(LOOPBACK_HOST, `$1${CONTAINER_LOOPBACK_ALIAS}`); |
| 155 | |
| 156 | /** |
| 157 | * Merge workerd's create-body `Env` with the deployment env alchemy injects, |
| 158 | * rewriting loopback hosts and **replacing by name**. |
| 159 | * |
| 160 | * Appending would leave the original `DATABASE_URL=…127.0.0.1…` in place and |
| 161 | * add a rewritten copy. glibc/`os.Getenv` (Go, C, Python, Node on Linux) |
| 162 | * return the first match, so the container would still dial `127.0.0.1` |
| 163 | * inside its own netns. |
| 164 | */ |
| 165 | export const mergeContainerCreateEnv = ( |
| 166 | originalEnv: ReadonlyArray<string> | undefined, |
| 167 | imageEnv: Record<string, string> | undefined, |
| 168 | ): string[] => { |
| 169 | const order: string[] = []; |
| 170 | const values = new Map<string, string | undefined>(); |
| 171 | const set = (name: string, value: string | undefined) => { |
| 172 | if (!values.has(name)) order.push(name); |
| 173 | values.set(name, value); |
| 174 | }; |
| 175 | for (const entry of originalEnv ?? []) { |
| 176 | const eq = entry.indexOf("="); |
| 177 | if (eq === -1) { |
| 178 | set(entry, undefined); |
| 179 | } else { |
| 180 | set(entry.slice(0, eq), rewriteLoopbackHosts(entry.slice(eq + 1))); |
| 181 | } |
| 182 | } |
| 183 | for (const [name, value] of Object.entries(imageEnv ?? {})) { |
| 184 | set(name, rewriteLoopbackHosts(value)); |
| 185 | } |
| 186 | return order.map((name) => { |
| 187 | const value = values.get(name); |
| 188 | return value === undefined ? name : `${name}=${value}`; |
| 189 | }); |
| 190 | }; |
| 191 | |
| 192 | export const DockerLive = Layer.effect( |
| 193 | Docker, |
| 194 | Effect.gen(function* () { |
| 195 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 196 | const fs = yield* FileSystem.FileSystem; |
| 197 | const path = yield* Path.Path; |
| 198 | |
| 199 | const bin = yield* DockerBin; |
| 200 | const containerEgressInterceptorImage = |
| 201 | yield* ContainerEgressInterceptorImage; |
| 202 | const registeredImages = new Map< |
| 203 | string, |
| 204 | { tag: string; env: Record<string, string> } |
| 205 | >(); |
| 206 | |
| 207 | const registeredLoopbackPorts = () => { |
| 208 | const ports = new Set<number>(); |
| 209 | for (const { env } of registeredImages.values()) { |
| 210 | for (const port of loopbackPortsFromEnv(env)) ports.add(port); |
| 211 | } |
| 212 | return [...ports]; |
| 213 | }; |
| 214 | |
| 215 | const getSocketPathFromContext = () => |
| 216 | ChildProcess.make(bin, ["context", "ls", "--format", "json"], { |
| 217 | stdin: "ignore", |
| 218 | stdout: "pipe", |
| 219 | stderr: "pipe", |
| 220 | detached: false, |
| 221 | }).pipe( |
| 222 | spawner.spawn, |
| 223 | Effect.flatMap((child) => |
| 224 | child.stdout.pipe( |
| 225 | Stream.decodeText, |
| 226 | Stream.splitLines, |
| 227 | Stream.filter((line) => line.trim() !== ""), |
| 228 | Stream.map( |
| 229 | (line) => |
| 230 | JSON.parse(line) as { |
| 231 | Current: boolean; |
| 232 | DockerEndpoint: string; |
| 233 | }, |
| 234 | ), |
| 235 | Stream.runCollect, |
| 236 | Effect.flatMap((items) => { |
| 237 | const endpoint = items.find( |
| 238 | (item) => item.Current, |
| 239 | )?.DockerEndpoint; |
| 240 | return endpoint |
| 241 | ? Effect.succeed(endpoint) |
| 242 | : Effect.fail( |
| 243 | new ConfigError({ |
| 244 | subtag: "DockerHostNotFound", |
| 245 | message: "Docker host not found", |
| 246 | }), |
| 247 | ); |
| 248 | }), |
| 249 | ), |
| 250 | ), |
| 251 | Effect.scoped, |
| 252 | ); |
| 253 | |
| 254 | const makeDockerProxyServer = (socketPath: string) => |
| 255 | NodeHttp.createServer(async (req, res) => { |
| 256 | const isCreateRequest = |
| 257 | req.method === "POST" && req.url?.startsWith("/containers/create"); |
| 258 | // workerd creates two containers per instance: the user container and |
| 259 | // a `<name>-proxy` networking sidecar whose namespace the user |
| 260 | // container joins (`NetworkMode: container:<sidecar>`) — so the |
| 261 | // sidecar's /etc/hosts is what the user container resolves against. |
| 262 | const isSidecarCreateRequest = |
| 263 | isCreateRequest && req.url!.endsWith("-proxy"); |
| 264 | if (isCreateRequest && !isSidecarCreateRequest) { |
| 265 | const original = await extractJsonBody<{ |
| 266 | Image: string; |
| 267 | Env: Array<string>; |
| 268 | }>(req); |
| 269 | const image = registeredImages.get(original.Image); |
| 270 | const transformed = JSON.stringify({ |
| 271 | ...original, |
| 272 | Image: image?.tag ?? original.Image, |
| 273 | Env: mergeContainerCreateEnv(original.Env, image?.env), |
| 274 | }); |
| 275 | const proxy = sendProxyRequest({ |
| 276 | socketPath, |
| 277 | path: req.url, |
| 278 | method: req.method, |
| 279 | headers: { |
| 280 | ...req.headers, |
| 281 | "content-length": Buffer.byteLength(transformed).toString(), |
| 282 | }, |
| 283 | res, |
| 284 | }); |
| 285 | proxy.end(transformed); |
| 286 | } else if (isSidecarCreateRequest) { |
| 287 | // Shared netns with the user container. Docker Desktop maps the |
| 288 | // alias through host-gateway (reaches host 127.0.0.1). Native |
| 289 | // Linux maps it to 127.0.0.1 in this netns and bind-mounts unix |
| 290 | // sockets; a SYN to the bridge IP is host INPUT (UFW). |
| 291 | const original = await extractJsonBody<{ |
| 292 | HostConfig?: { ExtraHosts?: Array<string>; Binds?: Array<string> }; |
| 293 | }>(req); |
| 294 | const ports = registeredLoopbackPorts(); |
| 295 | ensureLoopbackUnixSockets(ports); |
| 296 | const transformed = JSON.stringify({ |
| 297 | ...original, |
| 298 | HostConfig: mergeSidecarLoopbackHostConfig( |
| 299 | original.HostConfig, |
| 300 | ports, |
| 301 | ), |
| 302 | }); |
| 303 | const proxy = sendProxyRequest({ |
| 304 | socketPath, |
| 305 | path: req.url, |
| 306 | method: req.method, |
| 307 | headers: { |
| 308 | ...req.headers, |
| 309 | "content-length": Buffer.byteLength(transformed).toString(), |
| 310 | }, |
| 311 | res, |
| 312 | }); |
| 313 | proxy.end(transformed); |
| 314 | } else if (req.method === "POST" && isContainerStartPath(req.url)) { |
| 315 | const id = containerIdFromPath(req.url); |
| 316 | const proxy = sendProxyRequest({ |
| 317 | socketPath, |
| 318 | path: req.url, |
| 319 | method: req.method, |
| 320 | headers: req.headers, |
| 321 | res, |
| 322 | afterSuccess: |
| 323 | id === undefined |
| 324 | ? undefined |
| 325 | : () => |
| 326 | attachSidecarLoopback( |
| 327 | socketPath, |
| 328 | id, |
| 329 | registeredLoopbackPorts(), |
| 330 | ), |
| 331 | }); |
| 332 | req.pipe(proxy, { end: true }); |
| 333 | } else if (req.method === "DELETE") { |
| 334 | const id = containerIdFromPath(req.url); |
| 335 | if (id !== undefined) detachLoopbackNetnsForwarder(id); |
| 336 | const proxy = sendProxyRequest({ |
| 337 | socketPath, |
| 338 | path: req.url, |
| 339 | method: req.method, |
| 340 | headers: req.headers, |
| 341 | res, |
| 342 | }); |
| 343 | req.pipe(proxy, { end: true }); |
| 344 | } else { |
| 345 | const proxy = sendProxyRequest({ |
| 346 | socketPath, |
| 347 | path: req.url, |
| 348 | method: req.method, |
| 349 | headers: req.headers, |
| 350 | res, |
| 351 | }); |
| 352 | req.pipe(proxy, { end: true }); |
| 353 | } |
| 354 | }); |
| 355 | |
| 356 | const run = ( |
| 357 | args: Array<string>, |
| 358 | stdin: ChildProcess.CommandInput = "ignore", |
| 359 | ) => |
| 360 | ChildProcess.make(bin, args, { |
| 361 | stdin, |
| 362 | stdout: "pipe", |
| 363 | stderr: "pipe", |
| 364 | detached: false, |
| 365 | }).pipe( |
| 366 | spawner.spawn, |
| 367 | Effect.flatMap((child) => |
| 368 | Effect.all( |
| 369 | { |
| 370 | exitCode: child.exitCode, |
| 371 | stdout: child.stdout.pipe( |
| 372 | Stream.decodeText, |
| 373 | Stream.tap(Effect.logDebug), |
| 374 | Stream.mkString, |
| 375 | ), |
| 376 | stderr: child.stderr.pipe( |
| 377 | Stream.decodeText, |
| 378 | Stream.tap(Effect.logDebug), |
| 379 | Stream.mkString, |
| 380 | ), |
| 381 | }, |
| 382 | { concurrency: "unbounded" }, |
| 383 | ), |
| 384 | ), |
| 385 | Effect.scoped, |
| 386 | ); |
| 387 | |
| 388 | /** |
| 389 | * `run` resolves with the command's exit code instead of failing on it |
| 390 | * (some callers, e.g. `inspect`, treat a non-zero exit as a legitimate |
| 391 | * "not present" answer). Every command whose failure actually matters |
| 392 | * therefore has to check the code itself: without this, a `docker build` |
| 393 | * that exits 1 is indistinguishable from a successful build, the image |
| 394 | * is never created, and the only symptom the user ever sees is workerd |
| 395 | * reporting `Container exited while waiting for port <port>` on a loop. |
| 396 | */ |
| 397 | const ensureExitZero = <E>( |
| 398 | result: { exitCode: number; stdout: string; stderr: string }, |
| 399 | onNonZero: (result: { |
| 400 | exitCode: number; |
| 401 | stdout: string; |
| 402 | stderr: string; |
| 403 | }) => E, |
| 404 | ): Effect.Effect<void, E> => |
| 405 | result.exitCode === 0 ? Effect.void : Effect.fail(onNonZero(result)); |
| 406 | |
| 407 | /** |
| 408 | * `platform` is omitted for the egress interceptor: it is a host-side |
| 409 | * sidecar, not a deployable image, so Docker must resolve the |
| 410 | * host-native variant. Forcing `linux/amd64` there runs it under |
| 411 | * emulation on arm64 hosts, where its transparent-proxy setup dies at |
| 412 | * startup with `Fatal error: setsockoptint: protocol not available`. |
| 413 | * Deployable user images pass `linux/amd64` explicitly, because that is |
| 414 | * the architecture Cloudflare's container runtime executes. |
| 415 | */ |
| 416 | const pull = ({ |
| 417 | imageUri, |
| 418 | platform, |
| 419 | }: ContainerImage.Pull & { readonly platform?: string }) => |
| 420 | run([ |
| 421 | "pull", |
| 422 | toPullRef(imageUri), |
| 423 | ...(platform ? ["--platform", platform] : []), |
| 424 | ]).pipe( |
| 425 | Effect.mapError( |
| 426 | (cause) => |
| 427 | new SystemError({ |
| 428 | subtag: "DockerPullFailed", |
| 429 | message: `Failed to pull image "${imageUri}".`, |
| 430 | hint: "Ensure Docker is running and the image is available.", |
| 431 | detail: { bin, imageUri }, |
| 432 | cause, |
| 433 | }), |
| 434 | ), |
| 435 | Effect.flatMap((result) => { |
| 436 | if (result.exitCode !== 0) { |
| 437 | return Effect.fail( |
| 438 | new SystemError({ |
| 439 | subtag: "DockerPullFailed", |
| 440 | message: `Failed to pull image "${imageUri}".`, |
| 441 | hint: "Ensure Docker is running and the image is available.", |
| 442 | detail: { |
| 443 | bin, |
| 444 | imageUri, |
| 445 | exitCode: result.exitCode, |
| 446 | stdout: result.stdout, |
| 447 | stderr: result.stderr, |
| 448 | }, |
| 449 | }), |
| 450 | ); |
| 451 | } |
| 452 | return Effect.succeed(result.stdout); |
| 453 | }), |
| 454 | ); |
| 455 | |
| 456 | const inspect = (tag: string, format: string) => |
| 457 | Effect.map( |
| 458 | run(["image", "inspect", tag, "--format", format]), |
| 459 | (result) => result.stdout, |
| 460 | ); |
| 461 | |
| 462 | const list = (ancestor: string) => |
| 463 | run([ |
| 464 | "ps", |
| 465 | "-a", |
| 466 | "--no-trunc", |
| 467 | "--filter", |
| 468 | `ancestor=${ancestor}`, |
| 469 | "--format", |
| 470 | "{{.ID}} {{.Names}} {{.Image}}", |
| 471 | ]).pipe( |
| 472 | Effect.map((result) => |
| 473 | result.stdout |
| 474 | .split("\n") |
| 475 | .filter((line) => line.trim() !== "") |
| 476 | .map((line) => { |
| 477 | const [id, name, image] = line.split(" "); |
| 478 | return { id, name, image }; |
| 479 | }) |
| 480 | .filter((container) => container.image === ancestor), |
| 481 | ), |
| 482 | ); |
| 483 | |
| 484 | const docker = yield* Effect.zipWith( |
| 485 | DockerHost.pipe( |
| 486 | Effect.catchTag("ConfigError", getSocketPathFromContext), |
| 487 | Effect.orElseSucceed(() => DEFAULT_DOCKER_HOST), |
| 488 | Effect.flatMap((socketPath) => { |
| 489 | const server = makeDockerProxyServer(socketPath); |
| 490 | server.listen(0); |
| 491 | return getAddress(server); |
| 492 | }), |
| 493 | ), |
| 494 | // Skip the eager pull when the interceptor image is already present |
| 495 | // locally. `CONTAINER_EGRESS_INTERCEPTOR_IMAGE` can point at a |
| 496 | // local-only tag that exists in the Docker daemon but resolves in no |
| 497 | // registry (e.g. a locally-built dev image) — an unconditional |
| 498 | // `docker pull` there fails, this detached fiber dies, and every |
| 499 | // caller of `getWorkerdDockerConfiguration` (joined on first use) |
| 500 | // fails with it. `docker image inspect` prints the image id when |
| 501 | // present and empty stdout when absent (`run` reports the non-zero |
| 502 | // exit rather than failing the effect), so only pull when the image |
| 503 | // is genuinely missing. Any failure to even run `inspect` (unlike |
| 504 | // `pull`, it doesn't normalize its error channel) falls back to the |
| 505 | // pre-existing pull behavior rather than failing here. |
| 506 | inspect(containerEgressInterceptorImage, "{{.Id}}").pipe( |
| 507 | Effect.orElseSucceed(() => undefined), |
| 508 | Effect.flatMap((imageId) => |
| 509 | imageId?.trim() |
| 510 | ? Effect.void |
| 511 | : pull({ imageUri: containerEgressInterceptorImage }), |
| 512 | ), |
| 513 | ), |
| 514 | (socketPath) => ({ |
| 515 | localDocker: { |
| 516 | socketPath, |
| 517 | containerEgressInterceptorImage, |
| 518 | }, |
| 519 | }), |
| 520 | { concurrent: true }, |
| 521 | ).pipe( |
| 522 | Effect.forkDetach({ startImmediately: false, uninterruptible: true }), |
| 523 | ); |
| 524 | |
| 525 | return Docker.of({ |
| 526 | getWorkerdDockerConfiguration: Fiber.join(docker), |
| 527 | registerImageEnv: (className, tag, env) => { |
| 528 | const alias = generateImageTag(className); |
| 529 | return Effect.acquireRelease( |
| 530 | Effect.sync(() => { |
| 531 | registeredImages.set(alias, { tag, env }); |
| 532 | ensureLoopbackUnixSockets(loopbackPortsFromEnv(env)); |
| 533 | }), |
| 534 | () => Effect.sync(() => registeredImages.delete(alias)), |
| 535 | ).pipe(Effect.as(alias)); |
| 536 | }, |
| 537 | generateImageTag, |
| 538 | build: (tag, image) => |
| 539 | Effect.suspend(() => { |
| 540 | const args = [ |
| 541 | "build", |
| 542 | "--load", |
| 543 | "-t", |
| 544 | tag, |
| 545 | "--platform", |
| 546 | "linux/amd64", |
| 547 | "--provenance=false", |
| 548 | ...Object.entries(image.buildArgs ?? {}).map( |
| 549 | ([name, value]) => `--build-arg ${name}=${value}`, |
| 550 | ), |
| 551 | "-f", |
| 552 | "-", |
| 553 | path.resolve(image.context ?? path.dirname(image.dockerfile)), |
| 554 | ]; |
| 555 | return run( |
| 556 | args, |
| 557 | fs.stream( |
| 558 | image.context |
| 559 | ? path.resolve(image.context, image.dockerfile) |
| 560 | : path.resolve(image.dockerfile), |
| 561 | ), |
| 562 | ).pipe( |
| 563 | Effect.withLogSpan(`docker: build ${tag}`), |
| 564 | Effect.mapError( |
| 565 | (cause) => |
| 566 | new SystemError({ |
| 567 | subtag: "DockerBuildFailed", |
| 568 | message: `Failed to build image "${tag}".`, |
| 569 | cause, |
| 570 | }), |
| 571 | ), |
| 572 | Effect.flatMap((result) => |
| 573 | ensureExitZero( |
| 574 | result, |
| 575 | ({ exitCode, stdout, stderr }) => |
| 576 | new SystemError({ |
| 577 | subtag: "DockerBuildFailed", |
| 578 | message: `Failed to build image "${tag}".`, |
| 579 | hint: buildFailureHint(stderr), |
| 580 | detail: { bin, tag, exitCode, stdout, stderr }, |
| 581 | }), |
| 582 | ), |
| 583 | ), |
| 584 | ); |
| 585 | }), |
| 586 | pull: (tag, image) => |
| 587 | pull({ ...image, platform: "linux/amd64" }).pipe( |
| 588 | Effect.andThen( |
| 589 | run(["tag", image.imageUri, tag]).pipe( |
| 590 | Effect.mapError( |
| 591 | (cause) => |
| 592 | new SystemError({ |
| 593 | subtag: "DockerTagFailed", |
| 594 | message: `Failed to tag image "${image.imageUri}" as "${tag}".`, |
| 595 | cause, |
| 596 | }), |
| 597 | ), |
| 598 | Effect.flatMap((result) => |
| 599 | ensureExitZero( |
| 600 | result, |
| 601 | ({ exitCode, stdout, stderr }) => |
| 602 | new SystemError({ |
| 603 | subtag: "DockerTagFailed", |
| 604 | message: `Failed to tag image "${image.imageUri}" as "${tag}".`, |
| 605 | detail: { |
| 606 | bin, |
| 607 | imageUri: image.imageUri, |
| 608 | tag, |
| 609 | exitCode, |
| 610 | stdout, |
| 611 | stderr, |
| 612 | }, |
| 613 | }), |
| 614 | ), |
| 615 | ), |
| 616 | ), |
| 617 | ), |
| 618 | Effect.withLogSpan(`docker: pull ${image.imageUri}`), |
| 619 | Effect.asVoid, |
| 620 | ), |
| 621 | validate: (tag) => |
| 622 | inspect(tag, "{{ len .Config.ExposedPorts }}").pipe( |
| 623 | Effect.withLogSpan(`docker: inspect ${tag} for exposed ports`), |
| 624 | Effect.orElseSucceed(() => "0"), |
| 625 | Effect.flatMap((output) => |
| 626 | output === "0" |
| 627 | ? Effect.fail( |
| 628 | new ConfigError({ |
| 629 | subtag: "ContainerNoExposedPorts", |
| 630 | message: `The container for "${tag}" does not expose any ports.`, |
| 631 | hint: "Add an EXPOSE instruction to the Dockerfile for any ports you intend to connect to.", |
| 632 | }), |
| 633 | ) |
| 634 | : Effect.void, |
| 635 | ), |
| 636 | ), |
| 637 | // Untag ONLY the given tag (used by each runtime start's finalizer for |
| 638 | // the tag it created). Deliberately does not guess at sibling |
| 639 | // "<name>:<otherSuffix>" tags: concurrent/successive dev sessions of |
| 640 | // the same container class each hold their own random-suffix tag on |
| 641 | // the same underlying image, and pruning siblings untags an image a |
| 642 | // live workerd still needs (its container creates then fail forever). |
| 643 | removeImageTag: (tag) => |
| 644 | Effect.asVoid(run(["rmi", tag])).pipe( |
| 645 | Effect.withLogSpan(`docker: remove image tag ${tag}`), |
| 646 | Effect.ignore, |
| 647 | ), |
| 648 | removeContainer: (tag) => |
| 649 | list(tag).pipe( |
| 650 | Effect.flatMap((containers) => { |
| 651 | if (containers.length === 0) return Effect.void; |
| 652 | return Effect.asVoid( |
| 653 | run([ |
| 654 | "rm", |
| 655 | "--force", |
| 656 | ...containers.flatMap((container) => [ |
| 657 | container.id, |
| 658 | `${container.name}-proxy`, |
| 659 | ]), |
| 660 | ]), |
| 661 | ); |
| 662 | }), |
| 663 | Effect.withLogSpan(`docker: remove containers for ${tag}`), |
| 664 | Effect.mapError( |
| 665 | (cause) => |
| 666 | new SystemError({ |
| 667 | subtag: "DockerRemoveContainerFailed", |
| 668 | message: `Failed to remove containers for "${tag}".`, |
| 669 | cause, |
| 670 | }), |
| 671 | ), |
| 672 | ), |
| 673 | }); |
| 674 | }), |
| 675 | ); |
| 676 | |
| 677 | const generateImageTag = (className: string, suffix?: string) => |
| 678 | `${DEV_CONTAINER_PREFIX}/${className.toLowerCase()}:${suffix ?? crypto.randomUUID().slice(0, 8)}`; |
| 679 | |
| 680 | const sendProxyRequest = (input: { |
| 681 | socketPath: string; |
| 682 | path: string | undefined; |
| 683 | method: string | undefined; |
| 684 | headers: NodeHttp.OutgoingHttpHeaders; |
| 685 | res: NodeHttp.ServerResponse; |
| 686 | afterSuccess?: () => Promise<void>; |
| 687 | }) => { |
| 688 | // `transfer-encoding` is a hop-by-hop header and must not be forwarded |
| 689 | // verbatim. workerd sends its `DELETE /containers/<name>-proxy?force=true` |
| 690 | // cleanup request with `transfer-encoding: chunked` and an empty body; |
| 691 | // Bun's `node:http` client hangs indefinitely on such a request to a |
| 692 | // unix socket (it never flushes the terminating zero-chunk), so the |
| 693 | // docker daemon never responds and workerd blocks forever before it can |
| 694 | // create the container. Strip the header and let the runtime derive the |
| 695 | // framing from the body we actually write. (Node tolerates it, Bun does |
| 696 | // not — and Alchemy dev runs the runtime under Bun.) |
| 697 | delete input.headers["transfer-encoding"]; |
| 698 | const req = NodeHttp.request( |
| 699 | { |
| 700 | socketPath: input.socketPath.replace(/^unix:/, ""), |
| 701 | path: input.path, |
| 702 | method: input.method, |
| 703 | headers: input.headers, |
| 704 | }, |
| 705 | (res) => { |
| 706 | delete res.headers["transfer-encoding"]; |
| 707 | const succeed = |
| 708 | (res.statusCode ?? 500) < 300 && input.afterSuccess !== undefined; |
| 709 | if (!succeed) { |
| 710 | input.res.writeHead(res.statusCode || 500, res.headers); |
| 711 | res.pipe(input.res, { end: true }); |
| 712 | return; |
| 713 | } |
| 714 | const chunks: Array<Buffer> = []; |
| 715 | res.on("data", (chunk) => chunks.push(chunk)); |
| 716 | res.on("end", () => { |
| 717 | void input.afterSuccess!().finally(() => { |
| 718 | const status = res.statusCode || 500; |
| 719 | input.res.writeHead(status, res.headers); |
| 720 | if (status === 204 || chunks.length === 0) input.res.end(); |
| 721 | else input.res.end(Buffer.concat(chunks)); |
| 722 | }); |
| 723 | }); |
| 724 | }, |
| 725 | ); |
| 726 | req.on("error", (err) => { |
| 727 | input.res.writeHead(502, { "content-type": "text/plain" }); |
| 728 | input.res.end(`Proxy error: ${(err && err.message) || err}`); |
| 729 | }); |
| 730 | return req; |
| 731 | }; |
| 732 | |
| 733 | const attachSidecarLoopback = async ( |
| 734 | socketPath: string, |
| 735 | containerId: string, |
| 736 | ports: readonly number[], |
| 737 | ) => { |
| 738 | if (!usesUnixSocketLoopback() || ports.length === 0) return; |
| 739 | let inspect: { |
| 740 | Id?: string; |
| 741 | Name?: string; |
| 742 | State?: { Pid?: number }; |
| 743 | }; |
| 744 | try { |
| 745 | inspect = await dockerApiJson( |
| 746 | socketPath, |
| 747 | "GET", |
| 748 | `/containers/${containerId}/json`, |
| 749 | ); |
| 750 | } catch (error) { |
| 751 | console.warn( |
| 752 | `alchemy: could not inspect ${containerId} for loopback forwards (${String(error)})`, |
| 753 | ); |
| 754 | return; |
| 755 | } |
| 756 | const name = inspect.Name?.replace(/^\//, "") ?? ""; |
| 757 | if (!name.endsWith("-proxy")) return; |
| 758 | const result = attachLoopbackNetnsForwarder({ |
| 759 | keys: [containerId, inspect.Id ?? "", name], |
| 760 | pid: inspect.State?.Pid ?? 0, |
| 761 | ports, |
| 762 | }); |
| 763 | if (!result.ok) { |
| 764 | console.warn( |
| 765 | `alchemy: could not attach loopback unix-socket forwards in container netns (${result.error}). ` + |
| 766 | `A SYN to host-gateway is host INPUT and UFW may drop it. Do not set DEFAULT_INPUT_POLICY=ACCEPT. ` + |
| 767 | `If you must punch a hole: ${ufwAllowHint(ports)}`, |
| 768 | ); |
| 769 | } |
| 770 | }; |
| 771 | |
| 772 | const dockerApiJson = <T>( |
| 773 | socketPath: string, |
| 774 | method: string, |
| 775 | path: string, |
| 776 | ): Promise<T> => |
| 777 | new Promise((resolve, reject) => { |
| 778 | const req = NodeHttp.request( |
| 779 | { |
| 780 | socketPath: socketPath.replace(/^unix:/, ""), |
| 781 | path, |
| 782 | method, |
| 783 | }, |
| 784 | (res) => { |
| 785 | const chunks: Array<Buffer> = []; |
| 786 | res.on("data", (chunk) => chunks.push(chunk)); |
| 787 | res.on("end", () => { |
| 788 | const body = Buffer.concat(chunks).toString(); |
| 789 | if ((res.statusCode ?? 500) >= 300) { |
| 790 | reject(new Error(`docker ${method} ${path}: ${res.statusCode}`)); |
| 791 | return; |
| 792 | } |
| 793 | try { |
| 794 | resolve(JSON.parse(body) as T); |
| 795 | } catch (error) { |
| 796 | reject(error); |
| 797 | } |
| 798 | }); |
| 799 | }, |
| 800 | ); |
| 801 | req.on("error", reject); |
| 802 | req.end(); |
| 803 | }); |
| 804 | |
| 805 | const extractJsonBody = <T>(req: NodeHttp.IncomingMessage) => { |
| 806 | const promise = Promise.withResolvers<T>(); |
| 807 | const chunks: Array<Buffer> = []; |
| 808 | req.pipe( |
| 809 | new NodeStream.Writable({ |
| 810 | write(chunk, _encoding, callback) { |
| 811 | chunks.push(chunk); |
| 812 | callback(); |
| 813 | }, |
| 814 | final(callback) { |
| 815 | promise.resolve(JSON.parse(Buffer.concat(chunks).toString())); |
| 816 | callback(); |
| 817 | }, |
| 818 | }), |
| 819 | { end: true }, |
| 820 | ); |
| 821 | return promise.promise; |
| 822 | }; |