Skip to content

Commit f5afa05

Browse files
Retry inspecting container until exposed ports are mapped (#1032)
1 parent 282d3fe commit f5afa05

File tree

3 files changed

+148
-7
lines changed

3 files changed

+148
-7
lines changed

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

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { Wait } from "../wait-strategies/wait";
3333
import { waitForContainer } from "../wait-strategies/wait-for-container";
3434
import { WaitStrategy } from "../wait-strategies/wait-strategy";
3535
import { GenericContainerBuilder } from "./generic-container-builder";
36+
import { inspectContainerUntilPortsExposed } from "./inspect-container-util-ports-exposed";
3637
import { StartedGenericContainer } from "./started-generic-container";
3738

3839
const reusableContainerCreationLock = new AsyncLock();
@@ -141,8 +142,13 @@ export class GenericContainer implements TestContainer {
141142
if (!inspectResult.State.Running) {
142143
log.debug("Reused container is not running, attempting to start it");
143144
await client.container.start(container);
144-
// Refetch the inspect result to get the updated state
145-
inspectResult = await client.container.inspect(container);
145+
inspectResult = (
146+
await inspectContainerUntilPortsExposed(
147+
() => client.container.inspect(container),
148+
this.exposedPorts,
149+
container.id
150+
)
151+
).inspectResult;
146152
}
147153

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

199-
const inspectResult = await client.container.inspect(container);
200-
const mappedInspectResult = mapInspectResult(inspectResult);
205+
const { inspectResult, mappedInspectResult } = await inspectContainerUntilPortsExposed(
206+
() => client.container.inspect(container),
207+
this.exposedPorts,
208+
container.id
209+
);
201210
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter(
202211
this.exposedPorts
203212
);
@@ -361,7 +370,7 @@ export class GenericContainer implements TestContainer {
361370
public withExposedPorts(...ports: PortWithOptionalBinding[]): this {
362371
const exposedPorts: { [port: string]: Record<string, never> } = {};
363372
for (const exposedPort of ports) {
364-
exposedPorts[getContainerPort(exposedPort).toString()] = {};
373+
exposedPorts[`${getContainerPort(exposedPort).toString()}/tcp`] = {};
365374
}
366375

367376
this.exposedPorts = [...this.exposedPorts, ...ports];
@@ -373,9 +382,9 @@ export class GenericContainer implements TestContainer {
373382
const portBindings: Record<string, Array<Record<string, string>>> = {};
374383
for (const exposedPort of ports) {
375384
if (hasHostBinding(exposedPort)) {
376-
portBindings[exposedPort.container] = [{ HostPort: exposedPort.host.toString() }];
385+
portBindings[`${exposedPort.container}/tcp`] = [{ HostPort: exposedPort.host.toString() }];
377386
} else {
378-
portBindings[exposedPort] = [{ HostPort: "0" }];
387+
portBindings[`${exposedPort}/tcp`] = [{ HostPort: "0" }];
379388
}
380389
}
381390

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { ContainerInspectInfo } from "dockerode";
2+
import { InspectResult } from "../types";
3+
import { mapInspectResult } from "../utils/map-inspect-result";
4+
import { inspectContainerUntilPortsExposed } from "./inspect-container-util-ports-exposed";
5+
6+
function mockExposed(): { inspectResult: ContainerInspectInfo; mappedInspectResult: InspectResult } {
7+
const date = new Date();
8+
9+
const inspectResult: ContainerInspectInfo = {
10+
Name: "container-id",
11+
Config: {
12+
Hostname: "hostname",
13+
Labels: {},
14+
},
15+
State: {
16+
Health: {
17+
Status: "healthy",
18+
},
19+
Status: "running",
20+
Running: true,
21+
StartedAt: date.toISOString(),
22+
FinishedAt: date.toISOString(),
23+
},
24+
NetworkSettings: {
25+
Ports: { "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "45000" }] },
26+
Networks: {},
27+
},
28+
} as unknown as ContainerInspectInfo;
29+
30+
return { inspectResult, mappedInspectResult: mapInspectResult(inspectResult) };
31+
}
32+
33+
function mockNotExposed(): { inspectResult: ContainerInspectInfo; mappedInspectResult: InspectResult } {
34+
const date = new Date();
35+
36+
const inspectResult: ContainerInspectInfo = {
37+
Name: "container-id",
38+
Config: {
39+
Hostname: "hostname",
40+
Labels: {},
41+
},
42+
State: {
43+
Health: {
44+
Status: "healthy",
45+
},
46+
Status: "running",
47+
Running: true,
48+
StartedAt: date.toISOString(),
49+
FinishedAt: date.toISOString(),
50+
},
51+
NetworkSettings: {
52+
Ports: { "8080/tcp": [] },
53+
Networks: {},
54+
},
55+
} as unknown as ContainerInspectInfo;
56+
57+
return { inspectResult, mappedInspectResult: mapInspectResult(inspectResult) };
58+
}
59+
60+
test("returns the inspect results when all ports are exposed", async () => {
61+
const data = mockExposed();
62+
const inspectFn = vi.fn().mockResolvedValueOnce(data.inspectResult);
63+
64+
const result = await inspectContainerUntilPortsExposed(inspectFn, [8080], "container-id");
65+
66+
expect(result).toEqual(data);
67+
});
68+
69+
test("retries the inspect if ports are not yet exposed", async () => {
70+
const data1 = mockNotExposed();
71+
const data2 = mockExposed();
72+
const inspectFn = vi
73+
.fn()
74+
.mockResolvedValueOnce(data1.inspectResult)
75+
.mockResolvedValueOnce(data1.inspectResult)
76+
.mockResolvedValueOnce(data2.inspectResult);
77+
78+
const result = await inspectContainerUntilPortsExposed(inspectFn, [8080], "container-id");
79+
80+
expect(result).toEqual(data2);
81+
expect(inspectFn).toHaveBeenCalledTimes(3);
82+
});
83+
84+
test("throws an error when ports are not exposed within timeout", async () => {
85+
const data = mockNotExposed();
86+
const inspectFn = vi.fn().mockResolvedValue(data.inspectResult);
87+
88+
await expect(inspectContainerUntilPortsExposed(inspectFn, [8080], "container-id", 0)).rejects.toThrow(
89+
"Container did not expose all ports after starting"
90+
);
91+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { ContainerInspectInfo } from "dockerode";
2+
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+
};
11+
12+
export async function inspectContainerUntilPortsExposed(
13+
inspectFn: () => Promise<ContainerInspectInfo>,
14+
ports: PortWithOptionalBinding[],
15+
containerId: string,
16+
timeout = 5000
17+
): Promise<Result> {
18+
const result = await new IntervalRetry<Result, Error>(100).retryUntil(
19+
async () => {
20+
const inspectResult = await inspectFn();
21+
const mappedInspectResult = mapInspectResult(inspectResult);
22+
return { inspectResult, mappedInspectResult };
23+
},
24+
({ mappedInspectResult }) =>
25+
ports
26+
.map((exposedPort) => getContainerPort(exposedPort))
27+
.every((exposedPort) => mappedInspectResult.ports[exposedPort].length > 0),
28+
() => {
29+
const message = `Container did not expose all ports after starting`;
30+
log.error(message, { containerId });
31+
return new Error(message);
32+
},
33+
timeout
34+
);
35+
36+
if (result instanceof Error) {
37+
throw result;
38+
}
39+
40+
return result;
41+
}

0 commit comments

Comments
 (0)