Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for abortOnContainerExit to HTTP wait strategy #693

Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -162,6 +162,7 @@ export class DockerContainerClient implements ContainerClient {
follow: true,
stdout: true,
stderr: true,
tail: opts?.tail ?? -1,
cristianrgreco marked this conversation as resolved.
Show resolved Hide resolved
since: opts?.since ?? 0,
})) as IncomingMessage;
stream.socket.unref();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export class AbstractStartedContainer implements StartedTestContainer {
return this.startedTestContainer.exec(command, opts);
}

public logs(opts?: { since?: number }): Promise<Readable> {
public logs(opts?: { since?: number, tail?: number }): Promise<Readable> {
return this.startedTestContainer.logs(opts);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export class StartedGenericContainer implements StartedTestContainer {
return output;
}

public async logs(opts?: { since?: number }): Promise<Readable> {
public async logs(opts?: { since?: number, tail?: number }): Promise<Readable> {
const client = await getContainerRuntimeClient();

return client.container.logs(this.container, opts);
Expand Down
2 changes: 1 addition & 1 deletion packages/testcontainers/src/test-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export interface StartedTestContainer {
copyFilesToContainer(filesToCopy: FileToCopy[]): Promise<void>;
copyContentToContainer(contentsToCopy: ContentToCopy[]): Promise<void>;
exec(command: string | string[], opts?: Partial<ExecOptions>): Promise<ExecResult>;
logs(opts?: { since?: number }): Promise<Readable>;
logs(opts?: { since?: number, tail?: number }): Promise<Readable>;
}

export interface StoppedTestContainer {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,31 @@
import { GenericContainer } from "../generic-container/generic-container";
import { Wait } from "./wait";
import { checkContainerIsHealthy, checkContainerIsHealthyTls } from "../utils/test-helper";
import { getContainerRuntimeClient } from "../container-runtime";
import { IntervalRetry } from "../common";

jest.setTimeout(180_000);

process.env.TESTCONTAINERS_RYUK_DISABLED = 'true';
async function stopStartingContainer(container: GenericContainer, name: string) {
cristianrgreco marked this conversation as resolved.
Show resolved Hide resolved
const client = await getContainerRuntimeClient();
const containerStartPromise = container.start();

const status = await new IntervalRetry<boolean, boolean>(500).retryUntil(
cristianrgreco marked this conversation as resolved.
Show resolved Hide resolved
() => client.container.getById(name).inspect()
.then(i => i.State.Running)
.catch(() => false),
(status) => status,
() => false,
20000
);

if (!status) throw Error('failed start container');

await client.container.getById(name).stop();
await containerStartPromise;
}

describe("HttpWaitStrategy", () => {
it("should wait for 200", async () => {
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
Expand Down Expand Up @@ -85,6 +107,42 @@ describe("HttpWaitStrategy", () => {
).rejects.toThrowError("URL /hello-world not accessible after 3000ms");
});

it("should fail if container exited before healthcheck pass", async () => {
cristianrgreco marked this conversation as resolved.
Show resolved Hide resolved
const name = 'container-name';
const data = [1,2,3];
const tail = 50;
const echoCmd = data.map(i => `echo ${i}`).join(' && ');
const lastLogs = data.join('\n');
const container = new GenericContainer("cristianrgreco/testcontainer:1.1.14")
.withExposedPorts(8080)
.withStartupTimeout(30000)
.withEntrypoint(["/bin/sh", "-c", `${echoCmd} && sleep infinity`])
.withWaitStrategy(Wait.forHttp("/hello-world", 8080, true))
.withName(name);

await expect(
stopStartingContainer(container, name)
).rejects.toThrowError(new Error(`Container exited during HTTP healthCheck, last ${tail} logs: ${lastLogs}`));
});

it("should log only $tail logs if container exited before healthcheck pass", async () => {
const name = 'container-name';
const tail = 50;
const data = [...Array(tail + 5).keys()];
const echoCmd = data.map(i => `echo ${i}`).join(' && ');
const lastLogs = data.slice(tail * -1).join('\n');
const container = new GenericContainer("cristianrgreco/testcontainer:1.1.14")
.withExposedPorts(8080)
.withStartupTimeout(30000)
.withEntrypoint(["/bin/sh", "-c", `${echoCmd} && sleep infinity`])
.withWaitStrategy(Wait.forHttp("/hello-world", 8080, true))
.withName(name);

await expect(
stopStartingContainer(container, name)
).rejects.toThrowError(new Error(`Container exited during HTTP healthCheck, last ${tail} logs: ${lastLogs}`));
});

it("should set method", async () => {
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
.withExposedPorts(8080)
Expand Down
51 changes: 48 additions & 3 deletions packages/testcontainers/src/wait-strategies/http-wait-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export class HttpWaitStrategy extends AbstractWaitStrategy {
private _allowInsecure = false;
private readTimeout = 1000;

constructor(private readonly path: string, private readonly port: number) {
constructor(private readonly path: string, private readonly port: number, private readonly failOnExitedContainer = false) {
cristianrgreco marked this conversation as resolved.
Show resolved Hide resolved
super();
}

Expand Down Expand Up @@ -66,9 +66,10 @@ export class HttpWaitStrategy extends AbstractWaitStrategy {

public async waitUntilReady(container: Dockerode.Container, boundPorts: BoundPorts): Promise<void> {
log.debug(`Waiting for HTTP...`, { containerId: container.id });
const client = await getContainerRuntimeClient();

await new IntervalRetry<Response | undefined, Error>(this.readTimeout).retryUntil(
const waitingFinished = { value: false };
const client = await getContainerRuntimeClient();
const healthCheckPromise = new IntervalRetry<Response | undefined, Error>(this.readTimeout).retryUntil(
async () => {
try {
const url = `${this.protocol}://${client.info.containerRuntime.host}:${boundPorts.getBinding(this.port)}${
Expand All @@ -85,6 +86,10 @@ export class HttpWaitStrategy extends AbstractWaitStrategy {
}
},
async (response) => {
if (waitingFinished.value) {
return true;
}

if (response === undefined) {
return false;
} else if (!this.predicates.length) {
Expand All @@ -107,9 +112,49 @@ export class HttpWaitStrategy extends AbstractWaitStrategy {
this.startupTimeout
);

await Promise.race([this.waitContainerNotExited(container, waitingFinished), healthCheckPromise])
.finally(() => waitingFinished.value = true)

log.debug(`HTTP wait strategy complete`, { containerId: container.id });
}

private async waitContainerNotExited(container: Dockerode.Container, waitingFinished: {value: boolean}) {
const exitStatus = 'exited';
const status = await new IntervalRetry<string, null>(500).retryUntil(
async () => (await container.inspect()).State.Status,
(status) => waitingFinished.value || status === exitStatus,
() => null,
this.startupTimeout + 500 // delay for timeout after healthCheck
);

if (status !== exitStatus) {
return
}

const tail = 50;
const lastLogs: string[] = [];
const client = await getContainerRuntimeClient();
let message: string;

try {
const stream = await client.container.logs(container, {tail});
cristianrgreco marked this conversation as resolved.
Show resolved Hide resolved

await new Promise((res) => {
stream
.on('data', d => lastLogs.push(d.trim()))
.on('end', res);
});

message = `Container exited during HTTP healthCheck, last ${tail} logs: ${lastLogs.join('\n')}`;
} catch (err) {
message = 'Container exited during HTTP healthCheck, failed to get last logs';
}

log.error(message, { containerId: container.id });

if (this.failOnExitedContainer) throw new Error(message);
}

private getAgent(): Agent | undefined {
if (this._allowInsecure) {
return new https.Agent({
Expand Down
4 changes: 2 additions & 2 deletions packages/testcontainers/src/wait-strategies/wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export class Wait {
return new HealthCheckWaitStrategy();
}

public static forHttp(path: string, port: number): HttpWaitStrategy {
return new HttpWaitStrategy(path, port);
public static forHttp(path: string, port: number, shutdownOnExit = false): HttpWaitStrategy {
return new HttpWaitStrategy(path, port, shutdownOnExit);
}

public static forSuccessfulCommand(command: string): ShellWaitStrategy {
Expand Down