Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
29 changes: 27 additions & 2 deletions docs/features/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getContainerRuntimeClient } from "../container-runtime";
import { PullPolicy } from "../utils/pull-policy";
import {
checkContainerIsHealthy,
checkContainerIsHealthyUdp,
getDockerEventStream,
getRunningContainerNames,
waitForDockerEvent,
Expand All @@ -22,6 +23,12 @@ 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").withExposedPorts("5005/udp").start();
await checkContainerIsHealthyUdp(container);
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")
Expand All @@ -35,6 +42,20 @@ 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")
.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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -364,7 +364,9 @@ 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()}/tcp`] = {};
const containerPort = getContainerPort(exposedPort);
const protocol = getProtocol(exposedPort);
exposedPorts[`${containerPort}/${protocol}`] = {};
}

this.exposedPorts = [...this.exposedPorts, ...ports];
Expand All @@ -375,10 +377,12 @@ export class GenericContainer implements TestContainer {

const portBindings: Record<string, Array<Record<string, string>>> = {};
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" }];
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,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 {
Expand Down
3 changes: 2 additions & 1 deletion packages/testcontainers/src/test-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/testcontainers/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
56 changes: 53 additions & 3 deletions packages/testcontainers/src/utils/bound-ports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -38,16 +59,17 @@ 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);
}
});

it("should instantiate from an inspect result", () => {
const inspectResult: Partial<InspectResult> = {
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 }];
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
57 changes: 42 additions & 15 deletions packages/testcontainers/src/utils/bound-ports.ts
Original file line number Diff line number Diff line change
@@ -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<number, number>();
private readonly ports = new Map<string, number>();

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;
Expand All @@ -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<number, string>();
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);
}
}

Expand All @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions packages/testcontainers/src/utils/map-inspect-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})),
Expand Down
Loading