From 860eff6aa5fe82e7640bc17c4b4c91f7553716c0 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Mon, 12 Oct 2026 15:31:31 +0100 Subject: [PATCH] Include the build output when an image build fails A failed `fromDockerfile(...).build()` threw only "Failed to build image". The error now carries the daemon's error and the last 50 lines of build output, for the classic builder and BuildKit. Builds the daemon rejects before streaming now reject instead of never settling. --- docs/features/images.md | 2 + .../docker-with-failing-build/Dockerfile | 3 + .../clients/image/build-output.test.ts | 37 +++++++++ .../clients/image/build-output.ts | 71 +++++++++++++++++ .../clients/image/buildkit-trace.ts | 77 +++++++++++++++++++ .../clients/image/docker-image-client.ts | 31 ++++---- .../generic-container-builder.ts | 6 +- .../generic-container-dockerfile.test.ts | 18 +++++ .../generic-container.test.ts | 9 ++- 9 files changed, 236 insertions(+), 18 deletions(-) create mode 100644 packages/testcontainers/fixtures/docker/docker-with-failing-build/Dockerfile create mode 100644 packages/testcontainers/src/container-runtime/clients/image/build-output.test.ts create mode 100644 packages/testcontainers/src/container-runtime/clients/image/build-output.ts create mode 100644 packages/testcontainers/src/container-runtime/clients/image/buildkit-trace.ts diff --git a/docs/features/images.md b/docs/features/images.md index 7cfc18459..d73724745 100644 --- a/docs/features/images.md +++ b/docs/features/images.md @@ -24,6 +24,8 @@ const container = await GenericContainer .build("my-custom-image", { deleteOnExit: false }); ``` +If the build fails, the error includes the reason and the last lines of the build output. + ### With buildkit ```js diff --git a/packages/testcontainers/fixtures/docker/docker-with-failing-build/Dockerfile b/packages/testcontainers/fixtures/docker/docker-with-failing-build/Dockerfile new file mode 100644 index 000000000..8feda2388 --- /dev/null +++ b/packages/testcontainers/fixtures/docker/docker-with-failing-build/Dockerfile @@ -0,0 +1,3 @@ +FROM alpine:3.10 + +RUN cat /does-not-exist diff --git a/packages/testcontainers/src/container-runtime/clients/image/build-output.test.ts b/packages/testcontainers/src/container-runtime/clients/image/build-output.test.ts new file mode 100644 index 000000000..4686ecb91 --- /dev/null +++ b/packages/testcontainers/src/container-runtime/clients/image/build-output.test.ts @@ -0,0 +1,37 @@ +import { BuildOutput } from "./build-output"; + +describe("BuildOutput", () => { + it("should not report a failure when the build has no error", () => { + const buildOutput = new BuildOutput(); + + buildOutput.add(JSON.stringify({ stream: "Step 1/1 : FROM alpine\n" })); + + expect(buildOutput.getFailure()).toBeUndefined(); + }); + + it("should report the error with the build output", () => { + const buildOutput = new BuildOutput(); + + buildOutput.add(JSON.stringify({ stream: "Step 1/1 : RUN cat /does-not-exist" })); + buildOutput.add(JSON.stringify({ stream: "\n" })); + buildOutput.add(JSON.stringify({ stream: "\u001b[91mcat: can't open '/does-not-exist'\n\u001b[0m" })); + buildOutput.add(JSON.stringify({ error: "exit code: 1", errorDetail: { message: "exit code: 1" } })); + + expect(buildOutput.getFailure()).toBe( + "exit code: 1\n\nBuild output:\nStep 1/1 : RUN cat /does-not-exist\ncat: can't open '/does-not-exist'" + ); + }); + + it("should only keep the last lines of the build output", () => { + const buildOutput = new BuildOutput(); + + for (let i = 1; i <= 120; i++) { + buildOutput.add(JSON.stringify({ stream: `line ${i}\n` })); + } + buildOutput.add(JSON.stringify({ error: "exit code: 1" })); + + const failure = buildOutput.getFailure()?.split("\n"); + expect(failure?.slice(0, 4)).toEqual(["exit code: 1", "", "Last 50 lines of build output:", "line 71"]); + expect(failure).toHaveLength(53); + }); +}); diff --git a/packages/testcontainers/src/container-runtime/clients/image/build-output.ts b/packages/testcontainers/src/container-runtime/clients/image/build-output.ts new file mode 100644 index 000000000..004f859e1 --- /dev/null +++ b/packages/testcontainers/src/container-runtime/clients/image/build-output.ts @@ -0,0 +1,71 @@ +import { stripVTControlCharacters } from "node:util"; +import { decodeBuildkitTrace } from "./buildkit-trace"; + +const MAX_LINES = 50; + +type BuildEvent = { + stream?: unknown; + error?: unknown; + errorDetail?: { message?: unknown }; + id?: unknown; + aux?: unknown; +}; + +export class BuildOutput { + private readonly lines: string[] = []; + private partialLine = ""; + private truncated = false; + private error?: string; + + public add(line: string): void { + const event = this.parse(line); + const error = event.errorDetail?.message ?? event.error; + + if (typeof error === "string") { + this.error = error; + } else if (typeof event.stream === "string") { + this.append(event.stream); + } else if (event.id === "moby.buildkit.trace" && typeof event.aux === "string") { + this.append(decodeBuildkitTrace(event.aux)); + } + } + + public getFailure(): string | undefined { + if (this.error === undefined) { + return undefined; + } + + this.append("\n"); + if (this.lines.length === 0) { + return this.error; + } + + const heading = this.truncated ? `Last ${MAX_LINES} lines of build output:` : "Build output:"; + return [this.error, "", heading, ...this.lines].join("\n"); + } + + private parse(line: string): BuildEvent { + try { + return JSON.parse(line) ?? {}; + } catch { + return {}; + } + } + + private append(text: string): void { + const lines = (this.partialLine + text).split("\n"); + this.partialLine = lines.pop() ?? ""; + + for (const line of lines) { + const trimmedLine = stripVTControlCharacters(line).trimEnd(); + if (trimmedLine.length > 0) { + this.lines.push(trimmedLine); + } + } + + if (this.lines.length > MAX_LINES) { + this.lines.splice(0, this.lines.length - MAX_LINES); + this.truncated = true; + } + } +} diff --git a/packages/testcontainers/src/container-runtime/clients/image/buildkit-trace.ts b/packages/testcontainers/src/container-runtime/clients/image/buildkit-trace.ts new file mode 100644 index 000000000..7f641f652 --- /dev/null +++ b/packages/testcontainers/src/container-runtime/clients/image/buildkit-trace.ts @@ -0,0 +1,77 @@ +type Field = { number: number; value: number | Buffer }; + +const STATUS_VERTEXES = 1; +const STATUS_LOGS = 3; +const VERTEX_NAME = 3; +const VERTEX_STARTED = 5; +const VERTEX_COMPLETED = 6; +const VERTEX_LOG_MSG = 4; + +// BuildKit reports progress as base64 encoded protobuf StatusResponse messages: +// https://github.com/moby/buildkit/blob/master/api/services/control/control.proto +export function decodeBuildkitTrace(trace: string): string { + let output = ""; + + for (const status of readFields(Buffer.from(trace, "base64"))) { + if (!Buffer.isBuffer(status.value)) { + continue; + } + const fields = readFields(status.value); + const find = (number: number) => fields.find((field) => field.number === number)?.value; + + if (status.number === STATUS_VERTEXES) { + const name = find(VERTEX_NAME); + if (Buffer.isBuffer(name) && find(VERTEX_STARTED) !== undefined && find(VERTEX_COMPLETED) === undefined) { + output += `${name.toString("utf-8")}\n`; + } + } else if (status.number === STATUS_LOGS) { + const msg = find(VERTEX_LOG_MSG); + if (Buffer.isBuffer(msg)) { + output += msg.toString("utf-8"); + } + } + } + + return output; +} + +function readFields(buffer: Buffer): Field[] { + const fields: Field[] = []; + let offset = 0; + + const readVarint = () => { + let value = 0; + let multiplier = 1; + while (offset < buffer.length) { + const byte = buffer[offset++]; + value += (byte & 0x7f) * multiplier; + multiplier *= 0x80; + if ((byte & 0x80) === 0) { + break; + } + } + return value; + }; + + while (offset < buffer.length) { + const tag = readVarint(); + const number = Math.floor(tag / 8); + const wireType = tag % 8; + + if (wireType === 0) { + fields.push({ number, value: readVarint() }); + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 2) { + const length = readVarint(); + fields.push({ number, value: buffer.subarray(offset, offset + length) }); + offset += length; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + + return fields; +} diff --git a/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts index 4c26d12d9..56e12eb8f 100644 --- a/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts @@ -9,6 +9,7 @@ import tar from "tar-fs"; import { buildLog, log, pullLog } from "../../../common"; import { getAuthConfig } from "../../auth/get-auth-config"; import type { ImageName } from "../../image-name"; +import { BuildOutput } from "./build-output"; import type { ImageClient } from "./image-client"; export class DockerImageClient implements ImageClient { @@ -25,20 +26,24 @@ export class DockerImageClient implements ImageClient { log.debug(`Building image "${opts.t}" with context "${context}"...`); const tarPackOptions = await this.createTarPackOptions(context, opts.dockerfile ?? "Dockerfile"); const tarStream = tar.pack(context, tarPackOptions); - await new Promise((resolve) => { - this.dockerode - .buildImage(tarStream, opts) - .then((stream) => byline(stream)) - .then((stream) => { - stream.setEncoding("utf-8"); - stream.on("data", (line) => { - if (buildLog.enabled()) { - buildLog.trace(line, { imageName: opts.t }); - } - }); - stream.on("end", () => resolve()); - }); + const stream = await this.dockerode.buildImage(tarStream, opts); + const buildOutput = new BuildOutput(); + await new Promise((resolve, reject) => { + stream.on("error", reject); + const lines = byline(stream); + lines.setEncoding("utf-8"); + lines.on("data", (line) => { + if (buildLog.enabled()) { + buildLog.trace(line, { imageName: opts.t }); + } + buildOutput.add(line); + }); + lines.on("end", () => resolve()); }); + const failure = buildOutput.getFailure(); + if (failure !== undefined) { + throw new Error(failure); + } log.debug(`Built image "${opts.t}" with context "${context}"`); } catch (err) { log.error(`Failed to build image: ${err}`); diff --git a/packages/testcontainers/src/generic-container/generic-container-builder.ts b/packages/testcontainers/src/generic-container/generic-container-builder.ts index d06a7867e..21be924ba 100644 --- a/packages/testcontainers/src/generic-container/generic-container-builder.ts +++ b/packages/testcontainers/src/generic-container/generic-container-builder.ts @@ -104,7 +104,11 @@ export class GenericContainerBuilder { buildOptions.pull = true; } - await client.image.build(this.context, buildOptions); + try { + await client.image.build(this.context, buildOptions); + } catch (err) { + throw new Error(`Failed to build image: ${err instanceof Error ? err.message : err}`, { cause: err }); + } const container = new GenericContainer(imageName.string); if (!(await client.image.exists(imageName))) { diff --git a/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts b/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts index 37ae3e42f..73484566e 100644 --- a/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts @@ -36,6 +36,24 @@ describe("GenericContainer Dockerfile", { timeout: 180_000 }, () => { }); } + it("should include the build output when the build fails", async () => { + const context = path.resolve(fixtures, "docker-with-failing-build"); + + await expect(GenericContainer.fromDockerfile(context).build()).rejects.toThrow( + /^Failed to build image: .*No such file or directory/s + ); + }); + + if (!process.env.CI_PODMAN) { + it("should include the build output when the build fails with buildkit", async () => { + const context = path.resolve(fixtures, "docker-with-failing-build"); + + await expect(GenericContainer.fromDockerfile(context).withBuildkit().build()).rejects.toThrow( + /^Failed to build image: .*No such file or directory/s + ); + }); + } + it("should have a session ID label to be cleaned up by the Reaper", async () => { const context = path.resolve(fixtures, "docker"); const imageName = `${uuidGen.nextUuid()}:${uuidGen.nextUuid()}`; diff --git a/packages/testcontainers/src/generic-container/generic-container.test.ts b/packages/testcontainers/src/generic-container/generic-container.test.ts index eccf94c0b..d3e1f5a83 100644 --- a/packages/testcontainers/src/generic-container/generic-container.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container.test.ts @@ -692,10 +692,11 @@ describe("GenericContainer", { timeout: 180_000 }, () => { expect(container.getHostname()).toEqual("hostname"); }); - // failing to build an image hangs within the DockerImageClient.build method, - // that change might be larger so leave it out of this commit but skip the failing test - it.skip("should throw an error for a target stage that does not exist", async () => { + it("should throw an error for a target stage that does not exist", async () => { const context = path.resolve(fixtures, "docker-multi-stage"); - await GenericContainer.fromDockerfile(context).withTarget("invalid").build(); + + await expect(GenericContainer.fromDockerfile(context).withTarget("invalid").build()).rejects.toThrow( + "Failed to build image: " + ); }); });