Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/features/images.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM alpine:3.10

RUN cat /does-not-exist
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<void>((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<void>((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}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: "
);
});
});
Loading