| 1 | import { Docker, DockerLive } from "@/Docker"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { assert, expect, layer } from "alchemy-test"; |
| 4 | import { PlatformError, SystemError } from "effect/PlatformError"; |
| 5 | import { classifyDockerRegistryError } from "@/Docker/RegistryError.ts"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 8 | import * as Redacted from "effect/Redacted"; |
| 9 | import * as FileSystem from "effect/FileSystem"; |
| 10 | import * as Layer from "effect/Layer"; |
| 11 | import * as Path from "effect/Path"; |
| 12 | import * as Sink from "effect/Sink"; |
| 13 | import * as Stream from "effect/Stream"; |
| 14 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 15 | |
| 16 | const describe = layer(Layer.provideMerge(DockerLive, NodeServices.layer)); |
| 17 | |
| 18 | describe("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 | |
| 66 | describe("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 | */ |
| 139 | const 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 | |
| 188 | const registry = { |
| 189 | server: "registry.invalid", |
| 190 | username: "publisher", |
| 191 | password: Redacted.make("DESTINATION_SECRET_SENTINEL"), |
| 192 | }; |
| 193 | |
| 194 | describe("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 | }); |