Skip to content

Commit 6152416

Browse files
Make HealthCheckWaitStrategy event based (#789)
1 parent 3bafc47 commit 6152416

File tree

5 files changed

+68
-18
lines changed

5 files changed

+68
-18
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
version: "3.5"
2+
3+
services:
4+
container:
5+
image: cristianrgreco/testcontainer:1.1.14
6+
ports:
7+
- 8080
8+
healthcheck:
9+
test: "curl -f http://localhost:8081/hello-world || exit 1"
10+
timeout: 3s
11+
interval: 1s
12+
retries: 0

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface ContainerClient {
2424
logs(container: Container, opts?: ContainerLogsOptions): Promise<Readable>;
2525
exec(container: Container, command: string[], opts?: Partial<ExecOptions>): Promise<ExecResult>;
2626
restart(container: Container, opts?: { timeout: number }): Promise<void>;
27+
events(container: Container, eventNames: string[]): Promise<Readable>;
2728
remove(container: Container, opts?: { removeVolumes: boolean }): Promise<void>;
2829
connectToNetwork(container: Container, network: Network, networkAliases: string[]): Promise<void>;
2930
}

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { IncomingMessage } from "http";
1212
import { ExecOptions, ExecResult } from "./types";
1313
import byline from "byline";
1414
import { ContainerClient } from "./container-client";
15-
import { log, execLog, streamToString } from "../../../common";
15+
import { execLog, log, streamToString } from "../../../common";
1616

1717
export class DockerContainerClient implements ContainerClient {
1818
constructor(public readonly dockerode: Dockerode) {}
@@ -248,6 +248,19 @@ export class DockerContainerClient implements ContainerClient {
248248
}
249249
}
250250

251+
async events(container: Container, eventNames: string[]): Promise<Readable> {
252+
log.debug(`Fetching event stream...`, { containerId: container.id });
253+
const stream = (await this.dockerode.getEvents({
254+
filters: {
255+
type: ["container"],
256+
container: [container.id],
257+
event: eventNames,
258+
},
259+
})) as Readable;
260+
log.debug(`Fetched event stream...`, { containerId: container.id });
261+
return stream;
262+
}
263+
251264
protected async demuxStream(containerId: string, stream: Readable): Promise<Readable> {
252265
try {
253266
log.debug(`Demuxing stream...`, { containerId });

packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ describe("DockerComposeEnvironment", () => {
124124
await startedEnvironment.down();
125125
});
126126

127+
it("should support failing health check wait strategy", async () => {
128+
await expect(
129+
new DockerComposeEnvironment(fixtures, "docker-compose-with-healthcheck-unhealthy.yml")
130+
.withWaitStrategy(await composeContainerName("container"), Wait.forHealthCheck())
131+
.up()
132+
).rejects.toThrow(`Health check failed: unhealthy`);
133+
});
134+
127135
if (!process.env.CI_PODMAN) {
128136
it("should stop the container when the health check wait strategy times out", async () => {
129137
await expect(
Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,47 @@
11
import Dockerode from "dockerode";
22
import { AbstractWaitStrategy } from "./wait-strategy";
3-
import { IntervalRetry, log } from "../common";
3+
import { log } from "../common";
44
import { getContainerRuntimeClient } from "../container-runtime";
55

66
export class HealthCheckWaitStrategy extends AbstractWaitStrategy {
77
public async waitUntilReady(container: Dockerode.Container): Promise<void> {
88
log.debug(`Waiting for health check...`, { containerId: container.id });
9+
910
const client = await getContainerRuntimeClient();
11+
const containerEvents = await client.container.events(container, ["health_status"]);
1012

11-
const status = await new IntervalRetry<string | undefined, Error>(100).retryUntil(
12-
async () => (await client.container.inspect(container)).State.Health?.Status,
13-
(healthCheckStatus) => healthCheckStatus === "healthy" || healthCheckStatus === "unhealthy",
14-
() => {
15-
const timeout = this.startupTimeout;
16-
const message = `Health check not healthy after ${timeout}ms`;
13+
return new Promise((resolve, reject) => {
14+
const timeout = setTimeout(() => {
15+
const message = `Health check not healthy after ${this.startupTimeout}ms`;
1716
log.error(message, { containerId: container.id });
18-
throw new Error(message);
19-
},
20-
this.startupTimeout
21-
);
17+
containerEvents.destroy();
18+
reject(new Error(message));
19+
}, this.startupTimeout);
20+
21+
const onTerminalState = () => {
22+
clearTimeout(timeout);
23+
containerEvents.destroy();
24+
log.debug(`Health check wait strategy complete`, { containerId: container.id });
25+
};
26+
27+
containerEvents.on("data", (data) => {
28+
const parsedData = JSON.parse(data);
2229

23-
if (status !== "healthy") {
24-
const message = `Health check failed: ${status}`;
25-
log.error(message, { containerId: container.id });
26-
throw new Error(message);
27-
}
30+
const status =
31+
parsedData.status.split(":").length === 2
32+
? parsedData.status.split(":")[1].trim() // Docker
33+
: parsedData.HealthStatus; // Podman
2834

29-
log.debug(`Health check wait strategy complete`, { containerId: container.id });
35+
if (status === "healthy") {
36+
resolve();
37+
onTerminalState();
38+
} else if (status === "unhealthy") {
39+
const message = `Health check failed: ${status}`;
40+
log.error(message, { containerId: container.id });
41+
reject(new Error(message));
42+
onTerminalState();
43+
}
44+
});
45+
});
3046
}
3147
}

0 commit comments

Comments
 (0)