From 212f7f95a59cc21522cf48dd0c4990671d4649d4 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Fri, 6 Jun 2025 18:07:46 +0100 Subject: [PATCH 1/4] Retry inspecting container until exposed ports are mapped --- .../generic-container/generic-container.ts | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index ae2baf35d..3669a7285 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -1,8 +1,8 @@ import archiver from "archiver"; import AsyncLock from "async-lock"; -import { Container, ContainerCreateOptions, HostConfig } from "dockerode"; +import { Container, ContainerCreateOptions, ContainerInspectInfo, HostConfig } from "dockerode"; import { Readable } from "stream"; -import { containerLog, hash, log, toNanos } from "../common"; +import { containerLog, hash, IntervalRetry, log, toNanos } from "../common"; import { ContainerRuntimeClient, getContainerRuntimeClient, ImageName } from "../container-runtime"; import { CONTAINER_STATUSES } from "../container-runtime/clients/container/types"; import { StartedNetwork } from "../network/network"; @@ -141,8 +141,7 @@ 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 this.inspectContainer(client, container)).inspectResult; } const mappedInspectResult = mapInspectResult(inspectResult); @@ -196,8 +195,7 @@ 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 this.inspectContainer(client, container); const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter( this.exposedPorts ); @@ -241,6 +239,48 @@ export class GenericContainer implements TestContainer { return startedContainer; } + private async inspectContainer( + client: ContainerRuntimeClient, + container: Container + ): Promise<{ + inspectResult: ContainerInspectInfo; + mappedInspectResult: InspectResult; + }> { + const containerInspectRetry = await new IntervalRetry< + { + inspectResult: ContainerInspectInfo; + mappedInspectResult: InspectResult; + }, + Error + >(100).retryUntil( + async () => { + const inspectResult = await client.container.inspect(container); + const mappedInspectResult = mapInspectResult(inspectResult); + return { inspectResult, mappedInspectResult }; + }, + ({ mappedInspectResult }) => + this.exposedPorts + .map((exposedPort) => getContainerPort(exposedPort)) + .every( + (exposedPort) => + mappedInspectResult.ports[exposedPort].length > 0 && + mappedInspectResult.ports[exposedPort].every(({ hostPort }) => hostPort !== undefined) + ), + () => { + const message = `Container did not expose all ports after starting`; + log.error(message, { containerId: container.id }); + return new Error(message); + }, + 3000 + ); + + if (containerInspectRetry instanceof Error) { + throw containerInspectRetry; + } + + return containerInspectRetry; + } + private async connectContainerToPortForwarder(client: ContainerRuntimeClient, container: Container) { const portForwarder = await PortForwarderInstance.getInstance(); const portForwarderNetworkId = portForwarder.getNetworkId(); @@ -361,7 +401,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 +413,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" }]; } } From ce55fba76872f2b291b6233867b05187911662d1 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Fri, 6 Jun 2025 18:56:36 +0100 Subject: [PATCH 2/4] Refactor + add tests --- .../generic-container/generic-container.ts | 61 +++---------- ...spect-container-util-ports-exposed.test.ts | 91 +++++++++++++++++++ .../inspect-container-util-ports-exposed.ts | 45 +++++++++ 3 files changed, 151 insertions(+), 46 deletions(-) create mode 100644 packages/testcontainers/src/generic-container/inspect-container-util-ports-exposed.test.ts create mode 100644 packages/testcontainers/src/generic-container/inspect-container-util-ports-exposed.ts diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 3669a7285..7c2ec2358 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -1,8 +1,8 @@ import archiver from "archiver"; import AsyncLock from "async-lock"; -import { Container, ContainerCreateOptions, ContainerInspectInfo, HostConfig } from "dockerode"; +import { Container, ContainerCreateOptions, HostConfig } from "dockerode"; import { Readable } from "stream"; -import { containerLog, hash, IntervalRetry, log, toNanos } from "../common"; +import { containerLog, hash, log, toNanos } from "../common"; import { ContainerRuntimeClient, getContainerRuntimeClient, ImageName } from "../container-runtime"; import { CONTAINER_STATUSES } from "../container-runtime/clients/container/types"; import { StartedNetwork } from "../network/network"; @@ -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,7 +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); - inspectResult = (await this.inspectContainer(client, container)).inspectResult; + inspectResult = ( + await inspectContainerUntilPortsExposed( + () => client.container.inspect(container), + this.exposedPorts, + container.id + ) + ).inspectResult; } const mappedInspectResult = mapInspectResult(inspectResult); @@ -195,7 +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, mappedInspectResult } = await this.inspectContainer(client, container); + 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 ); @@ -239,48 +250,6 @@ export class GenericContainer implements TestContainer { return startedContainer; } - private async inspectContainer( - client: ContainerRuntimeClient, - container: Container - ): Promise<{ - inspectResult: ContainerInspectInfo; - mappedInspectResult: InspectResult; - }> { - const containerInspectRetry = await new IntervalRetry< - { - inspectResult: ContainerInspectInfo; - mappedInspectResult: InspectResult; - }, - Error - >(100).retryUntil( - async () => { - const inspectResult = await client.container.inspect(container); - const mappedInspectResult = mapInspectResult(inspectResult); - return { inspectResult, mappedInspectResult }; - }, - ({ mappedInspectResult }) => - this.exposedPorts - .map((exposedPort) => getContainerPort(exposedPort)) - .every( - (exposedPort) => - mappedInspectResult.ports[exposedPort].length > 0 && - mappedInspectResult.ports[exposedPort].every(({ hostPort }) => hostPort !== undefined) - ), - () => { - const message = `Container did not expose all ports after starting`; - log.error(message, { containerId: container.id }); - return new Error(message); - }, - 3000 - ); - - if (containerInspectRetry instanceof Error) { - throw containerInspectRetry; - } - - return containerInspectRetry; - } - private async connectContainerToPortForwarder(client: ContainerRuntimeClient, container: Container) { const portForwarder = await PortForwarderInstance.getInstance(); const portForwarderNetworkId = portForwarder.getNetworkId(); 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..2b283ce5d --- /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 ", 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..10ac0fa49 --- /dev/null +++ b/packages/testcontainers/src/generic-container/inspect-container-util-ports-exposed.ts @@ -0,0 +1,45 @@ +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 && + mappedInspectResult.ports[exposedPort].every(({ hostPort }) => hostPort !== undefined) + ), + () => { + 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; +} From 1ef4f596f82ee6461c7827e5d6c63e2030c8b666 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Fri, 6 Jun 2025 19:03:38 +0100 Subject: [PATCH 3/4] Update test name --- .../inspect-container-util-ports-exposed.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 2b283ce5d..bbe7a9b7f 100644 --- 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 @@ -81,7 +81,7 @@ test("retries the inspect if ports are not yet exposed", async () => { expect(inspectFn).toHaveBeenCalledTimes(3); }); -test("throws an error ", async () => { +test("throws an error when ports are not exposed within timeout", async () => { const data = mockNotExposed(); const inspectFn = vi.fn().mockResolvedValue(data.inspectResult); From 7d5a2261315d8b68e0eb138194830a971a912ea9 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Fri, 6 Jun 2025 19:08:55 +0100 Subject: [PATCH 4/4] Simplify predicate --- .../inspect-container-util-ports-exposed.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 index 10ac0fa49..2fec27cd5 100644 --- a/packages/testcontainers/src/generic-container/inspect-container-util-ports-exposed.ts +++ b/packages/testcontainers/src/generic-container/inspect-container-util-ports-exposed.ts @@ -24,11 +24,7 @@ export async function inspectContainerUntilPortsExposed( ({ mappedInspectResult }) => ports .map((exposedPort) => getContainerPort(exposedPort)) - .every( - (exposedPort) => - mappedInspectResult.ports[exposedPort].length > 0 && - mappedInspectResult.ports[exposedPort].every(({ hostPort }) => hostPort !== undefined) - ), + .every((exposedPort) => mappedInspectResult.ports[exposedPort].length > 0), () => { const message = `Container did not expose all ports after starting`; log.error(message, { containerId });