1import { Docker, DockerLive } from "@/Docker";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, expect, layer } from "alchemy-test";
4import { PlatformError, SystemError } from "effect/PlatformError";
5import { classifyDockerRegistryError } from "@/Docker/RegistryError.ts";
6import * as Effect from "effect/Effect";
7import * as ConfigProvider from "effect/ConfigProvider";
8import * as Redacted from "effect/Redacted";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Path from "effect/Path";
12import * as Sink from "effect/Sink";
13import * as Stream from "effect/Stream";
14import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
15
16const describe = layer(Layer.provideMerge(DockerLive, NodeServices.layer));
17
18describe("Docker.materialize", (it) => {
19 it.effect(
20 "materializes a Dockerfile in the target directory",
21 () =>
22 Effect.gen(function* () {
23 const fs = yield* FileSystem.FileSystem;
24 const path = yield* Path.Path;
25 const docker = yield* Docker;
26 const root = yield* fs.makeTempDirectoryScoped({
27 prefix: "alchemy-docker-ctx-",
28 });
29 const ctx = path.join(root, "ctx");
30 yield* docker.materialize({
31 context: ctx,
32 dockerfile: "FROM scratch\n",
33 files: [],
34 });
35 const dockerfile = path.join(ctx, "Dockerfile");
36 expect(yield* fs.exists(dockerfile)).toBe(true);
37 expect(yield* fs.readFileString(dockerfile)).toBe("FROM scratch\n");
38 }),
39 { tags: ["unit", "provider:docker", "local"] },
40 );
41
42 it.effect(
43 "writes nested context files",
44 () =>
45 Effect.gen(function* () {
46 const fs = yield* FileSystem.FileSystem;
47 const path = yield* Path.Path;
48 const docker = yield* Docker;
49 const root = yield* fs.makeTempDirectoryScoped({
50 prefix: "alchemy-docker-path-",
51 });
52 const ctx = path.join(root, "ctx");
53 yield* docker.materialize({
54 context: ctx,
55 dockerfile: "FROM scratch\n",
56 files: [{ path: "nested/hello.txt", content: "hi" }],
57 });
58 expect(
59 yield* fs.readFileString(path.join(ctx, "nested", "hello.txt")),
60 ).toBe("hi");
61 }),
62 { tags: ["unit", "provider:docker", "local"] },
63 );
64});
65
66describe("Docker registry errors", (it) => {
67 for (const [description, tag] of [
68 [
69 "ERROR: failed to build: failed to solve: failed to push registry.example/image:latest: unknown: blob unknown to registry",
70 "DockerRegistryBlobUnknown",
71 ],
72 [
73 "#7 ERROR: failed to push: unknown: blob unknown to registry\n------\nERROR: failed to build: failed to solve: failed to push registry.example/image:latest: unknown: blob unknown to registry\n\nView build details: docker-desktop://dashboard/build/builder/node/build-id\n",
74 "DockerRegistryBlobUnknown",
75 ],
76 [
77 "Command exited with code 1: blob unknown to registry",
78 "DockerRegistryBlobUnknown",
79 ],
80 [
81 "ERROR: unexpected status from HEAD request to https://registry.example/v2/image/blobs/sha256:abc: 503 Service Unavailable",
82 "DockerRegistryUnavailable",
83 ],
84 ["ERROR: failed to push: 502 Bad Gateway", "DockerRegistryUnavailable"],
85 ["ERROR: unexpected status: 401 Unauthorized", "PlatformError"],
86 ["ERROR: unexpected status: 403 Forbidden", "PlatformError"],
87 ["ERROR: unexpected status: 400 Bad Request", "PlatformError"],
88 ["ERROR: failed to solve: process exited with code 1", "PlatformError"],
89 [
90 "ERROR: unexpected status from HEAD request to https://registry.example/v2/500/blobs/sha256:abc: 403 Forbidden",
91 "PlatformError",
92 ],
93 [
94 'ERROR: failed to solve: process "/bin/sh -c echo 503 Service Unavailable && exit 1" did not complete successfully: exit code: 1',
95 "PlatformError",
96 ],
97 [
98 "#7 RUN echo 'blob unknown to registry'\nERROR: process exited with code 1",
99 "PlatformError",
100 ],
101 [
102 "#7 RUN echo '503 Service Unavailable'\nERROR: process exited with code 1",
103 "PlatformError",
104 ],
105 ] as const) {
106 it.effect(
107 `classifies ${description}`,
108 () =>
109 Effect.sync(() => {
110 const error = new PlatformError(
111 new SystemError({
112 _tag: "Unknown",
113 module: "Docker",
114 method: "buildx.build",
115 description,
116 }),
117 );
118 const classified = classifyDockerRegistryError(error);
119 expect(classified._tag).toBe(tag);
120 if (classified._tag === "PlatformError") {
121 expect(classified).toBe(error);
122 } else {
123 expect(classified.cause).toBe(error);
124 expect(classified.message).toBe(error.message);
125 }
126 }),
127 {
128 tags: ["unit", "provider:docker", "provider:docker:registry", "local"],
129 },
130 );
131 }
132});
133
134/**
135 * A `docker` CLI stand-in that answers `buildx version` with the given
136 * plugin version (or fails it like a missing plugin when `undefined`),
137 * records every other invocation (args + env), and exits 0.
138 */
139const fakeDocker = (buildxVersion: string | undefined) => {
140 const calls: Array<{
141 args: ReadonlyArray<string>;
142 env: Record<string, string | undefined>;
143 }> = [];
144 const encode = (text: string) => new TextEncoder().encode(text);
145 const spawner = ChildProcessSpawner.make((command) =>
146 Effect.gen(function* () {
147 assert(command._tag === "StandardCommand");
148 const probe =
149 command.args[0] === "buildx" && command.args[1] === "version";
150 if (!probe) {
151 calls.push({ args: command.args, env: command.options.env ?? {} });
152 }
153 const missing = probe && buildxVersion === undefined;
154 const stdout =
155 probe && !missing
156 ? `github.com/docker/buildx ${buildxVersion} 503f948aadbddb6de3ec5581f766e1d27f6975a1\n`
157 : "";
158 const stderr = missing
159 ? "docker: 'buildx' is not a docker command.\n"
160 : "";
161 return ChildProcessSpawner.makeHandle({
162 pid: ChildProcessSpawner.ProcessId(1),
163 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(missing ? 1 : 0)),
164 isRunning: Effect.succeed(false),
165 kill: () => Effect.void,
166 stdin: Sink.drain,
167 stdout: Stream.make(encode(stdout)),
168 stderr: Stream.make(encode(stderr)),
169 all: Stream.make(encode(stdout + stderr)),
170 getInputFd: () => Sink.drain,
171 getOutputFd: () => Stream.empty,
172 unref: Effect.succeed(Effect.void),
173 });
174 }),
175 );
176 return {
177 calls,
178 // `fresh` sidesteps the describe-level memoized `DockerLive` so the
179 // fake spawner is actually wired in.
180 layer: Layer.fresh(DockerLive).pipe(
181 Layer.provide(
182 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
183 ),
184 ),
185 };
186};
187
188const registry = {
189 server: "registry.invalid",
190 username: "publisher",
191 password: Redacted.make("DESTINATION_SECRET_SENTINEL"),
192};
193
194describe("Docker.image", (it) => {
195 it.effect(
196 "exports straight to the registry on Buildx >= 0.26",
197 () =>
198 Effect.gen(function* () {
199 const fake = fakeDocker("v0.26.1");
200 yield* Effect.gen(function* () {
201 const docker = yield* Docker;
202 yield* docker.image.build(
203 {
204 context: "/ctx",
205 tag: [
206 "registry.invalid/app:1",
207 "registry.invalid/app:buildcache",
208 ],
209 platform: "linux/amd64",
210 },
211 undefined,
212 registry,
213 );
214 }).pipe(Effect.provide(fake.layer));
215 expect(fake.calls).toHaveLength(1);
216 const [build] = fake.calls;
217 expect(build!.args.slice(0, 3)).toEqual(["buildx", "build", "--push"]);
218 expect(build!.args).toContain("/ctx");
219 expect(build!.args).toContain("registry.invalid/app:1");
220 expect(build!.args).toContain("registry.invalid/app:buildcache");
221 expect(build!.args.filter((arg) => arg === "--tag")).toHaveLength(2);
222 expect(build!.env.DOCKER_CONFIG).toBeUndefined();
223 const auth = JSON.parse(build!.env.DOCKER_AUTH_CONFIG!) as {
224 auths: Record<string, { auth: string }>;
225 };
226 expect(auth.auths["registry.invalid"]!.auth).toBe(
227 Buffer.from("publisher:DESTINATION_SECRET_SENTINEL").toString(
228 "base64",
229 ),
230 );
231 }),
232 { tags: ["unit", "provider:docker", "local"] },
233 );
234
235 for (const version of ["v0.23.0-desktop.1", "v0.25.0"]) {
236 it.effect(
237 `builds locally then pushes on Buildx ${version}`,
238 () =>
239 Effect.gen(function* () {
240 const fs = yield* FileSystem.FileSystem;
241 const path = yield* Path.Path;
242 const fake = fakeDocker(version);
243 yield* Effect.gen(function* () {
244 const docker = yield* Docker;
245 yield* docker.image.build(
246 {
247 context: "/ctx",
248 tag: [
249 "registry.invalid/app:1",
250 "registry.invalid/app:buildcache",
251 ],
252 platform: "linux/amd64",
253 },
254 undefined,
255 registry,
256 );
257 }).pipe(Effect.provide(fake.layer));
258 expect(fake.calls).toHaveLength(3);
259 const [build, push, cachePush] = fake.calls;
260 // `--load` so non-loading (docker-container) builders still land the
261 // image in the local store for the follow-up push.
262 expect(build!.args.slice(0, 3)).toEqual([
263 "buildx",
264 "build",
265 "--load",
266 ]);
267 expect(build!.args).toContain("/ctx");
268 expect(build!.args).toContain("--platform");
269 expect(build!.args).not.toContain("--push");
270 expect(build!.env.DOCKER_AUTH_CONFIG).toBeUndefined();
271 expect(build!.env.DOCKER_CONFIG).toBeUndefined();
272 expect(push!.args).toEqual([
273 "push",
274 "--platform",
275 "linux/amd64",
276 "registry.invalid/app:1",
277 ]);
278 expect(push!.env.DOCKER_AUTH_CONFIG).toBeUndefined();
279 expect(cachePush!.args).toEqual([
280 "push",
281 "--platform",
282 "linux/amd64",
283 "registry.invalid/app:buildcache",
284 ]);
285 // The isolated config is written under a temp dir for the push only
286 // and removed once the push scope closes.
287 const dir = push!.env.DOCKER_CONFIG;
288 assert(dir !== undefined);
289 expect(path.basename(dir)).toMatch(/^alchemy-docker-/);
290 expect(yield* fs.exists(dir)).toBe(false);
291 }),
292 { tags: ["unit", "provider:docker", "local"] },
293 );
294 }
295
296 it.effect(
297 "falls back to build + push when Buildx is not installed",
298 () =>
299 Effect.gen(function* () {
300 const fake = fakeDocker(undefined);
301 yield* Effect.gen(function* () {
302 const docker = yield* Docker;
303 yield* docker.image.build(
304 { context: "/ctx", tag: "registry.invalid/app:1" },
305 undefined,
306 registry,
307 );
308 }).pipe(Effect.provide(fake.layer));
309 expect(fake.calls.map((call) => call.args.slice(0, 2))).toEqual([
310 ["image", "build"],
311 ["push", "registry.invalid/app:1"],
312 ]);
313 }),
314 { tags: ["unit", "provider:docker", "local"] },
315 );
316
317 it.effect(
318 "builds without registry credentials via image build",
319 () =>
320 Effect.gen(function* () {
321 const fake = fakeDocker("v0.26.1");
322 yield* Effect.gen(function* () {
323 const docker = yield* Docker;
324 yield* docker.image.build({ context: "/ctx", tag: "local/app:1" });
325 }).pipe(Effect.provide(fake.layer));
326 expect(fake.calls).toHaveLength(1);
327 expect(fake.calls[0]!.args.slice(0, 2)).toEqual(["image", "build"]);
328 expect(fake.calls[0]!.env.DOCKER_AUTH_CONFIG).toBeUndefined();
329 }),
330 { tags: ["unit", "provider:docker", "local"] },
331 );
332
333 for (const [name, auth] of [
334 [
335 "invalid JSON",
336 '{"auths":{"source.invalid":{"auth":"AUTH_SECRET_SENTINEL"}},',
337 ],
338 [
339 "invalid base64",
340 '{"auths":{"source.invalid":{"auth":"AUTH_SECRET_SENTINEL!"}}}',
341 ],
342 [
343 "missing credential separator",
344 '{"auths":{"source.invalid":{"auth":"QVVUSF9TRUNSRVRfU0VOVElORUw="}}}',
345 ],
346 ]) {
347 it.effect(
348 `rejects ${name} without exposing registry credentials`,
349 () =>
350 Effect.gen(function* () {
351 const fake = fakeDocker("v0.26.1");
352 const result = yield* Effect.gen(function* () {
353 const docker = yield* Docker;
354 return yield* docker.image
355 .build(
356 {
357 context: "/ctx",
358 tag: "registry.invalid/invalid-auth:latest",
359 },
360 undefined,
361 registry,
362 )
363 .pipe(Effect.flip);
364 }).pipe(Effect.provide(fake.layer));
365 assert(result._tag === "PlatformError");
366 expect(result.reason._tag).toBe("InvalidData");
367 expect(result.reason.description).toContain("DOCKER_AUTH_CONFIG");
368 expect(fake.calls).toHaveLength(0);
369 const serialized = yield* Effect.sync(() => JSON.stringify(result));
370 expect(serialized).not.toContain("AUTH_SECRET_SENTINEL");
371 expect(serialized).not.toContain("QVVUSF9TRUNSRVRfU0VOVElORUw=");
372 expect(serialized).not.toContain("DESTINATION_SECRET_SENTINEL");
373 }).pipe(
374 Effect.provide(
375 ConfigProvider.layer(
376 ConfigProvider.fromUnknown({ DOCKER_AUTH_CONFIG: auth }),
377 ),
378 ),
379 ),
380 { tags: ["unit", "provider:docker", "local"] },
381 );
382 }
383
384 it.effect(
385 "builds a minimal image with content Dockerfile",
386 () =>
387 Effect.gen(function* () {
388 const fs = yield* FileSystem.FileSystem;
389 const path = yield* Path.Path;
390 const docker = yield* Docker;
391 const tag = "alchemy-docker-test:minimal";
392 yield* Effect.addFinalizer(() =>
393 docker.image.remove(tag, true).pipe(
394 Effect.catchReason("PlatformError", "NotFound", () => Effect.void),
395 Effect.ignore,
396 ),
397 );
398 const root = yield* fs.makeTempDirectoryScoped({
399 prefix: "alchemy-docker-build-",
400 });
401 const ctx = path.join(root, "ctx");
402 yield* docker.materialize({
403 context: ctx,
404 dockerfile: [
405 "FROM alpine:3.19",
406 "RUN echo ok > /tmp/ok.txt",
407 'CMD ["cat", "/tmp/ok.txt"]',
408 "",
409 ].join("\n"),
410 files: [],
411 });
412 yield* docker.image.build({ tag, context: ctx });
413 const inspect = yield* docker.image.inspect(tag);
414 expect(inspect.Id.length).toBeGreaterThan(0);
415 }),
416 { tags: ["unit", "provider:docker", "local"] },
417 );
418
419 it.effect(
420 "passes --platform and --build-arg",
421 () =>
422 Effect.gen(function* () {
423 const fs = yield* FileSystem.FileSystem;
424 const path = yield* Path.Path;
425 const docker = yield* Docker;
426 const tag = "alchemy-docker-test:args";
427 yield* Effect.addFinalizer(() =>
428 docker.image.remove(tag, true).pipe(
429 Effect.catchReason("PlatformError", "NotFound", () => Effect.void),
430 Effect.ignore,
431 ),
432 );
433 const root = yield* fs.makeTempDirectoryScoped({
434 prefix: "alchemy-docker-build-",
435 });
436 const ctx = path.join(root, "ctx");
437 yield* docker.materialize({
438 context: ctx,
439 dockerfile: [
440 "FROM alpine:3.19",
441 "ARG FOO=default",
442 'RUN echo "$FOO" > /out.txt',
443 "",
444 ].join("\n"),
445 files: [],
446 });
447 yield* docker.image.build({
448 tag,
449 context: ctx,
450 platform: "linux/amd64",
451 "build-arg": { FOO: "from-arg" },
452 });
453 const out = yield* docker.run(["run", "--rm", tag, "cat", "/out.txt"]);
454 expect(out.stdout.trim()).toBe("from-arg");
455 }),
456 { tags: ["unit", "provider:docker", "local"] },
457 );
458
459 it.effect(
460 "respects multi-stage --target",
461 () =>
462 Effect.gen(function* () {
463 const fs = yield* FileSystem.FileSystem;
464 const path = yield* Path.Path;
465 const docker = yield* Docker;
466 const tag = "alchemy-docker-test:target";
467 yield* Effect.addFinalizer(() =>
468 docker.image.remove(tag, true).pipe(
469 Effect.catchReason("PlatformError", "NotFound", () => Effect.void),
470 Effect.ignore,
471 ),
472 );
473 const root = yield* fs.makeTempDirectoryScoped({
474 prefix: "alchemy-docker-build-",
475 });
476 const ctx = path.join(root, "ctx");
477 yield* docker.materialize({
478 context: ctx,
479 dockerfile: [
480 "FROM alpine:3.19 AS base",
481 "RUN echo base > /stage.txt",
482 "",
483 "FROM alpine:3.19 AS secondary",
484 "RUN echo secondary > /stage.txt",
485 "",
486 ].join("\n"),
487 files: [],
488 });
489 yield* docker.image.build({ tag, context: ctx, target: "secondary" });
490 const out = yield* docker.run([
491 "run",
492 "--rm",
493 tag,
494 "cat",
495 "/stage.txt",
496 ]);
497 expect(out.stdout.trim()).toBe("secondary");
498 }),
499 { tags: ["unit", "provider:docker", "local"] },
500 );
501});