1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it, layer } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Sink from "effect/Sink";
6import * as Stream from "effect/Stream";
7import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
8import {
9 buildFailureHint,
10 CONTAINER_LOOPBACK_ALIAS,
11 Docker,
12 DockerLive,
13 mergeContainerCreateEnv,
14 rewriteLoopbackHosts,
15 toPullRef,
16} from "../Docker.ts";
17
18const PINNED =
19 "cloudflare/proxy-everything:3cb1195@sha256:0ef6716c52430096900b150d84a3302057d6cd2319dae7987128c85d0733e3c8";
20const PINNED_WITHOUT_TAG =
21 "cloudflare/proxy-everything@sha256:0ef6716c52430096900b150d84a3302057d6cd2319dae7987128c85d0733e3c8";
22
23describe("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. */
191const spawned: Array<ReadonlyArray<string>> = [];
192const 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
216layer(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 */
273const 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
311const present = makeInspectStub("sha256:deadbeef");
312layer(
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
336const absent = makeInspectStub("");
337layer(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 */
372const 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
400describe("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 */
419layer(
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
452layer(
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});