Skip to content

Commit 0aeaa42

Browse files
Add support for abortOnContainerExit to HTTP wait strategy (#693)
1 parent cce5bcc commit 0aeaa42

File tree

11 files changed

+146
-15
lines changed

11 files changed

+146
-15
lines changed

docs/features/wait-strategies.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,16 @@ const container = await new GenericContainer("redis")
120120
.start();
121121
```
122122

123+
Stop waiting after container exited if waiting for container restart not needed.
124+
125+
```javascript
126+
const { GenericContainer, Wait } = require("testcontainers");
127+
128+
const container = await new GenericContainer("redis")
129+
.withWaitStrategy(Wait.forHttp("/health", 8080, { abortOnContainerExit: true }))
130+
.start();
131+
```
132+
123133
### For status code
124134

125135
```javascript

packages/modules/couchbase/src/couchbase-container.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
Wait,
88
} from "testcontainers";
99
import { WaitStrategy } from "testcontainers/src/wait-strategies/wait-strategy";
10-
import { HttpWaitStrategy } from "testcontainers/src/wait-strategies/http-wait-strategy";
1110
import { BoundPorts } from "testcontainers/src/utils/bound-ports";
1211
import { ContainerRuntimeClient, getContainerRuntimeClient } from "testcontainers/src/container-runtime";
1312
import { CouchbaseService } from "./couchbase-service";
@@ -129,7 +128,7 @@ export class CouchbaseContainer extends GenericContainer {
129128
const waitStrategies: WaitStrategy[] = [];
130129

131130
waitStrategies.push(
132-
new HttpWaitStrategy("/pools/default", PORTS.MGMT_PORT)
131+
Wait.forHttp("/pools/default", PORTS.MGMT_PORT)
133132
.withBasicCredentials(this.username, this.password)
134133
.forStatusCode(200)
135134
.forResponsePredicate((response) => {
@@ -145,23 +144,23 @@ export class CouchbaseContainer extends GenericContainer {
145144

146145
if (this.enabledServices.has(CouchbaseService.QUERY)) {
147146
waitStrategies.push(
148-
new HttpWaitStrategy("/admin/ping", PORTS.QUERY_PORT)
147+
Wait.forHttp("/admin/ping", PORTS.QUERY_PORT)
149148
.withBasicCredentials(this.username, this.password)
150149
.forStatusCode(200)
151150
);
152151
}
153152

154153
if (this.enabledServices.has(CouchbaseService.ANALYTICS)) {
155154
waitStrategies.push(
156-
new HttpWaitStrategy("/admin/ping", PORTS.ANALYTICS_PORT)
155+
Wait.forHttp("/admin/ping", PORTS.ANALYTICS_PORT)
157156
.withBasicCredentials(this.username, this.password)
158157
.forStatusCode(200)
159158
);
160159
}
161160

162161
if (this.enabledServices.has(CouchbaseService.EVENTING)) {
163162
waitStrategies.push(
164-
new HttpWaitStrategy("/api/v1/config", PORTS.EVENTING_PORT)
163+
Wait.forHttp("/api/v1/config", PORTS.EVENTING_PORT)
165164
.withBasicCredentials(this.username, this.password)
166165
.forStatusCode(200)
167166
);
@@ -174,7 +173,7 @@ export class CouchbaseContainer extends GenericContainer {
174173
inspectResult: InspectResult,
175174
startedTestContainer: StartedTestContainer
176175
) {
177-
return new HttpWaitStrategy("/pools", PORTS.MGMT_PORT)
176+
return Wait.forHttp("/pools", PORTS.MGMT_PORT)
178177
.forStatusCode(200)
179178
.waitUntilReady(
180179
client.container.getById(startedTestContainer.getId()),
@@ -386,7 +385,7 @@ export class CouchbaseContainer extends GenericContainer {
386385

387386
await this.checkResponse(response, `Could not create bucket ${bucket.getName()}`);
388387

389-
await new HttpWaitStrategy(`/pools/default/b/${bucket.getName()}`, PORTS.MGMT_PORT)
388+
await Wait.forHttp(`/pools/default/b/${bucket.getName()}`, PORTS.MGMT_PORT)
390389
.withBasicCredentials(this.username, this.password)
391390
.forStatusCode(200)
392391
.forResponsePredicate((response) => {

packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ export class DockerContainerClient implements ContainerClient {
162162
follow: true,
163163
stdout: true,
164164
stderr: true,
165+
tail: opts?.tail ?? -1,
165166
since: opts?.since ?? 0,
166167
})) as IncomingMessage;
167168
stream.socket.unref();

packages/testcontainers/src/generic-container/abstract-started-container.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export class AbstractStartedContainer implements StartedTestContainer {
8383
return this.startedTestContainer.exec(command, opts);
8484
}
8585

86-
public logs(opts?: { since?: number }): Promise<Readable> {
86+
public logs(opts?: { since?: number; tail?: number }): Promise<Readable> {
8787
return this.startedTestContainer.logs(opts);
8888
}
8989
}

packages/testcontainers/src/generic-container/started-generic-container.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ export class StartedGenericContainer implements StartedTestContainer {
181181
return output;
182182
}
183183

184-
public async logs(opts?: { since?: number }): Promise<Readable> {
184+
public async logs(opts?: { since?: number; tail?: number }): Promise<Readable> {
185185
const client = await getContainerRuntimeClient();
186186

187187
return client.container.logs(this.container, opts);

packages/testcontainers/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export { DownedDockerComposeEnvironment } from "./docker-compose-environment/dow
1919
export { Network, StartedNetwork, StoppedNetwork } from "./network/network";
2020

2121
export { Wait } from "./wait-strategies/wait";
22+
export { HttpWaitStrategyOptions } from "./wait-strategies/http-wait-strategy";
2223
export { StartupCheckStrategy, StartupStatus } from "./wait-strategies/startup-check-strategy";
2324
export { PullPolicy, ImagePullPolicy } from "./utils/pull-policy";
2425
export { InspectResult, Content, ExecOptions, ExecResult } from "./types";

packages/testcontainers/src/test-container.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export interface StartedTestContainer {
7575
copyFilesToContainer(filesToCopy: FileToCopy[]): Promise<void>;
7676
copyContentToContainer(contentsToCopy: ContentToCopy[]): Promise<void>;
7777
exec(command: string | string[], opts?: Partial<ExecOptions>): Promise<ExecResult>;
78-
logs(opts?: { since?: number }): Promise<Readable>;
78+
logs(opts?: { since?: number; tail?: number }): Promise<Readable>;
7979
}
8080

8181
export interface StoppedTestContainer {

packages/testcontainers/src/utils/test-helper.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { StartedTestContainer } from "../test-container";
55
import https from "https";
66
import { GetEventsOptions } from "dockerode";
77
import { getContainerRuntimeClient } from "../container-runtime";
8+
import { GenericContainer } from "../generic-container/generic-container";
9+
import { IntervalRetry } from "../common";
810

911
export const checkContainerIsHealthy = async (container: StartedTestContainer): Promise<void> => {
1012
const url = `http://${container.getHost()}:${container.getMappedPort(8080)}`;
@@ -103,3 +105,25 @@ export async function deleteImageByName(imageName: string): Promise<void> {
103105
const dockerode = (await getContainerRuntimeClient()).container.dockerode;
104106
await dockerode.getImage(imageName).remove();
105107
}
108+
109+
export async function stopStartingContainer(container: GenericContainer, name: string) {
110+
const client = await getContainerRuntimeClient();
111+
const containerStartPromise = container.start();
112+
113+
const status = await new IntervalRetry<boolean, boolean>(500).retryUntil(
114+
() =>
115+
client.container
116+
.getById(name)
117+
.inspect()
118+
.then((i) => i.State.Running)
119+
.catch(() => false),
120+
(status) => status,
121+
() => false,
122+
10000
123+
);
124+
125+
if (!status) throw Error("failed start container");
126+
127+
await client.container.getById(name).stop();
128+
await containerStartPromise;
129+
}

packages/testcontainers/src/wait-strategies/http-wait-strategy.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { GenericContainer } from "../generic-container/generic-container";
22
import { Wait } from "./wait";
3-
import { checkContainerIsHealthy, checkContainerIsHealthyTls } from "../utils/test-helper";
3+
import { checkContainerIsHealthy, checkContainerIsHealthyTls, stopStartingContainer } from "../utils/test-helper";
44

55
jest.setTimeout(180_000);
66

@@ -85,6 +85,44 @@ describe("HttpWaitStrategy", () => {
8585
).rejects.toThrowError("URL /hello-world not accessible after 3000ms");
8686
});
8787

88+
describe("when options.abortOnContainerExit is true", () => {
89+
it("should fail if container exited before waiting pass", async () => {
90+
const name = "container-name";
91+
const data = [1, 2, 3];
92+
const tail = 50;
93+
const echoCmd = data.map((i) => `echo ${i}`).join(" && ");
94+
const lastLogs = data.join("\n");
95+
const container = new GenericContainer("cristianrgreco/testcontainer:1.1.14")
96+
.withExposedPorts(8080)
97+
.withStartupTimeout(20000)
98+
.withEntrypoint(["/bin/sh", "-c", `${echoCmd} && sleep infinity`])
99+
.withWaitStrategy(Wait.forHttp("/hello-world", 8080, { abortOnContainerExit: true }))
100+
.withName(name);
101+
102+
await expect(stopStartingContainer(container, name)).rejects.toThrowError(
103+
new Error(`Container exited during HTTP healthCheck, last ${tail} logs: ${lastLogs}`)
104+
);
105+
});
106+
107+
it("should log only $tail logs if container exited before waiting pass", async () => {
108+
const name = "container-name";
109+
const tail = 50;
110+
const data = [...Array(tail + 5).keys()];
111+
const echoCmd = data.map((i) => `echo ${i}`).join(" && ");
112+
const lastLogs = data.slice(tail * -1).join("\n");
113+
const container = new GenericContainer("cristianrgreco/testcontainer:1.1.14")
114+
.withExposedPorts(8080)
115+
.withStartupTimeout(20000)
116+
.withEntrypoint(["/bin/sh", "-c", `${echoCmd} && sleep infinity`])
117+
.withWaitStrategy(Wait.forHttp("/hello-world", 8080, { abortOnContainerExit: true }))
118+
.withName(name);
119+
120+
await expect(stopStartingContainer(container, name)).rejects.toThrowError(
121+
new Error(`Container exited during HTTP healthCheck, last ${tail} logs: ${lastLogs}`)
122+
);
123+
});
124+
});
125+
88126
it("should set method", async () => {
89127
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
90128
.withExposedPorts(8080)

packages/testcontainers/src/wait-strategies/http-wait-strategy.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { BoundPorts } from "../utils/bound-ports";
66
import { IntervalRetry, log } from "../common";
77
import { getContainerRuntimeClient } from "../container-runtime";
88

9+
export interface HttpWaitStrategyOptions {
10+
abortOnContainerExit?: boolean;
11+
}
12+
913
export class HttpWaitStrategy extends AbstractWaitStrategy {
1014
private protocol = "http";
1115
private method = "GET";
@@ -14,7 +18,11 @@ export class HttpWaitStrategy extends AbstractWaitStrategy {
1418
private _allowInsecure = false;
1519
private readTimeout = 1000;
1620

17-
constructor(private readonly path: string, private readonly port: number) {
21+
constructor(
22+
private readonly path: string,
23+
private readonly port: number,
24+
private readonly options: HttpWaitStrategyOptions
25+
) {
1826
super();
1927
}
2028

@@ -66,14 +74,29 @@ export class HttpWaitStrategy extends AbstractWaitStrategy {
6674

6775
public async waitUntilReady(container: Dockerode.Container, boundPorts: BoundPorts): Promise<void> {
6876
log.debug(`Waiting for HTTP...`, { containerId: container.id });
77+
78+
const exitStatus = "exited";
79+
let containerExited = false;
6980
const client = await getContainerRuntimeClient();
81+
const { abortOnContainerExit } = this.options;
7082

7183
await new IntervalRetry<Response | undefined, Error>(this.readTimeout).retryUntil(
7284
async () => {
7385
try {
7486
const url = `${this.protocol}://${client.info.containerRuntime.host}:${boundPorts.getBinding(this.port)}${
7587
this.path
7688
}`;
89+
90+
if (abortOnContainerExit) {
91+
const containerStatus = (await client.container.inspect(container)).State.Status;
92+
93+
if (containerStatus === exitStatus) {
94+
containerExited = true;
95+
96+
return;
97+
}
98+
}
99+
77100
return await fetch(url, {
78101
method: this.method,
79102
timeout: this.readTimeout,
@@ -85,6 +108,10 @@ export class HttpWaitStrategy extends AbstractWaitStrategy {
85108
}
86109
},
87110
async (response) => {
111+
if (abortOnContainerExit && containerExited) {
112+
return true;
113+
}
114+
88115
if (response === undefined) {
89116
return false;
90117
} else if (!this.predicates.length) {
@@ -107,9 +134,36 @@ export class HttpWaitStrategy extends AbstractWaitStrategy {
107134
this.startupTimeout
108135
);
109136

137+
if (abortOnContainerExit && containerExited) {
138+
return this.handleContainerExit(container);
139+
}
140+
110141
log.debug(`HTTP wait strategy complete`, { containerId: container.id });
111142
}
112143

144+
private async handleContainerExit(container: Dockerode.Container) {
145+
const tail = 50;
146+
const lastLogs: string[] = [];
147+
const client = await getContainerRuntimeClient();
148+
let message: string;
149+
150+
try {
151+
const stream = await client.container.logs(container, { tail });
152+
153+
await new Promise((res) => {
154+
stream.on("data", (d) => lastLogs.push(d.trim())).on("end", res);
155+
});
156+
157+
message = `Container exited during HTTP healthCheck, last ${tail} logs: ${lastLogs.join("\n")}`;
158+
} catch (err) {
159+
message = "Container exited during HTTP healthCheck, failed to get last logs";
160+
}
161+
162+
log.error(message, { containerId: container.id });
163+
164+
throw new Error(message);
165+
}
166+
113167
private getAgent(): Agent | undefined {
114168
if (this._allowInsecure) {
115169
return new https.Agent({

0 commit comments

Comments
 (0)