Skip to content

Commit c9c2b36

Browse files
Wait for ports to be bound when container restarts (#1087)
1 parent c0571da commit c9c2b36

File tree

5 files changed

+76
-83
lines changed

5 files changed

+76
-83
lines changed

.github/workflows/checks.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,12 @@ jobs:
124124
run: npm prune --omit=dev --workspace packages/testcontainers
125125
- name: Run CommonJS module smoke test
126126
run: node packages/testcontainers/smoke-test.js
127+
env:
128+
DEBUG: "testcontainers*"
127129
- name: Run ES module smoke test
128130
run: node packages/testcontainers/smoke-test.mjs
131+
env:
132+
DEBUG: "testcontainers*"
129133

130134
test:
131135
if: ${{ needs.detect-modules.outputs.modules_count > 0 }}

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

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -142,13 +142,7 @@ export class GenericContainer implements TestContainer {
142142
if (!inspectResult.State.Running) {
143143
log.debug("Reused container is not running, attempting to start it");
144144
await client.container.start(container);
145-
inspectResult = (
146-
await inspectContainerUntilPortsExposed(
147-
() => client.container.inspect(container),
148-
this.exposedPorts,
149-
container.id
150-
)
151-
).inspectResult;
145+
inspectResult = await inspectContainerUntilPortsExposed(() => client.container.inspect(container), container.id);
152146
}
153147

154148
const mappedInspectResult = mapInspectResult(inspectResult);
@@ -202,11 +196,11 @@ export class GenericContainer implements TestContainer {
202196
await client.container.start(container);
203197
log.info(`Started container for image "${this.createOpts.Image}"`, { containerId: container.id });
204198

205-
const { inspectResult, mappedInspectResult } = await inspectContainerUntilPortsExposed(
199+
const inspectResult = await inspectContainerUntilPortsExposed(
206200
() => client.container.inspect(container),
207-
this.exposedPorts,
208201
container.id
209202
);
203+
const mappedInspectResult = mapInspectResult(inspectResult);
210204
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter(
211205
this.exposedPorts
212206
);
Lines changed: 55 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,74 @@
11
import { ContainerInspectInfo } from "dockerode";
2-
import { mapInspectResult } from "../utils/map-inspect-result";
32
import { inspectContainerUntilPortsExposed } from "./inspect-container-util-ports-exposed";
43

5-
function mockInspectResult(ports: ContainerInspectInfo["NetworkSettings"]["Ports"]) {
6-
const date = new Date();
7-
8-
const inspectResult: ContainerInspectInfo = {
9-
Name: "container-id",
10-
Config: {
11-
Hostname: "hostname",
12-
Labels: {},
13-
},
14-
State: {
15-
Health: {
16-
Status: "healthy",
17-
},
18-
Status: "running",
19-
Running: true,
20-
StartedAt: date.toISOString(),
21-
FinishedAt: date.toISOString(),
4+
function mockInspectResult(
5+
portBindings: ContainerInspectInfo["HostConfig"]["PortBindings"],
6+
ports: ContainerInspectInfo["NetworkSettings"]["Ports"]
7+
): ContainerInspectInfo {
8+
return {
9+
HostConfig: {
10+
PortBindings: portBindings,
2211
},
2312
NetworkSettings: {
2413
Ports: ports,
25-
Networks: {},
2614
},
27-
} as unknown as ContainerInspectInfo;
28-
29-
return { inspectResult, mappedInspectResult: mapInspectResult(inspectResult) };
15+
} as ContainerInspectInfo;
3016
}
3117

32-
test("returns the inspect results when all ports are exposed", async () => {
33-
const data = mockInspectResult({ "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "45000" }] });
34-
const inspectFn = vi.fn().mockResolvedValueOnce(data.inspectResult);
18+
describe.sequential("inspectContainerUntilPortsExposed", () => {
19+
it("returns the inspect result when all ports are exposed", async () => {
20+
const data = mockInspectResult({ "8080/tcp": [] }, { "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "45000" }] });
21+
const inspectFn = vi.fn().mockResolvedValueOnce(data);
3522

36-
const result = await inspectContainerUntilPortsExposed(inspectFn, [8080], "container-id");
23+
const result = await inspectContainerUntilPortsExposed(inspectFn, "container-id");
3724

38-
expect(result).toEqual(data);
39-
});
25+
expect(result).toEqual(data);
26+
});
4027

41-
test("retries the inspect if ports are not yet exposed", async () => {
42-
const data1 = mockInspectResult({ "8080/tcp": [] });
43-
const data2 = mockInspectResult({ "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "45000" }] });
44-
const inspectFn = vi
45-
.fn()
46-
.mockResolvedValueOnce(data1.inspectResult)
47-
.mockResolvedValueOnce(data1.inspectResult)
48-
.mockResolvedValueOnce(data2.inspectResult);
28+
it("returns the inspect result when no ports are exposed", async () => {
29+
const data = mockInspectResult({}, {});
30+
const inspectFn = vi.fn().mockResolvedValueOnce(data);
4931

50-
const result = await inspectContainerUntilPortsExposed(inspectFn, [8080], "container-id");
32+
const result = await inspectContainerUntilPortsExposed(inspectFn, "container-id");
5133

52-
expect(result).toEqual(data2);
53-
expect(inspectFn).toHaveBeenCalledTimes(3);
54-
});
34+
expect(result).toEqual(data);
35+
});
5536

56-
test("throws an error when host ports are not exposed within timeout", async () => {
57-
const data = mockInspectResult({ "8080/tcp": [] });
58-
const inspectFn = vi.fn().mockResolvedValue(data.inspectResult);
37+
it("returns the inspect result if host config port bindings are null", async () => {
38+
const data = mockInspectResult(null, {});
39+
const inspectFn = vi.fn().mockResolvedValueOnce(data);
5940

60-
await expect(inspectContainerUntilPortsExposed(inspectFn, [8080], "container-id", 0)).rejects.toThrow(
61-
"Container did not expose all ports after starting"
62-
);
63-
});
41+
const result = await inspectContainerUntilPortsExposed(inspectFn, "container-id");
42+
43+
expect(result).toEqual(data);
44+
});
45+
46+
it("retries the inspect if ports are not yet exposed", async () => {
47+
const data1 = mockInspectResult({ "8080/tcp": [] }, { "8080/tcp": [] });
48+
const data2 = mockInspectResult({ "8080/tcp": [] }, { "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "45000" }] });
49+
const inspectFn = vi.fn().mockResolvedValueOnce(data1).mockResolvedValueOnce(data1).mockResolvedValueOnce(data2);
50+
51+
const result = await inspectContainerUntilPortsExposed(inspectFn, "container-id");
52+
53+
expect(result).toEqual(data2);
54+
expect(inspectFn).toHaveBeenCalledTimes(3);
55+
});
56+
57+
it("throws an error when host ports are not exposed within timeout", async () => {
58+
const data = mockInspectResult({ "8080/tcp": [] }, { "8080/tcp": [] });
59+
const inspectFn = vi.fn().mockResolvedValue(data);
60+
61+
await expect(inspectContainerUntilPortsExposed(inspectFn, "container-id", 0)).rejects.toThrow(
62+
"Timed out after 0ms while waiting for container ports to be bound to the host"
63+
);
64+
});
6465

65-
test("throws an error when container ports not exposed within timeout", async () => {
66-
const data = mockInspectResult({});
67-
const inspectFn = vi.fn().mockResolvedValue(data.inspectResult);
66+
it("throws an error when container ports not exposed within timeout", async () => {
67+
const data = mockInspectResult({ "8080/tcp": [] }, {});
68+
const inspectFn = vi.fn().mockResolvedValue(data);
6869

69-
await expect(inspectContainerUntilPortsExposed(inspectFn, [8080], "container-id", 0)).rejects.toThrow(
70-
"Container did not expose all ports after starting"
71-
);
70+
await expect(inspectContainerUntilPortsExposed(inspectFn, "container-id", 0)).rejects.toThrow(
71+
"Timed out after 0ms while waiting for container ports to be bound to the host"
72+
);
73+
});
7274
});

packages/testcontainers/src/generic-container/inspect-container-util-ports-exposed.ts

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,21 @@
11
import { ContainerInspectInfo } from "dockerode";
22
import { IntervalRetry, log } from "../common";
3-
import { InspectResult } from "../types";
4-
import { mapInspectResult } from "../utils/map-inspect-result";
5-
import { getContainerPort, PortWithOptionalBinding } from "../utils/port";
6-
7-
type Result = {
8-
inspectResult: ContainerInspectInfo;
9-
mappedInspectResult: InspectResult;
10-
};
113

124
export async function inspectContainerUntilPortsExposed(
135
inspectFn: () => Promise<ContainerInspectInfo>,
14-
ports: PortWithOptionalBinding[],
156
containerId: string,
167
timeout = 10_000
17-
): Promise<Result> {
18-
const result = await new IntervalRetry<Result, Error>(250).retryUntil(
19-
async () => {
20-
const inspectResult = await inspectFn();
21-
const mappedInspectResult = mapInspectResult(inspectResult);
22-
return { inspectResult, mappedInspectResult };
8+
): Promise<ContainerInspectInfo> {
9+
const result = await new IntervalRetry<ContainerInspectInfo, Error>(250).retryUntil(
10+
() => inspectFn(),
11+
(inspectResult) => {
12+
const portBindings = inspectResult?.HostConfig?.PortBindings;
13+
if (!portBindings) return true;
14+
const expectedlyBoundPorts = Object.keys(portBindings);
15+
return expectedlyBoundPorts.every((exposedPort) => inspectResult.NetworkSettings.Ports[exposedPort]?.length > 0);
2316
},
24-
({ mappedInspectResult }) =>
25-
ports
26-
.map((exposedPort) => getContainerPort(exposedPort))
27-
.every((exposedPort) => mappedInspectResult.ports[exposedPort]?.length > 0),
2817
() => {
29-
const message = `Container did not expose all ports after starting`;
18+
const message = `Timed out after ${timeout}ms while waiting for container ports to be bound to the host`;
3019
log.error(message, { containerId });
3120
return new Error(message);
3221
},

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels";
1212
import { mapInspectResult } from "../utils/map-inspect-result";
1313
import { waitForContainer } from "../wait-strategies/wait-for-container";
1414
import { WaitStrategy } from "../wait-strategies/wait-strategy";
15+
import { inspectContainerUntilPortsExposed } from "./inspect-container-util-ports-exposed";
1516
import { StoppedGenericContainer } from "./stopped-generic-container";
1617

1718
export class StartedGenericContainer implements StartedTestContainer {
@@ -80,7 +81,10 @@ export class StartedGenericContainer implements StartedTestContainer {
8081
const resolvedOptions: RestartOptions = { timeout: 0, ...options };
8182
await client.container.restart(this.container, resolvedOptions);
8283

83-
this.inspectResult = await client.container.inspect(this.container);
84+
this.inspectResult = await inspectContainerUntilPortsExposed(
85+
() => client.container.inspect(this.container),
86+
this.container.id
87+
);
8488
const mappedInspectResult = mapInspectResult(this.inspectResult);
8589
const startTime = new Date(this.inspectResult.State.StartedAt);
8690

0 commit comments

Comments
 (0)