diff --git a/docs/features/containers.md b/docs/features/containers.md index 07b37023f..80a66ad68 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -572,6 +572,31 @@ const container = await new GenericContainer("alpine") const httpPort = container.getFirstMappedPort(); ``` +Specify a protocol for the exposed port: + +```javascript +const container = await new GenericContainer("alpine") + .withExposedPorts({ + container: 80, + protocol: "udp" + }) + .start(); + +const httpPort = container.getMappedPort(80, "udp"); +``` + +Alternatively, specify the protocol using a string with the format `port/protocol`: + +```javascript +const container = await new GenericContainer("alpine") + .withExposedPorts("80/udp") + .start(); + +const httpPort = container.getMappedPort("80/udp"); +``` + +If no protocol is specified, it defaults to `tcp`. + Specify fixed host port bindings (**not recommended**): ```javascript @@ -609,12 +634,12 @@ expect(response.status).toBe(200); expect(await response.text()).toBe("PONG"); ``` -The example above starts a `testcontainers/helloworld` container and a `socat` container. +The example above starts a `testcontainers/helloworld` container and a `socat` container. The `socat` container is configured to forward traffic from port `8081` to the `testcontainers/helloworld` container on port `8080`. ## Running commands -To run a command inside an already started container, use the exec method. +To run a command inside an already started container, use the exec method. The command will be run in the container's working directory, returning the combined output (`output`), standard output (`stdout`), standard error (`stderr`), and exit code (`exitCode`). diff --git a/packages/modules/azurite/src/azurite-container.ts b/packages/modules/azurite/src/azurite-container.ts index 4c8c79b1f..0221b9eaa 100755 --- a/packages/modules/azurite/src/azurite-container.ts +++ b/packages/modules/azurite/src/azurite-container.ts @@ -1,6 +1,7 @@ import { AbstractStartedContainer, GenericContainer, + getContainerPort, hasHostBinding, PortWithOptionalBinding, StartedTestContainer, @@ -171,7 +172,8 @@ export class StartedAzuriteContainer extends AbstractStartedContainer { if (hasHostBinding(this.blobPort)) { return this.blobPort.host; } else { - return this.getMappedPort(this.blobPort); + const containerPort = getContainerPort(this.blobPort); + return this.getMappedPort(containerPort); } } @@ -179,7 +181,8 @@ export class StartedAzuriteContainer extends AbstractStartedContainer { if (hasHostBinding(this.queuePort)) { return this.queuePort.host; } else { - return this.getMappedPort(this.queuePort); + const containerPort = getContainerPort(this.queuePort); + return this.getMappedPort(containerPort); } } @@ -187,7 +190,8 @@ export class StartedAzuriteContainer extends AbstractStartedContainer { if (hasHostBinding(this.tablePort)) { return this.tablePort.host; } else { - return this.getMappedPort(this.tablePort); + const containerPort = getContainerPort(this.tablePort); + return this.getMappedPort(containerPort); } } diff --git a/packages/testcontainers/src/generic-container/abstract-started-container.ts b/packages/testcontainers/src/generic-container/abstract-started-container.ts index 92ce5bc4d..4ba5ccce5 100644 --- a/packages/testcontainers/src/generic-container/abstract-started-container.ts +++ b/packages/testcontainers/src/generic-container/abstract-started-container.ts @@ -43,7 +43,12 @@ export class AbstractStartedContainer implements StartedTestContainer { return this.startedTestContainer.getFirstMappedPort(); } - public getMappedPort(port: number): number { + public getMappedPort(port: number, protocol?: string): number; + public getMappedPort(portWithProtocol: `${number}/${"tcp" | "udp"}`): number; + public getMappedPort(port: number | `${number}/${"tcp" | "udp"}`, protocol?: string): number { + if (typeof port === "number") { + return this.startedTestContainer.getMappedPort(port, protocol); + } return this.startedTestContainer.getMappedPort(port); } diff --git a/packages/testcontainers/src/generic-container/generic-container.test.ts b/packages/testcontainers/src/generic-container/generic-container.test.ts index e84f7653d..7b7346015 100644 --- a/packages/testcontainers/src/generic-container/generic-container.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container.test.ts @@ -5,10 +5,12 @@ import { getContainerRuntimeClient } from "../container-runtime"; import { PullPolicy } from "../utils/pull-policy"; import { checkContainerIsHealthy, + checkContainerIsHealthyUdp, getDockerEventStream, getRunningContainerNames, waitForDockerEvent, } from "../utils/test-helper"; +import { Wait } from "../wait-strategies/wait"; import { GenericContainer } from "./generic-container"; describe("GenericContainer", { timeout: 180_000 }, () => { @@ -22,6 +24,17 @@ describe("GenericContainer", { timeout: 180_000 }, () => { expect(container.getFirstMappedPort()).toBe(container.getMappedPort(8080)); }); + it("should return first mapped port with regardless of protocol", async () => { + await using container = await new GenericContainer("mendhak/udp-listener") + .withWaitStrategy(Wait.forLogMessage("Listening on UDP port 5005")) + .withExposedPorts("5005/udp") + .start(); + + await checkContainerIsHealthyUdp(container); + expect(container.getFirstMappedPort()).toBe(container.getMappedPort("5005/udp")); + expect(container.getFirstMappedPort()).toBe(container.getMappedPort(5005, "udp")); + }); + it("should bind to specified host port", async () => { const hostPort = await getPort(); await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") @@ -35,6 +48,22 @@ describe("GenericContainer", { timeout: 180_000 }, () => { expect(container.getMappedPort(8080)).toBe(hostPort); }); + it("should bind to specified host port with a different protocol", async () => { + const hostPort = await getPort(); + await using container = await new GenericContainer("mendhak/udp-listener") + .withWaitStrategy(Wait.forLogMessage("Listening on UDP port 5005")) + .withExposedPorts({ + container: 5005, + host: hostPort, + protocol: "udp", + }) + .start(); + + await checkContainerIsHealthyUdp(container); + expect(container.getMappedPort("5005/udp")).toBe(hostPort); + expect(container.getMappedPort(5005, "udp")).toBe(hostPort); + }); + it("should execute a command on a running container", async () => { await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") .withExposedPorts(8080) diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 93ae90792..59c086e84 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -27,7 +27,7 @@ import { import { BoundPorts } from "../utils/bound-ports"; import { createLabels, LABEL_TESTCONTAINERS_CONTAINER_HASH, LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels"; import { mapInspectResult } from "../utils/map-inspect-result"; -import { getContainerPort, hasHostBinding, PortWithOptionalBinding } from "../utils/port"; +import { getContainerPort, getProtocol, hasHostBinding, PortWithOptionalBinding } from "../utils/port"; import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy"; import { Wait } from "../wait-strategies/wait"; import { waitForContainer } from "../wait-strategies/wait-for-container"; @@ -364,7 +364,9 @@ export class GenericContainer implements TestContainer { public withExposedPorts(...ports: PortWithOptionalBinding[]): this { const exposedPorts: { [port: string]: Record } = {}; for (const exposedPort of ports) { - exposedPorts[`${getContainerPort(exposedPort).toString()}/tcp`] = {}; + const containerPort = getContainerPort(exposedPort); + const protocol = getProtocol(exposedPort); + exposedPorts[`${containerPort}/${protocol}`] = {}; } this.exposedPorts = [...this.exposedPorts, ...ports]; @@ -375,10 +377,12 @@ export class GenericContainer implements TestContainer { const portBindings: Record>> = {}; for (const exposedPort of ports) { + const protocol = getProtocol(exposedPort); if (hasHostBinding(exposedPort)) { - portBindings[`${exposedPort.container}/tcp`] = [{ HostPort: exposedPort.host.toString() }]; + portBindings[`${exposedPort.container}/${protocol}`] = [{ HostPort: exposedPort.host.toString() }]; } else { - portBindings[`${exposedPort}/tcp`] = [{ HostPort: "0" }]; + const containerPort = getContainerPort(exposedPort); + portBindings[`${containerPort}/${protocol}`] = [{ 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 index dea6280d3..31fd8484c 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 @@ -17,7 +17,10 @@ function mockInspectResult( 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 data = mockInspectResult( + { "8080/tcp": [], "8081/udp": [] }, + { "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "45000" }], "8081/udp": [{ HostIp: "0.0.0.0", HostPort: "45001" }] } + ); const inspectFn = vi.fn().mockResolvedValueOnce(data); const result = await inspectContainerUntilPortsExposed(inspectFn, "container-id"); diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index 771b3bfb9..32f7d3925 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -10,6 +10,7 @@ import { CommitOptions, ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, import { BoundPorts } from "../utils/bound-ports"; import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels"; import { mapInspectResult } from "../utils/map-inspect-result"; +import { PortWithOptionalBinding } from "../utils/port"; import { waitForContainer } from "../wait-strategies/wait-for-container"; import { WaitStrategy } from "../wait-strategies/wait-strategy"; import { inspectContainerUntilPortsExposed } from "./inspect-container-util-ports-exposed"; @@ -95,7 +96,10 @@ export class StartedGenericContainer implements StartedTestContainer { } this.boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter( - Array.from(this.boundPorts.iterator()).map((port) => port[0]) + Array.from(this.boundPorts.iterator()).map((port) => { + const [portNumber, protocol] = port[0].split("/"); + return `${portNumber}/${protocol}` as PortWithOptionalBinding; + }) ); await waitForContainer(client, this.container, this.waitStrategy, this.boundPorts, startTime); @@ -136,8 +140,8 @@ export class StartedGenericContainer implements StartedTestContainer { return this.boundPorts.getFirstBinding(); } - public getMappedPort(port: number): number { - return this.boundPorts.getBinding(port); + public getMappedPort(port: string | number, protocol: string = "tcp"): number { + return this.boundPorts.getBinding(port, protocol); } public getId(): string { diff --git a/packages/testcontainers/src/test-container.ts b/packages/testcontainers/src/test-container.ts index b58f0b38c..3711b5eeb 100644 --- a/packages/testcontainers/src/test-container.ts +++ b/packages/testcontainers/src/test-container.ts @@ -71,7 +71,8 @@ export interface StartedTestContainer extends AsyncDisposable { getHost(): string; getHostname(): string; getFirstMappedPort(): number; - getMappedPort(port: number): number; + getMappedPort(port: number, protocol?: string): number; + getMappedPort(portWithProtocol: `${number}/${"tcp" | "udp"}`): number; getName(): string; getLabels(): Labels; getId(): string; diff --git a/packages/testcontainers/src/types.ts b/packages/testcontainers/src/types.ts index 6b5961326..df93bdc02 100644 --- a/packages/testcontainers/src/types.ts +++ b/packages/testcontainers/src/types.ts @@ -69,7 +69,7 @@ export type ExtraHost = { export type Labels = { [key: string]: string }; export type HostPortBindings = Array<{ hostIp: string; hostPort: number }>; -export type Ports = { [containerPort: number]: HostPortBindings }; +export type Ports = { [containerPortWithProtocol: string]: HostPortBindings }; export type AuthConfig = { username: string; diff --git a/packages/testcontainers/src/utils/bound-ports.test.ts b/packages/testcontainers/src/utils/bound-ports.test.ts index bb585793b..acc255f84 100644 --- a/packages/testcontainers/src/utils/bound-ports.test.ts +++ b/packages/testcontainers/src/utils/bound-ports.test.ts @@ -10,6 +10,27 @@ describe("BoundPorts", () => { expect(boundPorts.getBinding(1)).toBe(1000); }); + it("should return a binding with protocol", () => { + const boundPorts = new BoundPorts(); + boundPorts.setBinding(1, 1000, "tcp"); + boundPorts.setBinding(1, 2000, "udp"); + + expect(boundPorts.getBinding(1)).toBe(1000); + expect(boundPorts.getBinding(1, "tcp")).toBe(1000); + expect(boundPorts.getBinding(1, "udp")).toBe(2000); + }); + + it("should accept string port keys", () => { + const boundPorts = new BoundPorts(); + boundPorts.setBinding("8080/tcp", 1000); + boundPorts.setBinding("8080/udp", 2000); + + expect(boundPorts.getBinding("8080/tcp")).toBe(1000); + expect(boundPorts.getBinding("8080/udp")).toBe(2000); + expect(boundPorts.getBinding(8080, "tcp")).toBe(1000); + expect(boundPorts.getBinding(8080, "udp")).toBe(2000); + }); + describe("BoundPorts", () => { it("should return a binding", () => { const boundPorts = new BoundPorts(); @@ -38,7 +59,7 @@ describe("BoundPorts", () => { boundPorts.setBinding(1, 1000); for (const [internalPort, hostPort] of boundPorts.iterator()) { - expect(internalPort).toBe(1); + expect(internalPort).toBe("1/tcp"); expect(hostPort).toBe(1000); } }); @@ -46,8 +67,9 @@ describe("BoundPorts", () => { it("should instantiate from an inspect result", () => { const inspectResult: Partial = { ports: { - 8080: [{ hostIp: "0.0.0.0", hostPort: 10000 }], - 8081: [{ hostIp: "0.0.0.0", hostPort: 10001 }], + "8080/tcp": [{ hostIp: "0.0.0.0", hostPort: 10000 }], + "8081/tcp": [{ hostIp: "0.0.0.0", hostPort: 10001 }], + "8080/udp": [{ hostIp: "0.0.0.0", hostPort: 10002 }], }, }; const hostIps: HostIp[] = [{ address: "127.0.0.1", family: 4 }]; @@ -56,6 +78,8 @@ describe("BoundPorts", () => { expect(boundPorts.getBinding(8080)).toBe(10000); expect(boundPorts.getBinding(8081)).toBe(10001); + expect(boundPorts.getBinding(8080, "tcp")).toBe(10000); + expect(boundPorts.getBinding(8080, "udp")).toBe(10002); }); it("should filter port bindings", () => { @@ -68,6 +92,32 @@ describe("BoundPorts", () => { expect(() => filtered.getBinding(1)).toThrowError("No port binding found for :1"); expect(filtered.getBinding(2)).toBe(2000); }); + + it("should filter port bindings with protocols", () => { + const boundPorts = new BoundPorts(); + boundPorts.setBinding(8080, 1000, "tcp"); + boundPorts.setBinding(8080, 2000, "udp"); + boundPorts.setBinding(9090, 3000, "tcp"); + + let filtered = boundPorts.filter([8080]); + expect(filtered.getBinding(8080)).toBe(1000); + expect(() => filtered.getBinding(8080, "udp")).toThrowError("No port binding found for :8080/udp"); + expect(() => filtered.getBinding(9090)).toThrowError("No port binding found for :9090/tcp"); + + filtered = boundPorts.filter(["8080/udp"]); + expect(filtered.getBinding(8080, "udp")).toBe(2000); + expect(() => filtered.getBinding(8080, "tcp")).toThrowError("No port binding found for :8080/tcp"); + }); + + it("should handle case-insensitive protocols", () => { + const boundPorts = new BoundPorts(); + boundPorts.setBinding(8080, 1000, "tcp"); + expect(boundPorts.getBinding(8080, "TCP")).toBe(1000); + + boundPorts.setBinding("9090/TCP", 2000); + expect(boundPorts.getBinding(9090, "tcp")).toBe(2000); + expect(boundPorts.getBinding("9090/tcp")).toBe(2000); + }); }); describe("resolveHostPortBinding", () => { diff --git a/packages/testcontainers/src/utils/bound-ports.ts b/packages/testcontainers/src/utils/bound-ports.ts index 3c062d976..fb7949cca 100644 --- a/packages/testcontainers/src/utils/bound-ports.ts +++ b/packages/testcontainers/src/utils/bound-ports.ts @@ -1,16 +1,25 @@ import net from "net"; import { HostIp } from "../container-runtime"; import { HostPortBindings, InspectResult } from "../types"; -import { getContainerPort, PortWithOptionalBinding } from "./port"; +import { getContainerPort, getProtocol, PortWithOptionalBinding } from "./port"; export class BoundPorts { - private readonly ports = new Map(); + private readonly ports = new Map(); - public getBinding(port: number): number { - const binding = this.ports.get(port); + public getBinding(port: number | string, protocol: string = "tcp"): number { + let key: string; + + if (typeof port === "string" && port.includes("/")) { + const [portNumber, portProtocol] = port.split("/"); + key = `${portNumber}/${portProtocol.toLowerCase()}`; + } else { + key = `${port}/${protocol.toLowerCase()}`; + } + + const binding = this.ports.get(key); if (!binding) { - throw new Error(`No port binding found for :${port}`); + throw new Error(`No port binding found for :${key}`); } return binding; @@ -26,22 +35,40 @@ export class BoundPorts { } } - public setBinding(key: number, value: number): void { - this.ports.set(key, value); + public setBinding(key: string | number, value: number, protocol: string = "tcp"): void { + const normalizedProtocol = protocol.toLowerCase(); + + if (typeof key === "string" && key.includes("/")) { + const [portNumber, portProtocol] = key.split("/"); + const normalizedKey = `${portNumber}/${portProtocol.toLowerCase()}`; + this.ports.set(normalizedKey, value); + } else { + const portKey = typeof key === "string" ? key : `${key}/${normalizedProtocol}`; + this.ports.set(portKey, value); + } } - public iterator(): Iterable<[number, number]> { + public iterator(): Iterable<[string, number]> { return this.ports; } public filter(ports: PortWithOptionalBinding[]): BoundPorts { const boundPorts = new BoundPorts(); + const containerPortsWithProtocol = new Map(); + ports.forEach((port) => { + const containerPort = getContainerPort(port); + const protocol = getProtocol(port); + containerPortsWithProtocol.set(containerPort, protocol); + }); - const containerPorts = ports.map((port) => getContainerPort(port)); - - for (const [internalPort, hostPort] of this.iterator()) { - if (containerPorts.includes(internalPort)) { - boundPorts.setBinding(internalPort, hostPort); + for (const [internalPortWithProtocol, hostPort] of this.iterator()) { + const [internalPortStr, protocol] = internalPortWithProtocol.split("/"); + const internalPort = parseInt(internalPortStr, 10); + if ( + containerPortsWithProtocol.has(internalPort) && + containerPortsWithProtocol.get(internalPort)?.toLowerCase() === protocol?.toLowerCase() + ) { + boundPorts.setBinding(internalPortWithProtocol, hostPort); } } @@ -51,9 +78,9 @@ export class BoundPorts { public static fromInspectResult(hostIps: HostIp[], inspectResult: InspectResult): BoundPorts { const boundPorts = new BoundPorts(); - Object.entries(inspectResult.ports).forEach(([containerPort, hostBindings]) => { + Object.entries(inspectResult.ports).forEach(([containerPortWithProtocol, hostBindings]) => { const hostPort = resolveHostPortBinding(hostIps, hostBindings); - boundPorts.setBinding(parseInt(containerPort), hostPort); + boundPorts.setBinding(containerPortWithProtocol, hostPort); }); return boundPorts; diff --git a/packages/testcontainers/src/utils/map-inspect-result.ts b/packages/testcontainers/src/utils/map-inspect-result.ts index 8270058d9..67e7d65d3 100644 --- a/packages/testcontainers/src/utils/map-inspect-result.ts +++ b/packages/testcontainers/src/utils/map-inspect-result.ts @@ -24,9 +24,10 @@ function mapPorts(inspectInfo: ContainerInspectInfo): Ports { return Object.entries(inspectInfo.NetworkSettings.Ports) .filter(([, hostPorts]) => hostPorts !== null) .map(([containerPortAndProtocol, hostPorts]) => { - const containerPort = parseInt(containerPortAndProtocol.split("/")[0]); + const [port, protocol] = containerPortAndProtocol.split("/"); + const containerPort = parseInt(port); return { - [containerPort]: hostPorts.map((hostPort) => ({ + [`${containerPort}/${protocol}`]: hostPorts.map((hostPort) => ({ hostIp: hostPort.HostIp, hostPort: parseInt(hostPort.HostPort), })), diff --git a/packages/testcontainers/src/utils/port.test.ts b/packages/testcontainers/src/utils/port.test.ts new file mode 100644 index 000000000..f54711063 --- /dev/null +++ b/packages/testcontainers/src/utils/port.test.ts @@ -0,0 +1,52 @@ +import { describe, expect } from "vitest"; +import { getContainerPort, getProtocol, hasHostBinding } from "./port"; + +describe("port utilities", () => { + describe("getContainerPort", () => { + it("should return the container port when defined as a number", () => { + expect(getContainerPort(8080)).toBe(8080); + }); + + it("should return the port when defined as a string with format `port/protocol`", () => { + expect(getContainerPort("8080/tcp")).toBe(8080); + expect(getContainerPort("8080/udp")).toBe(8080); + }); + + it("should return the container port from when defined as `PortWithBinding`", () => { + expect(getContainerPort({ container: 8080, host: 49000 })).toBe(8080); + }); + }); + + describe("hasHostBinding", () => { + it("should return true for `PortWithBinding` with defined `host` parameter", () => { + expect(hasHostBinding({ container: 8080, host: 49000 })).toBe(true); + }); + + it("should return false when querying for a number", () => { + expect(hasHostBinding(8080)).toBe(false); + }); + + it("should return false when querying for a string with format `port/protocol`", () => { + expect(hasHostBinding("8080/tcp")).toBe(false); + }); + }); + + describe("getProtocol", () => { + it("should return the default `tcp` for a number", () => { + expect(getProtocol(8080)).toBe("tcp"); + }); + + it("should return the protocol part of a string with format `port/protocol`", () => { + expect(getProtocol("8080/tcp")).toBe("tcp"); + expect(getProtocol("8080/udp")).toBe("udp"); + }); + + it("should maintain backwards compatibility when defined as `PortWithBinding` without protocol", () => { + expect(getProtocol({ container: 8080, host: 49000 })).toBe("tcp"); + }); + + it("should return the protocol parameter when defined as `PortWithBinding`", () => { + expect(getProtocol({ container: 8080, host: 49000, protocol: "udp" })).toBe("udp"); + }); + }); +}); diff --git a/packages/testcontainers/src/utils/port.ts b/packages/testcontainers/src/utils/port.ts index c45695170..dfa50e344 100644 --- a/packages/testcontainers/src/utils/port.ts +++ b/packages/testcontainers/src/utils/port.ts @@ -1,13 +1,41 @@ export type PortWithBinding = { container: number; host: number; + protocol?: "tcp" | "udp"; }; -export type PortWithOptionalBinding = number | PortWithBinding; +export type PortWithOptionalBinding = number | `${number}/${"tcp" | "udp"}` | PortWithBinding; -export const getContainerPort = (port: PortWithOptionalBinding): number => - typeof port === "number" ? port : port.container; +const portWithProtocolRegex = RegExp(/^(\d+)(?:\/(udp|tcp))?$/i); + +export const getContainerPort = (port: PortWithOptionalBinding): number => { + if (typeof port === "number") { + return port; + } else if (typeof port === "string") { + const match = portWithProtocolRegex.exec(port); + if (match) { + return parseInt(match[1], 10); + } + throw new Error(`Invalid port format: ${port}`); + } else { + return port.container; + } +}; export const hasHostBinding = (port: PortWithOptionalBinding): port is PortWithBinding => { return typeof port === "object" && port.host !== undefined; }; + +export const getProtocol = (port: PortWithOptionalBinding): string => { + if (typeof port === "number") { + return "tcp"; + } else if (typeof port === "string") { + const match = portWithProtocolRegex.exec(port); + if (match?.[2]) { + return match[2].toLowerCase(); + } + return "tcp"; + } else { + return port.protocol ? port.protocol.toLowerCase() : "tcp"; + } +}; diff --git a/packages/testcontainers/src/utils/test-helper.ts b/packages/testcontainers/src/utils/test-helper.ts index 0f649dbab..6fae65bb2 100644 --- a/packages/testcontainers/src/utils/test-helper.ts +++ b/packages/testcontainers/src/utils/test-helper.ts @@ -1,5 +1,6 @@ import { GetEventsOptions, ImageInspectInfo } from "dockerode"; import { createServer, Server } from "http"; +import { createSocket } from "node:dgram"; import fs from "node:fs"; import path from "node:path"; import { Readable } from "stream"; @@ -30,6 +31,18 @@ export const checkContainerIsHealthyTls = async (container: StartedTestContainer expect(response.statusCode).toBe(200); }; +export const checkContainerIsHealthyUdp = async (container: StartedTestContainer): Promise => { + const testMessage = "health_check"; + await using client = createSocket("udp4"); + client.send(Buffer.from(testMessage), container.getFirstMappedPort(), container.getHost()); + const logs = await container.logs(); + for await (const log of logs) { + if (log.includes(testMessage)) { + return; + } + } +}; + export const checkEnvironmentContainerIsHealthy = async ( startedEnvironment: StartedDockerComposeEnvironment, containerName: string diff --git a/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.test.ts b/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.test.ts index 242870d8d..b74088b22 100644 --- a/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.test.ts +++ b/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.test.ts @@ -20,7 +20,7 @@ describe("HostPortWaitStrategy", { timeout: 180_000 }, () => { .withExposedPorts(8081) .withStartupTimeout(0) .start() - ).rejects.toThrowError(/Port \d+ not bound after 0ms/); + ).rejects.toThrowError(/Port \d+\/(tcp|udp) not bound after 0ms/); expect(await getRunningContainerNames()).not.toContain(containerName); }); diff --git a/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.ts b/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.ts index 7224e8889..daf2450e9 100644 --- a/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.ts +++ b/packages/testcontainers/src/wait-strategies/host-port-wait-strategy.ts @@ -22,7 +22,13 @@ export class HostPortWaitStrategy extends AbstractWaitStrategy { container: Dockerode.Container, boundPorts: BoundPorts ): Promise { - for (const [, hostPort] of boundPorts.iterator()) { + for (const [portKey, hostPort] of boundPorts.iterator()) { + if (portKey.toLowerCase().endsWith("/udp")) { + log.debug(`Skipping wait for host port ${hostPort} (mapped from UDP port ${portKey})`, { + containerId: container.id, + }); + continue; + } log.debug(`Waiting for host port ${hostPort}...`, { containerId: container.id }); await this.waitForPort(container, hostPort, portCheck); log.debug(`Host port ${hostPort} ready`, { containerId: container.id }); @@ -36,13 +42,24 @@ export class HostPortWaitStrategy extends AbstractWaitStrategy { boundPorts: BoundPorts ): Promise { for (const [internalPort] of boundPorts.iterator()) { + if (internalPort.toLowerCase().endsWith("/udp")) { + log.debug(`Skipping wait for internal UDP port ${internalPort}`, { + containerId: container.id, + }); + continue; + } log.debug(`Waiting for internal port ${internalPort}...`, { containerId: container.id }); await this.waitForPort(container, internalPort, portCheck); log.debug(`Internal port ${internalPort} ready`, { containerId: container.id }); } + log.debug(`Internal port wait strategy complete`, { containerId: container.id }); } - private async waitForPort(container: Dockerode.Container, port: number, portCheck: PortCheck): Promise { + private async waitForPort( + container: Dockerode.Container, + port: number | string, + portCheck: PortCheck + ): Promise { await new IntervalRetry(100).retryUntil( () => portCheck.isBound(port), (isBound) => isBound, diff --git a/packages/testcontainers/src/wait-strategies/utils/port-check.test.ts b/packages/testcontainers/src/wait-strategies/utils/port-check.test.ts index b40bf4766..59f0f1cf4 100644 --- a/packages/testcontainers/src/wait-strategies/utils/port-check.test.ts +++ b/packages/testcontainers/src/wait-strategies/utils/port-check.test.ts @@ -31,8 +31,6 @@ describe.sequential("PortCheck", () => { // @ts-ignore client = new ContainerRuntimeClient(); portCheck = new InternalPortCheck(client, mockContainer); - - // Make sure logging is enabled to capture all logs mockLogger.enabled.mockImplementation(() => true); }); diff --git a/packages/testcontainers/src/wait-strategies/utils/port-check.ts b/packages/testcontainers/src/wait-strategies/utils/port-check.ts index 0969bb5fb..cbb75d597 100644 --- a/packages/testcontainers/src/wait-strategies/utils/port-check.ts +++ b/packages/testcontainers/src/wait-strategies/utils/port-check.ts @@ -4,15 +4,21 @@ import { log } from "../../common"; import { ContainerRuntimeClient } from "../../container-runtime"; export interface PortCheck { - isBound(port: number): Promise; + isBound(port: number | string): Promise; } export class HostPortCheck implements PortCheck { constructor(private readonly client: ContainerRuntimeClient) {} - public isBound(port: number): Promise { + public isBound(port: number | string): Promise { + if (typeof port === "string" && port.toLowerCase().endsWith("/udp")) { + log.debug(`Skipping host port check for UDP port ${port} (UDP port checks not supported)`); + return Promise.resolve(true); + } + return new Promise((resolve) => { const socket = new Socket(); + const portNumber = typeof port === "string" ? parseInt(port.split("/")[0], 10) : port; socket .setTimeout(1000) .on("error", () => { @@ -23,7 +29,7 @@ export class HostPortCheck implements PortCheck { socket.destroy(); resolve(false); }) - .connect(port, this.client.info.containerRuntime.host, () => { + .connect(portNumber, this.client.info.containerRuntime.host, () => { socket.end(); resolve(true); }); @@ -40,12 +46,20 @@ export class InternalPortCheck implements PortCheck { private readonly container: Dockerode.Container ) {} - public async isBound(port: number): Promise { - const portHex = port.toString(16).padStart(4, "0"); + public async isBound(port: number | string): Promise { + if (typeof port === "string" && port.toLowerCase().includes("/udp")) { + log.debug(`Skipping internal port check for UDP port ${port} (UDP port checks not supported)`, { + containerId: this.container.id, + }); + return Promise.resolve(true); + } + + const portNumber = typeof port === "string" ? parseInt(port.split("/")[0], 10) : port; + const portHex = portNumber.toString(16).padStart(4, "0"); const commands = [ ["/bin/sh", "-c", `cat /proc/net/tcp* | awk '{print $2}' | grep -i :${portHex}`], - ["/bin/sh", "-c", `nc -vz -w 1 localhost ${port}`], - ["/bin/bash", "-c", `