Skip to content

Commit a333af3

Browse files
Fix healthcheck default wait strategy fallbacks
1 parent 1ba2eac commit a333af3

13 files changed

Lines changed: 358 additions & 74 deletions

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,15 @@ export class KafkaContainer extends GenericContainer {
190190

191191
const client = await getContainerRuntimeClient();
192192
const dockerContainer = client.container.getById(container.getId());
193+
const dockerInspectResult = await client.container.inspect(dockerContainer);
193194
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult).filter(
194195
this.exposedPorts
195196
);
196-
await waitForContainer(client, dockerContainer, this.originalWaitStrategy ?? Wait.forListeningPorts(), boundPorts);
197+
const waitStrategy = await this.selectWaitStrategy(client, dockerInspectResult, this.originalWaitStrategy);
198+
if (this.startupTimeoutMs !== undefined) {
199+
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
200+
}
201+
await waitForContainer(client, dockerContainer, waitStrategy, boundPorts);
197202

198203
if (this.saslSslConfig && this.mode !== KafkaMode.KRAFT) {
199204
await this.createUser(container, this.saslSslConfig.sasl);

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,15 @@ export class RedpandaContainer extends GenericContainer {
6868

6969
const client = await getContainerRuntimeClient();
7070
const dockerContainer = client.container.getById(container.getId());
71+
const dockerInspectResult = await client.container.inspect(dockerContainer);
7172
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult).filter(
7273
this.exposedPorts
7374
);
74-
await waitForContainer(client, dockerContainer, this.originalWaitStrategy ?? Wait.forListeningPorts(), boundPorts);
75+
const waitStrategy = await this.selectWaitStrategy(client, dockerInspectResult, this.originalWaitStrategy);
76+
if (this.startupTimeoutMs !== undefined) {
77+
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
78+
}
79+
await waitForContainer(client, dockerContainer, waitStrategy, boundPorts);
7580
}
7681

7782
private renderRedpandaFile(host: string, port: number): string {

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

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -97,19 +97,16 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => {
9797
await checkEnvironmentContainerIsHealthy(startedEnvironment, "container-1");
9898
});
9999

100-
// Podman compat inspect does not consistently expose Config.Healthcheck for compose-defined health checks.
101-
if (!process.env.CI_PODMAN) {
102-
it("should wait for a healthcheck defined in a service", async () => {
103-
await using startedEnvironment = await new DockerComposeEnvironment(
104-
fixtures,
105-
"docker-compose-with-delayed-healthcheck.yml"
106-
).up();
107-
const container = startedEnvironment.getContainer("container-1");
108-
109-
expect(await getHealthCheckStatus(container)).toBe("healthy");
110-
await checkEnvironmentContainerIsHealthy(startedEnvironment, "container-1");
111-
});
112-
}
100+
it("should wait for a healthcheck defined in a service", async () => {
101+
await using startedEnvironment = await new DockerComposeEnvironment(
102+
fixtures,
103+
"docker-compose-with-delayed-healthcheck.yml"
104+
).up();
105+
const container = startedEnvironment.getContainer("container-1");
106+
107+
expect(await getHealthCheckStatus(container)).toBe("healthy");
108+
await checkEnvironmentContainerIsHealthy(startedEnvironment, "container-1");
109+
});
113110

114111
it("should use listening ports if healthcheck is not defined in a service", async () => {
115112
await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose-with-name.yml").up();

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

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
import { ContainerInfo, ContainerInspectInfo } from "dockerode";
22
import { containerLog, log, RandomUuid, Uuid } from "../common";
3-
import { ComposeOptions, getContainerRuntimeClient, parseComposeContainerName } from "../container-runtime";
3+
import {
4+
ComposeOptions,
5+
ContainerRuntimeClient,
6+
getContainerRuntimeClient,
7+
ImageName,
8+
parseComposeContainerName,
9+
} from "../container-runtime";
410
import { StartedGenericContainer } from "../generic-container/started-generic-container";
511
import { getReaper } from "../reaper/reaper";
612
import { Environment } from "../types";
713
import { BoundPorts } from "../utils/bound-ports";
814
import { mapInspectResult } from "../utils/map-inspect-result";
915
import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy";
10-
import { hasHealthCheck } from "../wait-strategies/utils/health-check";
16+
import {
17+
hasDisabledHealthCheckConfig,
18+
hasHealthCheckConfig,
19+
hasHealthCheckStatus,
20+
} from "../wait-strategies/utils/health-check";
1121
import { Wait } from "../wait-strategies/wait";
1222
import { waitForContainer } from "../wait-strategies/wait-for-container";
1323
import { WaitStrategy } from "../wait-strategies/wait-strategy";
@@ -175,7 +185,7 @@ export class DockerComposeEnvironment {
175185
const inspectResult = await client.container.inspect(container);
176186
const mappedInspectResult = mapInspectResult(inspectResult);
177187
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult);
178-
const waitStrategy = this.selectWaitStrategy(containerName, inspectResult);
188+
const waitStrategy = await this.selectWaitStrategy(client, containerName, inspectResult);
179189
if (this.startupTimeoutMs !== undefined) {
180190
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
181191
}
@@ -222,17 +232,52 @@ export class DockerComposeEnvironment {
222232
});
223233
}
224234

225-
private selectWaitStrategy(containerName: string, inspectResult: ContainerInspectInfo): WaitStrategy {
235+
private async selectWaitStrategy(
236+
client: ContainerRuntimeClient,
237+
containerName: string,
238+
inspectResult: ContainerInspectInfo
239+
): Promise<WaitStrategy> {
226240
const containerWaitStrategy = this.waitStrategy[containerName]
227241
? this.waitStrategy[containerName]
228242
: this.defaultWaitStrategy;
229243
if (containerWaitStrategy) return containerWaitStrategy;
230-
if (hasHealthCheck(inspectResult.Config.Healthcheck)) {
244+
if (hasDisabledHealthCheckConfig(inspectResult)) {
245+
return Wait.forListeningPorts();
246+
}
247+
if (hasHealthCheckConfig(inspectResult) || hasHealthCheckStatus(inspectResult)) {
248+
return Wait.forHealthCheck();
249+
}
250+
if (await this.imageHasHealthCheck(client, inspectResult)) {
231251
return Wait.forHealthCheck();
232252
}
233253
return Wait.forListeningPorts();
234254
}
235255

256+
private async imageHasHealthCheck(
257+
client: ContainerRuntimeClient,
258+
inspectResult: ContainerInspectInfo
259+
): Promise<boolean> {
260+
const imageNames = Array.from(
261+
new Set(
262+
[inspectResult.Config.Image, inspectResult.Image].filter(
263+
(imageName): imageName is string => imageName !== undefined && imageName !== ""
264+
)
265+
)
266+
);
267+
268+
for (const imageName of imageNames) {
269+
try {
270+
if (hasHealthCheckConfig(await client.image.inspect(ImageName.fromString(imageName)))) {
271+
return true;
272+
}
273+
} catch (err) {
274+
log.debug(`Failed to inspect image "${imageName}" for health check config: ${err}`);
275+
}
276+
}
277+
278+
return false;
279+
}
280+
236281
private warnForUnusedWaitStrategies(startedContainerNames: Set<string>): void {
237282
const unusedWaitStrategyContainerNames = Object.keys(this.waitStrategy).filter(
238283
(configuredContainerName) => !startedContainerNames.has(configuredContainerName)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { ContainerInspectInfo, ImageInspectInfo } from "dockerode";
2+
import { ContainerRuntimeClient } from "../container-runtime";
3+
import { HealthCheckWaitStrategy } from "../wait-strategies/health-check-wait-strategy";
4+
import { HostPortWaitStrategy } from "../wait-strategies/host-port-wait-strategy";
5+
import { GenericContainer } from "./generic-container";
6+
7+
class TestGenericContainer extends GenericContainer {
8+
public selectWaitStrategyForTest(client: ContainerRuntimeClient, inspectResult: ContainerInspectInfo) {
9+
return this.selectWaitStrategy(client, inspectResult);
10+
}
11+
}
12+
13+
const containerInspectResult = (healthcheck?: { Test: string[] }): ContainerInspectInfo =>
14+
({
15+
Config: {
16+
Hostname: "hostname",
17+
Image: "image:latest",
18+
Labels: {},
19+
Healthcheck: healthcheck,
20+
},
21+
State: {
22+
Status: "running",
23+
Running: true,
24+
StartedAt: "2026-05-14T10:00:00.000Z",
25+
FinishedAt: "0001-01-01T00:00:00.000Z",
26+
},
27+
NetworkSettings: {
28+
Ports: {},
29+
Networks: {},
30+
},
31+
}) as unknown as ContainerInspectInfo;
32+
33+
const client = (imageInspectResult: ImageInspectInfo): ContainerRuntimeClient =>
34+
({
35+
image: {
36+
inspect: vi.fn().mockResolvedValue(imageInspectResult),
37+
},
38+
}) as unknown as ContainerRuntimeClient;
39+
40+
describe("GenericContainer wait strategy selection", () => {
41+
it("should fall back to image health check config when container config does not expose it", async () => {
42+
const imageInspectResult = {
43+
Config: {
44+
Healthcheck: {
45+
Test: ["CMD-SHELL", "test -f /tmp/ready"],
46+
},
47+
},
48+
} as unknown as ImageInspectInfo;
49+
50+
await expect(
51+
new TestGenericContainer("image:latest").selectWaitStrategyForTest(
52+
client(imageInspectResult),
53+
containerInspectResult()
54+
)
55+
).resolves.toBeInstanceOf(HealthCheckWaitStrategy);
56+
});
57+
58+
it("should not fall back to image health check config when the container disables health checks", async () => {
59+
const imageInspectResult = {
60+
Config: {
61+
Healthcheck: {
62+
Test: ["CMD-SHELL", "test -f /tmp/ready"],
63+
},
64+
},
65+
} as unknown as ImageInspectInfo;
66+
67+
await expect(
68+
new TestGenericContainer("image:latest").selectWaitStrategyForTest(
69+
client(imageInspectResult),
70+
containerInspectResult({ Test: ["NONE"] })
71+
)
72+
).resolves.toBeInstanceOf(HostPortWaitStrategy);
73+
});
74+
});

packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts

Lines changed: 33 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,14 @@ describe("GenericContainer default wait strategy", { timeout: 180_000 }, () => {
5050
await checkContainerIsHealthy(container);
5151
});
5252

53-
// Podman compat inspect does not consistently expose Config.Healthcheck for built images.
54-
if (!process.env.CI_PODMAN) {
55-
it("should wait for a healthcheck defined in the image", async () => {
56-
const context = path.resolve(fixtures, "docker-with-delayed-health-check");
57-
const genericContainer = await GenericContainer.fromDockerfile(context).build();
58-
await using container = await genericContainer.withExposedPorts(8080).start();
59-
60-
expect(await getHealthCheckStatus(container)).toBe("healthy");
61-
await checkContainerIsHealthy(container);
62-
});
63-
}
53+
it("should wait for a healthcheck defined in the image", async () => {
54+
const context = path.resolve(fixtures, "docker-with-delayed-health-check");
55+
const genericContainer = await GenericContainer.fromDockerfile(context).build();
56+
await using container = await genericContainer.withExposedPorts(8080).start();
57+
58+
expect(await getHealthCheckStatus(container)).toBe("healthy");
59+
await checkContainerIsHealthy(container);
60+
});
6461

6562
it("should use listening ports if the image disables healthcheck", async () => {
6663
const context = path.resolve(fixtures, "docker-with-disabled-health-check");
@@ -71,34 +68,31 @@ describe("GenericContainer default wait strategy", { timeout: 180_000 }, () => {
7168
expect(await getHealthCheckStatus(container)).toBeUndefined();
7269
});
7370

74-
// Podman compat inspect does not consistently expose Config.Healthcheck for reused built images.
75-
if (!process.env.CI_PODMAN) {
76-
it.sequential("should wait for an image healthcheck when reusing a stopped container", async () => {
77-
vi.stubEnv("TESTCONTAINERS_REUSE_ENABLE", "true");
78-
79-
const imageName = `localhost/${randomUuid()}:${randomUuid()}`;
80-
const containerName = `reusable-healthcheck-${randomUuid()}`;
81-
const context = path.resolve(fixtures, "docker-with-delayed-health-check");
82-
await GenericContainer.fromDockerfile(context).build(imageName);
83-
84-
const container1 = await new GenericContainer(imageName)
85-
.withName(containerName)
86-
.withExposedPorts(8080)
87-
.withReuse()
88-
.start();
89-
await container1.stop({ remove: false, timeout: 10_000 });
90-
91-
await using container2 = await new GenericContainer(imageName)
92-
.withName(containerName)
93-
.withExposedPorts(8080)
94-
.withReuse()
95-
.start();
96-
97-
expect(container2.getId()).toBe(container1.getId());
98-
expect(await getHealthCheckStatus(container2)).toBe("healthy");
99-
await container2.stop({ remove: true });
100-
});
101-
}
71+
it.sequential("should wait for an image healthcheck when reusing a stopped container", async () => {
72+
vi.stubEnv("TESTCONTAINERS_REUSE_ENABLE", "true");
73+
74+
const imageName = `localhost/${randomUuid()}:${randomUuid()}`;
75+
const containerName = `reusable-healthcheck-${randomUuid()}`;
76+
const context = path.resolve(fixtures, "docker-with-delayed-health-check");
77+
await GenericContainer.fromDockerfile(context).build(imageName);
78+
79+
const container1 = await new GenericContainer(imageName)
80+
.withName(containerName)
81+
.withExposedPorts(8080)
82+
.withReuse()
83+
.start();
84+
await container1.stop({ remove: false, timeout: 10_000 });
85+
86+
await using container2 = await new GenericContainer(imageName)
87+
.withName(containerName)
88+
.withExposedPorts(8080)
89+
.withReuse()
90+
.start();
91+
92+
expect(container2.getId()).toBe(container1.getId());
93+
expect(await getHealthCheckStatus(container2)).toBe("healthy");
94+
await container2.stop({ remove: true });
95+
});
10296

10397
it("should use an explicitly defined wait strategy even if image defines healthcheck", async () => {
10498
const context = path.resolve(fixtures, "docker-with-delayed-health-check");

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

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@ import { createLabels, LABEL_TESTCONTAINERS_CONTAINER_HASH, LABEL_TESTCONTAINERS
3131
import { mapInspectResult } from "../utils/map-inspect-result";
3232
import { getContainerPort, getProtocol, hasHostBinding, PortWithOptionalBinding } from "../utils/port";
3333
import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy";
34-
import { hasHealthCheck } from "../wait-strategies/utils/health-check";
34+
import {
35+
hasDisabledHealthCheckConfig,
36+
hasHealthCheck,
37+
hasHealthCheckConfig,
38+
hasHealthCheckStatus,
39+
} from "../wait-strategies/utils/health-check";
3540
import { Wait } from "../wait-strategies/wait";
3641
import { waitForContainer } from "../wait-strategies/wait-for-container";
3742
import { WaitStrategy } from "../wait-strategies/wait-strategy";
@@ -122,17 +127,36 @@ export class GenericContainer implements TestContainer {
122127
return this.startContainer(client);
123128
}
124129

125-
private selectWaitStrategy(inspectResult: ContainerInspectInfo): WaitStrategy {
126-
if (this.waitStrategy) return this.waitStrategy;
130+
protected async selectWaitStrategy(
131+
client: ContainerRuntimeClient,
132+
inspectResult: ContainerInspectInfo,
133+
waitStrategy: WaitStrategy | undefined = this.waitStrategy
134+
): Promise<WaitStrategy> {
135+
if (waitStrategy) return waitStrategy;
127136
if (hasHealthCheck(this.healthCheck)) {
128137
return Wait.forHealthCheck();
129138
}
130-
if (hasHealthCheck(inspectResult.Config.Healthcheck)) {
139+
if (hasDisabledHealthCheckConfig(inspectResult)) {
140+
return Wait.forListeningPorts();
141+
}
142+
if (hasHealthCheckConfig(inspectResult) || hasHealthCheckStatus(inspectResult)) {
143+
return Wait.forHealthCheck();
144+
}
145+
if (await this.imageHasHealthCheck(client)) {
131146
return Wait.forHealthCheck();
132147
}
133148
return Wait.forListeningPorts();
134149
}
135150

151+
private async imageHasHealthCheck(client: ContainerRuntimeClient): Promise<boolean> {
152+
try {
153+
return hasHealthCheckConfig(await client.image.inspect(this.imageName));
154+
} catch (err) {
155+
log.debug(`Failed to inspect image "${this.imageName.string}" for health check config: ${err}`);
156+
return false;
157+
}
158+
}
159+
136160
private async reuseOrStartContainer(client: ContainerRuntimeClient) {
137161
const containerHash = hash(JSON.stringify(this.createOpts));
138162
this.createOpts.Labels = { ...this.createOpts.Labels, [LABEL_TESTCONTAINERS_CONTAINER_HASH]: containerHash };
@@ -165,7 +189,7 @@ export class GenericContainer implements TestContainer {
165189
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter(
166190
this.exposedPorts
167191
);
168-
const waitStrategy = this.selectWaitStrategy(inspectResult);
192+
const waitStrategy = await this.selectWaitStrategy(client, inspectResult);
169193
if (this.startupTimeoutMs !== undefined) {
170194
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
171195
}
@@ -238,7 +262,7 @@ export class GenericContainer implements TestContainer {
238262
await this.containerStarting(mappedInspectResult, false);
239263
}
240264

241-
const waitStrategy = this.selectWaitStrategy(inspectResult);
265+
const waitStrategy = await this.selectWaitStrategy(client, inspectResult);
242266
if (this.startupTimeoutMs !== undefined) {
243267
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
244268
}

0 commit comments

Comments
 (0)