| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { describe, expect, it, layer } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Sink from "effect/Sink"; |
| 6 | import * as Stream from "effect/Stream"; |
| 7 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 8 | import { |
| 9 | buildFailureHint, |
| 10 | CONTAINER_LOOPBACK_ALIAS, |
| 11 | Docker, |
| 12 | DockerLive, |
| 13 | mergeContainerCreateEnv, |
| 14 | rewriteLoopbackHosts, |
| 15 | toPullRef, |
| 16 | } from "../Docker.ts"; |
| 17 | |
| 18 | const PINNED = |
| 19 | "cloudflare/proxy-everything:3cb1195@sha256:0ef6716c52430096900b150d84a3302057d6cd2319dae7987128c85d0733e3c8"; |
| 20 | const PINNED_WITHOUT_TAG = |
| 21 | "cloudflare/proxy-everything@sha256:0ef6716c52430096900b150d84a3302057d6cd2319dae7987128c85d0733e3c8"; |
| 22 | |
| 23 | describe("Docker", () => { |
| 24 | describe("toPullRef", () => { |
| 25 | it("drops the tag when a digest pins the image", () => { |
| 26 | expect(toPullRef(PINNED)).toBe(PINNED_WITHOUT_TAG); |
| 27 | }); |
| 28 | |
| 29 | it("keeps tag-only refs unchanged", () => { |
| 30 | expect(toPullRef("rocicorp/zero:1.8.0")).toBe("rocicorp/zero:1.8.0"); |
| 31 | }); |
| 32 | |
| 33 | it("keeps digest-only refs unchanged", () => { |
| 34 | expect(toPullRef("repo@sha256:abc")).toBe("repo@sha256:abc"); |
| 35 | }); |
| 36 | |
| 37 | it("preserves registry ports", () => { |
| 38 | expect(toPullRef("registry.example.com:5000/repo:v1@sha256:abc")).toBe( |
| 39 | "registry.example.com:5000/repo@sha256:abc", |
| 40 | ); |
| 41 | }); |
| 42 | }); |
| 43 | |
| 44 | describe("rewriteLoopbackHosts", () => { |
| 45 | it("keeps the alias localhost-looking (Prisma's http/https gate)", () => { |
| 46 | expect(CONTAINER_LOOPBACK_ALIAS).toContain("localhost"); |
| 47 | }); |
| 48 | |
| 49 | it("rewrites URL hosts for every loopback form", () => { |
| 50 | expect(rewriteLoopbackHosts("http://localhost:3000/path?q=1")).toBe( |
| 51 | `http://${CONTAINER_LOOPBACK_ALIAS}:3000/path?q=1`, |
| 52 | ); |
| 53 | expect( |
| 54 | rewriteLoopbackHosts("postgres://user:pass@127.0.0.1:5432/db"), |
| 55 | ).toBe(`postgres://user:pass@${CONTAINER_LOOPBACK_ALIAS}:5432/db`); |
| 56 | expect(rewriteLoopbackHosts("http://0.0.0.0:8080")).toBe( |
| 57 | `http://${CONTAINER_LOOPBACK_ALIAS}:8080`, |
| 58 | ); |
| 59 | expect(rewriteLoopbackHosts("http://[::1]:8080")).toBe( |
| 60 | `http://${CONTAINER_LOOPBACK_ALIAS}:8080`, |
| 61 | ); |
| 62 | }); |
| 63 | |
| 64 | it("rewrites the local Prisma Postgres URL but not the api_key payload", () => { |
| 65 | const apiKey = "eyJkYXRhYmFzZVVybCI6InBvc3RncmVzOi8vbG9jYWxob3N0In0"; |
| 66 | expect( |
| 67 | rewriteLoopbackHosts( |
| 68 | `prisma+postgres://localhost:51216/?api_key=${apiKey}`, |
| 69 | ), |
| 70 | ).toBe( |
| 71 | `prisma+postgres://${CONTAINER_LOOPBACK_ALIAS}:51216/?api_key=${apiKey}`, |
| 72 | ); |
| 73 | }); |
| 74 | |
| 75 | it("rewrites every common connection-string shape, not just Prisma's", () => { |
| 76 | expect(rewriteLoopbackHosts("mysql://root@127.0.0.1:3306/app")).toBe( |
| 77 | `mysql://root@${CONTAINER_LOOPBACK_ALIAS}:3306/app`, |
| 78 | ); |
| 79 | expect(rewriteLoopbackHosts("redis://localhost:6379/0")).toBe( |
| 80 | `redis://${CONTAINER_LOOPBACK_ALIAS}:6379/0`, |
| 81 | ); |
| 82 | expect(rewriteLoopbackHosts("amqp://guest:guest@localhost:5672")).toBe( |
| 83 | `amqp://guest:guest@${CONTAINER_LOOPBACK_ALIAS}:5672`, |
| 84 | ); |
| 85 | // multi-host URI (every host is rewritten) |
| 86 | expect( |
| 87 | rewriteLoopbackHosts( |
| 88 | "mongodb://user:pass@localhost:27017,localhost:27018/db?replicaSet=rs0", |
| 89 | ), |
| 90 | ).toBe( |
| 91 | `mongodb://user:pass@${CONTAINER_LOOPBACK_ALIAS}:27017,${CONTAINER_LOOPBACK_ALIAS}:27018/db?replicaSet=rs0`, |
| 92 | ); |
| 93 | // JDBC-style compound scheme |
| 94 | expect(rewriteLoopbackHosts("jdbc:postgresql://localhost:5432/db")).toBe( |
| 95 | `jdbc:postgresql://${CONTAINER_LOOPBACK_ALIAS}:5432/db`, |
| 96 | ); |
| 97 | // Kafka-style broker list |
| 98 | expect(rewriteLoopbackHosts("localhost:9092,localhost:9093")).toBe( |
| 99 | `${CONTAINER_LOOPBACK_ALIAS}:9092,${CONTAINER_LOOPBACK_ALIAS}:9093`, |
| 100 | ); |
| 101 | // websocket + URL embedded in a JSON env value |
| 102 | expect(rewriteLoopbackHosts('{"ws":"ws://localhost:8080/socket"}')).toBe( |
| 103 | `{"ws":"ws://${CONTAINER_LOOPBACK_ALIAS}:8080/socket"}`, |
| 104 | ); |
| 105 | }); |
| 106 | |
| 107 | it("rewrites bare host values and DSN keyword form", () => { |
| 108 | expect(rewriteLoopbackHosts("localhost:5432")).toBe( |
| 109 | `${CONTAINER_LOOPBACK_ALIAS}:5432`, |
| 110 | ); |
| 111 | expect(rewriteLoopbackHosts("localhost")).toBe(CONTAINER_LOOPBACK_ALIAS); |
| 112 | expect( |
| 113 | rewriteLoopbackHosts("host=127.0.0.1 port=5432 sslmode=disable"), |
| 114 | ).toBe(`host=${CONTAINER_LOOPBACK_ALIAS} port=5432 sslmode=disable`); |
| 115 | }); |
| 116 | |
| 117 | it("leaves non-loopback hosts and lookalike substrings alone", () => { |
| 118 | expect(rewriteLoopbackHosts("https://db.example.com:5432")).toBe( |
| 119 | "https://db.example.com:5432", |
| 120 | ); |
| 121 | expect(rewriteLoopbackHosts("http://localhost.example.com/")).toBe( |
| 122 | "http://localhost.example.com/", |
| 123 | ); |
| 124 | expect(rewriteLoopbackHosts("http://notlocalhost:3000")).toBe( |
| 125 | "http://notlocalhost:3000", |
| 126 | ); |
| 127 | expect(rewriteLoopbackHosts("8080")).toBe("8080"); |
| 128 | }); |
| 129 | |
| 130 | it("leaves Neon and PlanetScale cloud URLs untouched", () => { |
| 131 | const neon = |
| 132 | "postgres://neondb_owner:secret@ep-cool-name-123456-pooler.us-east-1.aws.neon.tech/neondb?sslmode=require"; |
| 133 | const neonDirect = |
| 134 | "postgresql://neondb_owner:secret@ep-cool-name-123456.us-east-2.aws.neon.tech/neondb?sslmode=require&channel_binding=require"; |
| 135 | const planetscalePg = |
| 136 | "postgresql://user:secret@xxxx.pg.psdb.cloud:6432/postgres?sslmode=verify-full"; |
| 137 | const planetscaleMysql = |
| 138 | "mysql://user:secret@aws.connect.psdb.cloud:3306/db?sslaccept=strict"; |
| 139 | expect(rewriteLoopbackHosts(neon)).toBe(neon); |
| 140 | expect(rewriteLoopbackHosts(neonDirect)).toBe(neonDirect); |
| 141 | expect(rewriteLoopbackHosts(planetscalePg)).toBe(planetscalePg); |
| 142 | expect(rewriteLoopbackHosts(planetscaleMysql)).toBe(planetscaleMysql); |
| 143 | expect( |
| 144 | mergeContainerCreateEnv(["DATABASE_URL=" + neon], { |
| 145 | DATABASE_URL: neon, |
| 146 | }), |
| 147 | ).toEqual([`DATABASE_URL=${neon}`]); |
| 148 | }); |
| 149 | }); |
| 150 | |
| 151 | describe("mergeContainerCreateEnv", () => { |
| 152 | it("rewrites and replaces by name instead of appending a second value", () => { |
| 153 | expect( |
| 154 | mergeContainerCreateEnv( |
| 155 | [ |
| 156 | "PATH=/usr/bin", |
| 157 | "DATABASE_URL=postgres://postgres@127.0.0.1:5432/db", |
| 158 | ], |
| 159 | { |
| 160 | DATABASE_URL: "postgres://postgres@127.0.0.1:5432/db", |
| 161 | PPG_URL: "prisma+postgres://localhost:51216/?api_key=test", |
| 162 | }, |
| 163 | ), |
| 164 | ).toEqual([ |
| 165 | "PATH=/usr/bin", |
| 166 | `DATABASE_URL=postgres://postgres@${CONTAINER_LOOPBACK_ALIAS}:5432/db`, |
| 167 | `PPG_URL=prisma+postgres://${CONTAINER_LOOPBACK_ALIAS}:51216/?api_key=test`, |
| 168 | ]); |
| 169 | }); |
| 170 | |
| 171 | it("rewrites loopback hosts already present on the create body", () => { |
| 172 | expect( |
| 173 | mergeContainerCreateEnv( |
| 174 | ["DATABASE_URL=postgres://postgres@127.0.0.1:5432/db"], |
| 175 | undefined, |
| 176 | ), |
| 177 | ).toEqual([ |
| 178 | `DATABASE_URL=postgres://postgres@${CONTAINER_LOOPBACK_ALIAS}:5432/db`, |
| 179 | ]); |
| 180 | }); |
| 181 | |
| 182 | it("preserves flag-style entries that have no value", () => { |
| 183 | expect( |
| 184 | mergeContainerCreateEnv(["DEBUG", "FOO=bar"], { FOO: "baz" }), |
| 185 | ).toEqual(["DEBUG", "FOO=baz"]); |
| 186 | }); |
| 187 | }); |
| 188 | }); |
| 189 | |
| 190 | /** Records every spawned argv and pretends each command exited 0 with no output. */ |
| 191 | const spawned: Array<ReadonlyArray<string>> = []; |
| 192 | const SpawnerStub = Layer.succeed( |
| 193 | ChildProcessSpawner.ChildProcessSpawner, |
| 194 | ChildProcessSpawner.make((command) => { |
| 195 | if (command._tag === "StandardCommand") { |
| 196 | spawned.push([command.command, ...command.args]); |
| 197 | } |
| 198 | return Effect.succeed( |
| 199 | ChildProcessSpawner.makeHandle({ |
| 200 | pid: ChildProcessSpawner.ProcessId(1), |
| 201 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), |
| 202 | isRunning: Effect.succeed(false), |
| 203 | kill: () => Effect.void, |
| 204 | stdin: Sink.drain, |
| 205 | stdout: Stream.empty, |
| 206 | stderr: Stream.empty, |
| 207 | all: Stream.empty, |
| 208 | getInputFd: () => Sink.drain, |
| 209 | getOutputFd: () => Stream.empty, |
| 210 | unref: Effect.succeed(Effect.void), |
| 211 | }), |
| 212 | ); |
| 213 | }), |
| 214 | ); |
| 215 | |
| 216 | layer(Layer.provide(DockerLive, Layer.merge(NodeServices.layer, SpawnerStub)))( |
| 217 | (it) => { |
| 218 | it.effect("pull strips the tag from digest-pinned refs", () => |
| 219 | Effect.gen(function* () { |
| 220 | spawned.length = 0; |
| 221 | const docker = yield* Docker; |
| 222 | yield* docker.pull("alchemy-test:latest", { imageUri: PINNED }); |
| 223 | expect(spawned).toContainEqual([ |
| 224 | "docker", |
| 225 | "pull", |
| 226 | PINNED_WITHOUT_TAG, |
| 227 | "--platform", |
| 228 | "linux/amd64", |
| 229 | ]); |
| 230 | // only the pull ref is rewritten — the local tag alias keeps the original uri |
| 231 | expect(spawned).toContainEqual([ |
| 232 | "docker", |
| 233 | "tag", |
| 234 | PINNED, |
| 235 | "alchemy-test:latest", |
| 236 | ]); |
| 237 | }), |
| 238 | ); |
| 239 | |
| 240 | it.effect("pull passes refs without a digest through unchanged", () => |
| 241 | Effect.gen(function* () { |
| 242 | spawned.length = 0; |
| 243 | const docker = yield* Docker; |
| 244 | yield* docker.pull("alchemy-test:latest", { |
| 245 | imageUri: "rocicorp/zero:1.8.0", |
| 246 | }); |
| 247 | expect(spawned).toContainEqual([ |
| 248 | "docker", |
| 249 | "pull", |
| 250 | "rocicorp/zero:1.8.0", |
| 251 | "--platform", |
| 252 | "linux/amd64", |
| 253 | ]); |
| 254 | }), |
| 255 | ); |
| 256 | }, |
| 257 | ); |
| 258 | |
| 259 | /** |
| 260 | * A stub whose `docker image inspect` stdout is configurable per test, so |
| 261 | * `getWorkerdDockerConfiguration`'s inspect-before-pull check on the egress |
| 262 | * interceptor image can be exercised both ways (present vs absent locally). |
| 263 | * All other commands (`docker pull`, `docker tag`, …) still succeed with |
| 264 | * empty output, matching `SpawnerStub` above. |
| 265 | * |
| 266 | * `getWorkerdDockerConfiguration`'s pull/skip-pull decision runs on a |
| 267 | * `forkDetach` fiber that starts as soon as the `Docker` layer is built — |
| 268 | * i.e. before an `it.effect` body gets a chance to run, let alone reset a |
| 269 | * shared recording array. So each test below gets its OWN dedicated |
| 270 | * `spawned` array (returned alongside the layer) instead of resetting the |
| 271 | * top-level one mid-test, which would race the fiber's own spawns. |
| 272 | */ |
| 273 | const makeInspectStub = (inspectStdout: string) => { |
| 274 | const spawned: Array<ReadonlyArray<string>> = []; |
| 275 | const layer = Layer.succeed( |
| 276 | ChildProcessSpawner.ChildProcessSpawner, |
| 277 | ChildProcessSpawner.make((command) => { |
| 278 | if (command._tag === "StandardCommand") { |
| 279 | spawned.push([command.command, ...command.args]); |
| 280 | } |
| 281 | const isInspect = |
| 282 | command._tag === "StandardCommand" && |
| 283 | command.args[0] === "image" && |
| 284 | command.args[1] === "inspect"; |
| 285 | return Effect.succeed( |
| 286 | ChildProcessSpawner.makeHandle({ |
| 287 | pid: ChildProcessSpawner.ProcessId(1), |
| 288 | exitCode: Effect.succeed( |
| 289 | ChildProcessSpawner.ExitCode( |
| 290 | isInspect && inspectStdout === "" ? 1 : 0, |
| 291 | ), |
| 292 | ), |
| 293 | isRunning: Effect.succeed(false), |
| 294 | kill: () => Effect.void, |
| 295 | stdin: Sink.drain, |
| 296 | stdout: isInspect |
| 297 | ? Stream.make(new TextEncoder().encode(inspectStdout)) |
| 298 | : Stream.empty, |
| 299 | stderr: Stream.empty, |
| 300 | all: Stream.empty, |
| 301 | getInputFd: () => Sink.drain, |
| 302 | getOutputFd: () => Stream.empty, |
| 303 | unref: Effect.succeed(Effect.void), |
| 304 | }), |
| 305 | ); |
| 306 | }), |
| 307 | ); |
| 308 | return { layer, spawned }; |
| 309 | }; |
| 310 | |
| 311 | const present = makeInspectStub("sha256:deadbeef"); |
| 312 | layer( |
| 313 | Layer.provide(DockerLive, Layer.merge(NodeServices.layer, present.layer)), |
| 314 | )((it) => { |
| 315 | it.effect( |
| 316 | "skips the pull when the interceptor image is already present locally", |
| 317 | () => |
| 318 | Effect.gen(function* () { |
| 319 | const docker = yield* Docker; |
| 320 | yield* docker.getWorkerdDockerConfiguration; |
| 321 | expect(present.spawned).toContainEqual([ |
| 322 | "docker", |
| 323 | "image", |
| 324 | "inspect", |
| 325 | PINNED, |
| 326 | "--format", |
| 327 | "{{.Id}}", |
| 328 | ]); |
| 329 | expect(present.spawned.filter(([, verb]) => verb === "pull")).toEqual( |
| 330 | [], |
| 331 | ); |
| 332 | }), |
| 333 | ); |
| 334 | }); |
| 335 | |
| 336 | const absent = makeInspectStub(""); |
| 337 | layer(Layer.provide(DockerLive, Layer.merge(NodeServices.layer, absent.layer)))( |
| 338 | (it) => { |
| 339 | it.effect( |
| 340 | "pulls the interceptor image when it is not present locally", |
| 341 | () => |
| 342 | Effect.gen(function* () { |
| 343 | const docker = yield* Docker; |
| 344 | yield* docker.getWorkerdDockerConfiguration; |
| 345 | expect(absent.spawned).toContainEqual([ |
| 346 | "docker", |
| 347 | "image", |
| 348 | "inspect", |
| 349 | PINNED, |
| 350 | "--format", |
| 351 | "{{.Id}}", |
| 352 | ]); |
| 353 | // Exactly one pull, with no `--platform`: the interceptor is a |
| 354 | // host-side sidecar, so Docker must resolve the host-native variant |
| 355 | // of this multi-arch image. Forcing `linux/amd64` here runs it |
| 356 | // under emulation on arm64 hosts, where it exits 1 with |
| 357 | // `setsockoptint: protocol not available` before workerd can create |
| 358 | // the user container. |
| 359 | expect(absent.spawned.filter(([, verb]) => verb === "pull")).toEqual([ |
| 360 | ["docker", "pull", PINNED_WITHOUT_TAG], |
| 361 | ]); |
| 362 | }), |
| 363 | ); |
| 364 | }, |
| 365 | ); |
| 366 | |
| 367 | /** |
| 368 | * Spawner whose `docker <verb>` exits non-zero with `stderr`, so the tests |
| 369 | * below can assert that a failing docker command is reported rather than |
| 370 | * mistaken for a successful one. |
| 371 | */ |
| 372 | const makeFailingStub = (verb: string, stderr: string, exitCode = 1) => |
| 373 | Layer.succeed( |
| 374 | ChildProcessSpawner.ChildProcessSpawner, |
| 375 | ChildProcessSpawner.make((command) => { |
| 376 | const fails = |
| 377 | command._tag === "StandardCommand" && command.args[0] === verb; |
| 378 | return Effect.succeed( |
| 379 | ChildProcessSpawner.makeHandle({ |
| 380 | pid: ChildProcessSpawner.ProcessId(1), |
| 381 | exitCode: Effect.succeed( |
| 382 | ChildProcessSpawner.ExitCode(fails ? exitCode : 0), |
| 383 | ), |
| 384 | isRunning: Effect.succeed(false), |
| 385 | kill: () => Effect.void, |
| 386 | stdin: Sink.drain, |
| 387 | stdout: Stream.empty, |
| 388 | stderr: fails |
| 389 | ? Stream.make(new TextEncoder().encode(stderr)) |
| 390 | : Stream.empty, |
| 391 | all: Stream.empty, |
| 392 | getInputFd: () => Sink.drain, |
| 393 | getOutputFd: () => Stream.empty, |
| 394 | unref: Effect.succeed(Effect.void), |
| 395 | }), |
| 396 | ); |
| 397 | }), |
| 398 | ); |
| 399 | |
| 400 | describe("buildFailureHint", () => { |
| 401 | it("names the buildx plugin when docker only names the flag", () => { |
| 402 | expect(buildFailureHint("unknown flag: --load")).toContain("buildx"); |
| 403 | }); |
| 404 | |
| 405 | it("stays silent for ordinary build failures", () => { |
| 406 | expect( |
| 407 | buildFailureHint("ERROR: failed to solve: dockerfile parse error"), |
| 408 | ).toBeUndefined(); |
| 409 | }); |
| 410 | }); |
| 411 | |
| 412 | /** |
| 413 | * `run` reports a non-zero exit in its success channel, so a command whose |
| 414 | * failure matters has to check it. When `build` did not, a failed |
| 415 | * `docker build` looked like a success: no image was ever created and the |
| 416 | * only symptom was workerd looping on `Container exited while waiting for |
| 417 | * port 8080`, with the actual docker error absent from the logs entirely. |
| 418 | */ |
| 419 | layer( |
| 420 | Layer.provide( |
| 421 | DockerLive, |
| 422 | Layer.merge( |
| 423 | NodeServices.layer, |
| 424 | makeFailingStub("build", "unknown flag: --load"), |
| 425 | ), |
| 426 | ), |
| 427 | )((it) => { |
| 428 | it.effect("build fails when docker build exits non-zero", () => |
| 429 | Effect.gen(function* () { |
| 430 | const docker = yield* Docker; |
| 431 | const result = yield* Effect.result( |
| 432 | docker.build("alchemy-test:latest", { |
| 433 | dockerfile: `${import.meta.dirname}/fixtures/Dockerfile.build-failure`, |
| 434 | }), |
| 435 | ); |
| 436 | expect(result._tag).toBe("Failure"); |
| 437 | if (result._tag !== "Failure") return; |
| 438 | const error = result.failure as { |
| 439 | subtag: string; |
| 440 | hint?: string; |
| 441 | detail?: { exitCode: number; stderr: string }; |
| 442 | }; |
| 443 | expect(error.subtag).toBe("DockerBuildFailed"); |
| 444 | // The docker output the user needs is carried, not discarded. |
| 445 | expect(error.detail?.exitCode).toBe(1); |
| 446 | expect(error.detail?.stderr).toContain("unknown flag: --load"); |
| 447 | expect(error.hint).toContain("buildx"); |
| 448 | }), |
| 449 | ); |
| 450 | }); |
| 451 | |
| 452 | layer( |
| 453 | Layer.provide( |
| 454 | DockerLive, |
| 455 | Layer.merge( |
| 456 | NodeServices.layer, |
| 457 | makeFailingStub("tag", "Error response from daemon: no such image"), |
| 458 | ), |
| 459 | ), |
| 460 | )((it) => { |
| 461 | it.effect("pull fails when the follow-up docker tag exits non-zero", () => |
| 462 | Effect.gen(function* () { |
| 463 | const docker = yield* Docker; |
| 464 | const result = yield* Effect.result( |
| 465 | docker.pull("alchemy-test:latest", { imageUri: PINNED }), |
| 466 | ); |
| 467 | expect(result._tag).toBe("Failure"); |
| 468 | if (result._tag !== "Failure") return; |
| 469 | const error = result.failure as { |
| 470 | subtag: string; |
| 471 | detail?: { stderr: string }; |
| 472 | }; |
| 473 | expect(error.subtag).toBe("DockerTagFailed"); |
| 474 | expect(error.detail?.stderr).toContain("no such image"); |
| 475 | }), |
| 476 | ); |
| 477 | }); |