Skip to content
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
23 changes: 16 additions & 7 deletions packages/testcontainers/src/generic-container/generic-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { Wait } from "../wait-strategies/wait";
import { waitForContainer } from "../wait-strategies/wait-for-container";
import { WaitStrategy } from "../wait-strategies/wait-strategy";
import { GenericContainerBuilder } from "./generic-container-builder";
import { inspectContainerUntilPortsExposed } from "./inspect-container-util-ports-exposed";
import { StartedGenericContainer } from "./started-generic-container";

const reusableContainerCreationLock = new AsyncLock();
Expand Down Expand Up @@ -141,8 +142,13 @@ export class GenericContainer implements TestContainer {
if (!inspectResult.State.Running) {
log.debug("Reused container is not running, attempting to start it");
await client.container.start(container);
// Refetch the inspect result to get the updated state
inspectResult = await client.container.inspect(container);
inspectResult = (
await inspectContainerUntilPortsExposed(
() => client.container.inspect(container),
this.exposedPorts,
container.id
)
).inspectResult;
}

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

const inspectResult = await client.container.inspect(container);
const mappedInspectResult = mapInspectResult(inspectResult);
const { inspectResult, mappedInspectResult } = await inspectContainerUntilPortsExposed(
() => client.container.inspect(container),
this.exposedPorts,
container.id
);
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter(
this.exposedPorts
);
Expand Down Expand Up @@ -361,7 +370,7 @@ export class GenericContainer implements TestContainer {
public withExposedPorts(...ports: PortWithOptionalBinding[]): this {
const exposedPorts: { [port: string]: Record<string, never> } = {};
for (const exposedPort of ports) {
exposedPorts[getContainerPort(exposedPort).toString()] = {};
exposedPorts[`${getContainerPort(exposedPort).toString()}/tcp`] = {};
}

this.exposedPorts = [...this.exposedPorts, ...ports];
Expand All @@ -373,9 +382,9 @@ export class GenericContainer implements TestContainer {
const portBindings: Record<string, Array<Record<string, string>>> = {};
for (const exposedPort of ports) {
if (hasHostBinding(exposedPort)) {
portBindings[exposedPort.container] = [{ HostPort: exposedPort.host.toString() }];
portBindings[`${exposedPort.container}/tcp`] = [{ HostPort: exposedPort.host.toString() }];
} else {
portBindings[exposedPort] = [{ HostPort: "0" }];
portBindings[`${exposedPort}/tcp`] = [{ HostPort: "0" }];
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { ContainerInspectInfo } from "dockerode";
import { InspectResult } from "../types";
import { mapInspectResult } from "../utils/map-inspect-result";
import { inspectContainerUntilPortsExposed } from "./inspect-container-util-ports-exposed";

function mockExposed(): { inspectResult: ContainerInspectInfo; mappedInspectResult: InspectResult } {
const date = new Date();

const inspectResult: ContainerInspectInfo = {
Name: "container-id",
Config: {
Hostname: "hostname",
Labels: {},
},
State: {
Health: {
Status: "healthy",
},
Status: "running",
Running: true,
StartedAt: date.toISOString(),
FinishedAt: date.toISOString(),
},
NetworkSettings: {
Ports: { "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "45000" }] },
Networks: {},
},
} as unknown as ContainerInspectInfo;

return { inspectResult, mappedInspectResult: mapInspectResult(inspectResult) };
}

function mockNotExposed(): { inspectResult: ContainerInspectInfo; mappedInspectResult: InspectResult } {
const date = new Date();

const inspectResult: ContainerInspectInfo = {
Name: "container-id",
Config: {
Hostname: "hostname",
Labels: {},
},
State: {
Health: {
Status: "healthy",
},
Status: "running",
Running: true,
StartedAt: date.toISOString(),
FinishedAt: date.toISOString(),
},
NetworkSettings: {
Ports: { "8080/tcp": [] },
Networks: {},
},
} as unknown as ContainerInspectInfo;

return { inspectResult, mappedInspectResult: mapInspectResult(inspectResult) };
}

test("returns the inspect results when all ports are exposed", async () => {
const data = mockExposed();
const inspectFn = vi.fn().mockResolvedValueOnce(data.inspectResult);

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

expect(result).toEqual(data);
});

test("retries the inspect if ports are not yet exposed", async () => {
const data1 = mockNotExposed();
const data2 = mockExposed();
const inspectFn = vi
.fn()
.mockResolvedValueOnce(data1.inspectResult)
.mockResolvedValueOnce(data1.inspectResult)
.mockResolvedValueOnce(data2.inspectResult);

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

expect(result).toEqual(data2);
expect(inspectFn).toHaveBeenCalledTimes(3);
});

test("throws an error when ports are not exposed within timeout", async () => {
const data = mockNotExposed();
const inspectFn = vi.fn().mockResolvedValue(data.inspectResult);

await expect(inspectContainerUntilPortsExposed(inspectFn, [8080], "container-id", 0)).rejects.toThrow(
"Container did not expose all ports after starting"
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { ContainerInspectInfo } from "dockerode";
import { IntervalRetry, log } from "../common";
import { InspectResult } from "../types";
import { mapInspectResult } from "../utils/map-inspect-result";
import { getContainerPort, PortWithOptionalBinding } from "../utils/port";

type Result = {
inspectResult: ContainerInspectInfo;
mappedInspectResult: InspectResult;
};

export async function inspectContainerUntilPortsExposed(
inspectFn: () => Promise<ContainerInspectInfo>,
ports: PortWithOptionalBinding[],
containerId: string,
timeout = 5000
): Promise<Result> {
const result = await new IntervalRetry<Result, Error>(100).retryUntil(
async () => {
const inspectResult = await inspectFn();
const mappedInspectResult = mapInspectResult(inspectResult);
return { inspectResult, mappedInspectResult };
},
({ mappedInspectResult }) =>
ports
.map((exposedPort) => getContainerPort(exposedPort))
.every((exposedPort) => mappedInspectResult.ports[exposedPort].length > 0),
() => {
const message = `Container did not expose all ports after starting`;
log.error(message, { containerId });
return new Error(message);
},
timeout
);

if (result instanceof Error) {
throw result;
}

return result;
}