diff --git a/packages/testcontainers/fixtures/docker-compose/docker-compose-with-healthcheck-unhealthy.yml b/packages/testcontainers/fixtures/docker-compose/docker-compose-with-healthcheck-unhealthy.yml new file mode 100644 index 000000000..4e33bdbe2 --- /dev/null +++ b/packages/testcontainers/fixtures/docker-compose/docker-compose-with-healthcheck-unhealthy.yml @@ -0,0 +1,12 @@ +version: "3.5" + +services: + container: + image: cristianrgreco/testcontainer:1.1.14 + ports: + - 8080 + healthcheck: + test: "curl -f http://localhost:8081/hello-world || exit 1" + timeout: 3s + interval: 1s + retries: 0 diff --git a/packages/testcontainers/src/container-runtime/clients/container/container-client.ts b/packages/testcontainers/src/container-runtime/clients/container/container-client.ts index e19eab593..9786c68c4 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/container-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/container-client.ts @@ -24,6 +24,7 @@ export interface ContainerClient { logs(container: Container, opts?: ContainerLogsOptions): Promise; exec(container: Container, command: string[], opts?: Partial): Promise; restart(container: Container, opts?: { timeout: number }): Promise; + events(container: Container, eventNames: string[]): Promise; remove(container: Container, opts?: { removeVolumes: boolean }): Promise; connectToNetwork(container: Container, network: Network, networkAliases: string[]): Promise; } diff --git a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts index 0f560fc8e..9b413c36d 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts @@ -12,7 +12,7 @@ import { IncomingMessage } from "http"; import { ExecOptions, ExecResult } from "./types"; import byline from "byline"; import { ContainerClient } from "./container-client"; -import { log, execLog, streamToString } from "../../../common"; +import { execLog, log, streamToString } from "../../../common"; export class DockerContainerClient implements ContainerClient { constructor(public readonly dockerode: Dockerode) {} @@ -248,6 +248,19 @@ export class DockerContainerClient implements ContainerClient { } } + async events(container: Container, eventNames: string[]): Promise { + log.debug(`Fetching event stream...`, { containerId: container.id }); + const stream = (await this.dockerode.getEvents({ + filters: { + type: ["container"], + container: [container.id], + event: eventNames, + }, + })) as Readable; + log.debug(`Fetched event stream...`, { containerId: container.id }); + return stream; + } + protected async demuxStream(containerId: string, stream: Readable): Promise { try { log.debug(`Demuxing stream...`, { containerId }); diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts index ed79a0e24..68cf14417 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts @@ -123,6 +123,14 @@ describe("DockerComposeEnvironment", () => { await startedEnvironment.down(); }); + it("should support failing health check wait strategy", async () => { + await expect( + new DockerComposeEnvironment(fixtures, "docker-compose-with-healthcheck-unhealthy.yml") + .withWaitStrategy(await composeContainerName("container"), Wait.forHealthCheck()) + .up() + ).rejects.toThrow(`Health check failed: unhealthy`); + }); + if (!process.env.CI_PODMAN) { it("should stop the container when the health check wait strategy times out", async () => { await expect( diff --git a/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.ts index bc2bad018..ced92d562 100644 --- a/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/health-check-wait-strategy.ts @@ -1,31 +1,47 @@ import Dockerode from "dockerode"; import { AbstractWaitStrategy } from "./wait-strategy"; -import { IntervalRetry, log } from "../common"; +import { log } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; export class HealthCheckWaitStrategy extends AbstractWaitStrategy { public async waitUntilReady(container: Dockerode.Container): Promise { log.debug(`Waiting for health check...`, { containerId: container.id }); + const client = await getContainerRuntimeClient(); + const containerEvents = await client.container.events(container, ["health_status"]); - const status = await new IntervalRetry(100).retryUntil( - async () => (await client.container.inspect(container)).State.Health?.Status, - (healthCheckStatus) => healthCheckStatus === "healthy" || healthCheckStatus === "unhealthy", - () => { - const timeout = this.startupTimeout; - const message = `Health check not healthy after ${timeout}ms`; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const message = `Health check not healthy after ${this.startupTimeout}ms`; log.error(message, { containerId: container.id }); - throw new Error(message); - }, - this.startupTimeout - ); + containerEvents.destroy(); + reject(new Error(message)); + }, this.startupTimeout); + + const onTerminalState = () => { + clearTimeout(timeout); + containerEvents.destroy(); + log.debug(`Health check wait strategy complete`, { containerId: container.id }); + }; + + containerEvents.on("data", (data) => { + const parsedData = JSON.parse(data); - if (status !== "healthy") { - const message = `Health check failed: ${status}`; - log.error(message, { containerId: container.id }); - throw new Error(message); - } + const status = + parsedData.status.split(":").length === 2 + ? parsedData.status.split(":")[1].trim() // Docker + : parsedData.HealthStatus; // Podman - log.debug(`Health check wait strategy complete`, { containerId: container.id }); + if (status === "healthy") { + resolve(); + onTerminalState(); + } else if (status === "unhealthy") { + const message = `Health check failed: ${status}`; + log.error(message, { containerId: container.id }); + reject(new Error(message)); + onTerminalState(); + } + }); + }); } }