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 ba1dcc602..0217ea0e2 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 @@ -10,6 +10,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 { @@ -26,20 +27,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 = Readable.from(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: " + ); }); });