diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2e1d25818..563a15a11 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -124,8 +124,12 @@ jobs: run: npm prune --omit=dev --workspace packages/testcontainers - name: Run CommonJS module smoke test run: node packages/testcontainers/smoke-test.js + env: + DEBUG: "testcontainers*" - name: Run ES module smoke test run: node packages/testcontainers/smoke-test.mjs + env: + DEBUG: "testcontainers*" test: if: ${{ needs.detect-modules.outputs.modules_count > 0 }} diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 7c2ec2358..93ae90792 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -142,13 +142,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); - inspectResult = ( - await inspectContainerUntilPortsExposed( - () => client.container.inspect(container), - this.exposedPorts, - container.id - ) - ).inspectResult; + inspectResult = await inspectContainerUntilPortsExposed(() => client.container.inspect(container), container.id); } const mappedInspectResult = mapInspectResult(inspectResult); @@ -202,11 +196,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 inspectContainerUntilPortsExposed( + const inspectResult = await inspectContainerUntilPortsExposed( () => client.container.inspect(container), - this.exposedPorts, container.id ); + const mappedInspectResult = mapInspectResult(inspectResult); const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter( this.exposedPorts ); 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 b198bb02d..dea6280d3 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 @@ -1,72 +1,74 @@ import { ContainerInspectInfo } from "dockerode"; -import { mapInspectResult } from "../utils/map-inspect-result"; import { inspectContainerUntilPortsExposed } from "./inspect-container-util-ports-exposed"; -function mockInspectResult(ports: ContainerInspectInfo["NetworkSettings"]["Ports"]) { - 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(), +function mockInspectResult( + portBindings: ContainerInspectInfo["HostConfig"]["PortBindings"], + ports: ContainerInspectInfo["NetworkSettings"]["Ports"] +): ContainerInspectInfo { + return { + HostConfig: { + PortBindings: portBindings, }, NetworkSettings: { Ports: ports, - Networks: {}, }, - } as unknown as ContainerInspectInfo; - - return { inspectResult, mappedInspectResult: mapInspectResult(inspectResult) }; + } as ContainerInspectInfo; } -test("returns the inspect results when all ports are exposed", async () => { - const data = mockInspectResult({ "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "45000" }] }); - const inspectFn = vi.fn().mockResolvedValueOnce(data.inspectResult); +describe.sequential("inspectContainerUntilPortsExposed", () => { + it("returns the inspect result when all ports are exposed", async () => { + const data = mockInspectResult({ "8080/tcp": [] }, { "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "45000" }] }); + const inspectFn = vi.fn().mockResolvedValueOnce(data); - const result = await inspectContainerUntilPortsExposed(inspectFn, [8080], "container-id"); + const result = await inspectContainerUntilPortsExposed(inspectFn, "container-id"); - expect(result).toEqual(data); -}); + expect(result).toEqual(data); + }); -test("retries the inspect if ports are not yet exposed", async () => { - const data1 = mockInspectResult({ "8080/tcp": [] }); - const data2 = mockInspectResult({ "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "45000" }] }); - const inspectFn = vi - .fn() - .mockResolvedValueOnce(data1.inspectResult) - .mockResolvedValueOnce(data1.inspectResult) - .mockResolvedValueOnce(data2.inspectResult); + it("returns the inspect result when no ports are exposed", async () => { + const data = mockInspectResult({}, {}); + const inspectFn = vi.fn().mockResolvedValueOnce(data); - const result = await inspectContainerUntilPortsExposed(inspectFn, [8080], "container-id"); + const result = await inspectContainerUntilPortsExposed(inspectFn, "container-id"); - expect(result).toEqual(data2); - expect(inspectFn).toHaveBeenCalledTimes(3); -}); + expect(result).toEqual(data); + }); -test("throws an error when host ports are not exposed within timeout", async () => { - const data = mockInspectResult({ "8080/tcp": [] }); - const inspectFn = vi.fn().mockResolvedValue(data.inspectResult); + it("returns the inspect result if host config port bindings are null", async () => { + const data = mockInspectResult(null, {}); + const inspectFn = vi.fn().mockResolvedValueOnce(data); - await expect(inspectContainerUntilPortsExposed(inspectFn, [8080], "container-id", 0)).rejects.toThrow( - "Container did not expose all ports after starting" - ); -}); + const result = await inspectContainerUntilPortsExposed(inspectFn, "container-id"); + + expect(result).toEqual(data); + }); + + it("retries the inspect if ports are not yet exposed", async () => { + const data1 = mockInspectResult({ "8080/tcp": [] }, { "8080/tcp": [] }); + const data2 = mockInspectResult({ "8080/tcp": [] }, { "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "45000" }] }); + const inspectFn = vi.fn().mockResolvedValueOnce(data1).mockResolvedValueOnce(data1).mockResolvedValueOnce(data2); + + const result = await inspectContainerUntilPortsExposed(inspectFn, "container-id"); + + expect(result).toEqual(data2); + expect(inspectFn).toHaveBeenCalledTimes(3); + }); + + it("throws an error when host ports are not exposed within timeout", async () => { + const data = mockInspectResult({ "8080/tcp": [] }, { "8080/tcp": [] }); + const inspectFn = vi.fn().mockResolvedValue(data); + + await expect(inspectContainerUntilPortsExposed(inspectFn, "container-id", 0)).rejects.toThrow( + "Timed out after 0ms while waiting for container ports to be bound to the host" + ); + }); -test("throws an error when container ports not exposed within timeout", async () => { - const data = mockInspectResult({}); - const inspectFn = vi.fn().mockResolvedValue(data.inspectResult); + it("throws an error when container ports not exposed within timeout", async () => { + const data = mockInspectResult({ "8080/tcp": [] }, {}); + const inspectFn = vi.fn().mockResolvedValue(data); - await expect(inspectContainerUntilPortsExposed(inspectFn, [8080], "container-id", 0)).rejects.toThrow( - "Container did not expose all ports after starting" - ); + await expect(inspectContainerUntilPortsExposed(inspectFn, "container-id", 0)).rejects.toThrow( + "Timed out after 0ms while waiting for container ports to be bound to the host" + ); + }); }); 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 762154633..8209144c7 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 @@ -1,32 +1,21 @@ 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 = 10_000 -): Promise { - const result = await new IntervalRetry(250).retryUntil( - async () => { - const inspectResult = await inspectFn(); - const mappedInspectResult = mapInspectResult(inspectResult); - return { inspectResult, mappedInspectResult }; +): Promise { + const result = await new IntervalRetry(250).retryUntil( + () => inspectFn(), + (inspectResult) => { + const portBindings = inspectResult?.HostConfig?.PortBindings; + if (!portBindings) return true; + const expectedlyBoundPorts = Object.keys(portBindings); + return expectedlyBoundPorts.every((exposedPort) => inspectResult.NetworkSettings.Ports[exposedPort]?.length > 0); }, - ({ mappedInspectResult }) => - ports - .map((exposedPort) => getContainerPort(exposedPort)) - .every((exposedPort) => mappedInspectResult.ports[exposedPort]?.length > 0), () => { - const message = `Container did not expose all ports after starting`; + const message = `Timed out after ${timeout}ms while waiting for container ports to be bound to the host`; log.error(message, { containerId }); return new Error(message); }, diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index 895faead8..771b3bfb9 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -12,6 +12,7 @@ import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels"; import { mapInspectResult } from "../utils/map-inspect-result"; import { waitForContainer } from "../wait-strategies/wait-for-container"; import { WaitStrategy } from "../wait-strategies/wait-strategy"; +import { inspectContainerUntilPortsExposed } from "./inspect-container-util-ports-exposed"; import { StoppedGenericContainer } from "./stopped-generic-container"; export class StartedGenericContainer implements StartedTestContainer { @@ -80,7 +81,10 @@ export class StartedGenericContainer implements StartedTestContainer { const resolvedOptions: RestartOptions = { timeout: 0, ...options }; await client.container.restart(this.container, resolvedOptions); - this.inspectResult = await client.container.inspect(this.container); + this.inspectResult = await inspectContainerUntilPortsExposed( + () => client.container.inspect(this.container), + this.container.id + ); const mappedInspectResult = mapInspectResult(this.inspectResult); const startTime = new Date(this.inspectResult.State.StartedAt);