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
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe("MSSQLServerContainer", { timeout: 180_000 }, () => {
it("should throw error with invalid password", async () => {
const container = new MSSQLServerContainer(IMAGE).acceptLicense().withPassword("password");
await expect(container.start()).rejects.toThrow(
Error('Log stream ended and message "/.*Recovery is complete.*/" was not received')
'Log stream ended and message "/.*Recovery is complete.*/" was not received'
);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PassThrough, type Readable } from "node:stream";
import { text } from "node:stream/consumers";
import type Dockerode from "dockerode";
import { DockerContainerClient } from "./docker-container-client";

Expand All @@ -22,6 +23,41 @@ describe("DockerContainerClient", () => {

await vi.waitFor(() => expect(actualLogStream.destroyed).toBe(true));
});

it("should end the log stream after the current logs when not following", async () => {
const container = {
id: "container-id",
logs: vi.fn(async () => Buffer.from("first line\nsecond line\n")),
};
const dockerode = {
modem: {
demuxStream: (raw: Readable, stdout: PassThrough) => raw.on("data", (chunk) => stdout.write(chunk)),
},
} as unknown as Dockerode;
const client = new DockerContainerClient(dockerode);

const stream = await client.logs(container as unknown as Dockerode.Container, { follow: false });

expect(await text(stream)).toBe("first line\nsecond line\n");
expect(container.logs).toHaveBeenCalledWith(expect.objectContaining({ follow: false }));
});

it("should return logs that docker-modem parsed as JSON when not following", async () => {
const container = {
id: "container-id",
logs: vi.fn(async () => ({ level: "fatal" })),
};
const dockerode = {
modem: {
demuxStream: (raw: Readable, stdout: PassThrough) => raw.on("data", (chunk) => stdout.write(chunk)),
},
} as unknown as Dockerode;
const client = new DockerContainerClient(dockerode);

const stream = await client.logs(container as unknown as Dockerode.Container, { follow: false });

expect(await text(stream)).toBe('{"level":"fatal"}');
});
});

describe("exec", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { IncomingMessage } from "node:http";
import { type Duplex, PassThrough, type Readable } from "node:stream";
import { type Duplex, PassThrough, Readable } from "node:stream";
import { finished } from "node:stream/promises";
import type Dockerode from "dockerode";
import type {
Expand Down Expand Up @@ -176,18 +176,22 @@ export class DockerContainerClient implements ContainerClient {
const proxyStream = new PassThrough();
proxyStream.setEncoding("utf8");

container
.logs({
follow: true,
stdout: true,
stderr: true,
tail: opts?.tail ?? -1,
since: opts?.since ?? 0,
})
.then(async (stream) => {
const actualLogStream = stream as IncomingMessage;
actualLogStream.socket?.unref();

const logsOpts = { stdout: true, stderr: true, tail: opts?.tail ?? -1, since: opts?.since ?? 0 };
// docker-modem parses a non-streamed body that is valid JSON, which unframed (TTY) logs can be.
const toBuffer = (logs: unknown) => (Buffer.isBuffer(logs) ? logs : Buffer.from(JSON.stringify(logs)));
const logStream: Promise<Readable> =
opts?.follow === false
? container
.logs({ ...logsOpts, follow: false })
.then((logs) => Readable.from([toBuffer(logs)], { objectMode: false }))
: container.logs({ ...logsOpts, follow: true }).then((stream) => {
const actualLogStream = stream as IncomingMessage;
actualLogStream.socket?.unref();
return actualLogStream;
});

logStream
.then(async (actualLogStream) => {
const demuxedStream = await this.demuxStream(container.id, actualLogStream);
if (proxyStream.destroyed) {
demuxedStream.destroy();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { RandomUuid } from "../common";
import { checkContainerIsHealthy } from "../utils/test-helper";
import { Wait } from "../wait-strategies/wait";
import { GenericContainer } from "./generic-container";

describe("GenericContainer restart", { timeout: 180_000 }, () => {
Expand Down Expand Up @@ -28,4 +29,21 @@ describe("GenericContainer restart", { timeout: 180_000 }, () => {
await checkContainerIsHealthy(container);
expect((await container.exec(["cat", "config.txt"])).output).toEqual(expect.stringContaining("testconfig"));
});

it("should include the container logs since the restart in the error when the restart fails", async () => {
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
.withCommand([
"/bin/sh",
"-c",
"if [ -f /tmp/started ]; then echo 'second start'; exit 1; fi; touch /tmp/started; echo 'first start'; sleep infinity",
])
.withWaitStrategy(Wait.forLogMessage("first start"))
.start();

await expect(container.restart()).rejects.toThrow(
new Error(
'Log stream ended and message "first start" was not received\nContainer logs (last 50 lines):\nsecond start'
)
);
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import path from "node:path";
import archiver from "archiver";
import type { Container } from "dockerode";
import getPort from "get-port";
import { RandomUuid } from "../common";
import { getContainerRuntimeClient } from "../container-runtime";
import type { BoundPorts } from "../utils/bound-ports";
import { PullPolicy } from "../utils/pull-policy";
import {
checkContainerIsHealthy,
Expand All @@ -13,6 +15,7 @@ import {
waitForDockerEvent,
} from "../utils/test-helper";
import { Wait } from "../wait-strategies/wait";
import { AbstractWaitStrategy } from "../wait-strategies/wait-strategy";
import { GenericContainer } from "./generic-container";

describe("GenericContainer", { timeout: 180_000 }, () => {
Expand Down Expand Up @@ -692,6 +695,22 @@ describe("GenericContainer", { timeout: 180_000 }, () => {
expect(container.getHostname()).toEqual("hostname");
});

it("should include the container logs in the error when the container fails to become ready", async () => {
const waitStrategy = new (class extends AbstractWaitStrategy {
public async waitUntilReady(container: Container, boundPorts: BoundPorts): Promise<void> {
await Wait.forLogMessage("connecting to database").waitUntilReady(container, boundPorts);
throw new Error("Container is not ready");
}
})();

await expect(
new GenericContainer("cristianrgreco/testcontainer:1.1.14")
.withCommand(["/bin/sh", "-c", "echo 'connecting to database'; sleep infinity"])
.withWaitStrategy(waitStrategy)
.start()
).rejects.toThrow(new Error("Container is not ready\nContainer logs (last 50 lines):\nconnecting to database"));
});

// 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 () => {
Expand Down
39 changes: 39 additions & 0 deletions packages/testcontainers/src/wait-strategies/wait-for-container.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { text } from "node:stream/consumers";
import type { Container } from "dockerode";
import { log } from "../common";
import type { ContainerRuntimeClient } from "../container-runtime";
import type { BoundPorts } from "../utils/bound-ports";
import type { WaitStrategy } from "./wait-strategy";

const LOG_TAIL = 50;

export const waitForContainer = async (
client: ContainerRuntimeClient,
container: Container,
Expand All @@ -18,6 +21,7 @@ export const waitForContainer = async (
log.info(`Container is ready`, { containerId: container.id });
} catch (err) {
log.error(`Container failed to be ready: ${err}`, { containerId: container.id });
await appendContainerLogs(client, container, err, startTime);
try {
await client.container.stop(container, { timeout: 0 });
await client.container.remove(container, { removeVolumes: true });
Expand All @@ -27,3 +31,38 @@ export const waitForContainer = async (
throw err;
}
};

const appendContainerLogs = async (
client: ContainerRuntimeClient,
container: Container,
err: unknown,
startTime?: Date
): Promise<void> => {
if (!(err instanceof Error)) {
return;
}

try {
const stream = await client.container.logs(container, {
follow: false,
tail: LOG_TAIL,
since: startTime ? startTime.getTime() / 1000 : 0,
});
const logs = (await text(stream)).trimEnd();

if (logs.length === 0 || withoutWhitespace(err.message).includes(withoutWhitespace(logs))) {
return;
}

const header = String(err);
const { stack } = err;
err.message = `${err.message}\nContainer logs (last ${LOG_TAIL} lines):\n${logs}`;
if (stack?.startsWith(header)) {
err.stack = `${err}${stack.slice(header.length)}`;
}
} catch (logsErr) {
log.warn(`Failed to add container logs to the error: ${logsErr}`, { containerId: container.id });
}
};

const withoutWhitespace = (value: string): string => value.replace(/\s+/g, "");
Loading