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

Make HealthCheckWaitStrategy event based #789

Merged
merged 7 commits into from
Jul 26, 2024
Merged
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
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface ContainerClient {
logs(container: Container, opts?: ContainerLogsOptions): Promise<Readable>;
exec(container: Container, command: string[], opts?: Partial<ExecOptions>): Promise<ExecResult>;
restart(container: Container, opts?: { timeout: number }): Promise<void>;
events(container: Container, eventNames: string[]): Promise<Readable>;
remove(container: Container, opts?: { removeVolumes: boolean }): Promise<void>;
connectToNetwork(container: Container, network: Network, networkAliases: string[]): Promise<void>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down Expand Up @@ -248,6 +248,19 @@ export class DockerContainerClient implements ContainerClient {
}
}

async events(container: Container, eventNames: string[]): Promise<Readable> {
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<Readable> {
try {
log.debug(`Demuxing stream...`, { containerId });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string | undefined, Error>(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();
}
});
});
}
}
Loading