diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index ae2baf35d..7c2ec2358 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -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(); @@ -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); @@ -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 ); @@ -361,7 +370,7 @@ export class GenericContainer implements TestContainer { public withExposedPorts(...ports: PortWithOptionalBinding[]): this { const exposedPorts: { [port: string]: Record } = {}; for (const exposedPort of ports) { - exposedPorts[getContainerPort(exposedPort).toString()] = {}; + exposedPorts[`${getContainerPort(exposedPort).toString()}/tcp`] = {}; } this.exposedPorts = [...this.exposedPorts, ...ports]; @@ -373,9 +382,9 @@ export class GenericContainer implements TestContainer { const portBindings: Record>> = {}; 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" }]; } } diff --git a/packages/testcontainers/src/generic-container/inspect-container-util-ports-exposed.test.ts b/packages/testcontainers/src/generic-container/inspect-container-util-ports-exposed.test.ts new file mode 100644 index 000000000..bbe7a9b7f --- /dev/null +++ b/packages/testcontainers/src/generic-container/inspect-container-util-ports-exposed.test.ts @@ -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" + ); +}); diff --git a/packages/testcontainers/src/generic-container/inspect-container-util-ports-exposed.ts b/packages/testcontainers/src/generic-container/inspect-container-util-ports-exposed.ts new file mode 100644 index 000000000..2fec27cd5 --- /dev/null +++ b/packages/testcontainers/src/generic-container/inspect-container-util-ports-exposed.ts @@ -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, + ports: PortWithOptionalBinding[], + containerId: string, + timeout = 5000 +): Promise { + const result = await new IntervalRetry(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; +}