Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
27 changes: 25 additions & 2 deletions docs/features/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,29 @@ 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 +632,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,8 +43,8 @@ export class AbstractStartedContainer implements StartedTestContainer {
return this.startedTestContainer.getFirstMappedPort();
}

public getMappedPort(port: number): number {
return this.startedTestContainer.getMappedPort(port);
public getMappedPort(port: string | number, protocol?: string): number {
return this.startedTestContainer.getMappedPort(port, protocol);
}

public getName(): string {
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 @@ -376,9 +378,12 @@ export class GenericContainer implements TestContainer {
const portBindings: Record<string, Array<Record<string, string>>> = {};
for (const exposedPort of ports) {
if (hasHostBinding(exposedPort)) {
portBindings[`${exposedPort.container}/tcp`] = [{ HostPort: exposedPort.host.toString() }];
const protocol = getProtocol(exposedPort);
portBindings[`${exposedPort.container}/${protocol}`] = [{ HostPort: exposedPort.host.toString() }];
} else {
portBindings[`${exposedPort}/tcp`] = [{ HostPort: "0" }];
const containerPort = getContainerPort(exposedPort);
const protocol = getProtocol(exposedPort);
portBindings[`${containerPort}/${protocol}`] = [{ HostPort: "0" }];
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,23 @@ describe.sequential("inspectContainerUntilPortsExposed", () => {
"Timed out after 0ms while waiting for container ports to be bound to the host"
);
});

test("handles multiple ports with different protocols", async () => {
const data = mockInspectResult(
{
"8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "45000" }],
"9090/udp": [{ HostIp: "0.0.0.0", HostPort: "46000" }],
},
{
"8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "45000" }],
"9090/udp": [{ HostIp: "0.0.0.0", HostPort: "46000" }],
}
);

const inspectFn = vi.fn().mockResolvedValueOnce(data);

const result = await inspectContainerUntilPortsExposed(inspectFn, "container-id");

expect(result).toEqual(data);
});
});
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
2 changes: 1 addition & 1 deletion packages/testcontainers/src/test-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export interface StartedTestContainer extends AsyncDisposable {
getHost(): string;
getHostname(): string;
getFirstMappedPort(): number;
getMappedPort(port: number): number;
getMappedPort(port: string | number, protocol?: string): 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: 41 additions & 16 deletions packages/testcontainers/src/utils/bound-ports.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
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>();

public getBinding(port: number): number {
const binding = this.ports.get(port);
private readonly ports = new Map<string, number>();

public getBinding(port: number | string, protocol: string = "tcp"): number {
const normalizedProtocol = protocol.toLowerCase();
const key = typeof port === "string" ? port : `${port}/${normalizedProtocol}`;
let binding = this.ports.get(key);
if (!binding && typeof port === "string" && port.includes("/")) {
const [portNumber, portProtocol] = port.split("/");
const normalizedKey = `${portNumber}/${portProtocol.toLowerCase()}`;
binding = this.ports.get(normalizedKey);
}

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 +33,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 +76,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