Skip to content

Commit 98a81b3

Browse files
authored
Implement support for specifying the protocol when exposing ports (#1068)
1 parent 0c3d4e4 commit 98a81b3

19 files changed

+326
-51
lines changed

docs/features/containers.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,31 @@ const container = await new GenericContainer("alpine")
572572
const httpPort = container.getFirstMappedPort();
573573
```
574574

575+
Specify a protocol for the exposed port:
576+
577+
```javascript
578+
const container = await new GenericContainer("alpine")
579+
.withExposedPorts({
580+
container: 80,
581+
protocol: "udp"
582+
})
583+
.start();
584+
585+
const httpPort = container.getMappedPort(80, "udp");
586+
```
587+
588+
Alternatively, specify the protocol using a string with the format `port/protocol`:
589+
590+
```javascript
591+
const container = await new GenericContainer("alpine")
592+
.withExposedPorts("80/udp")
593+
.start();
594+
595+
const httpPort = container.getMappedPort("80/udp");
596+
```
597+
598+
If no protocol is specified, it defaults to `tcp`.
599+
575600
Specify fixed host port bindings (**not recommended**):
576601

577602
```javascript
@@ -609,12 +634,12 @@ expect(response.status).toBe(200);
609634
expect(await response.text()).toBe("PONG");
610635
```
611636

612-
The example above starts a `testcontainers/helloworld` container and a `socat` container.
637+
The example above starts a `testcontainers/helloworld` container and a `socat` container.
613638
The `socat` container is configured to forward traffic from port `8081` to the `testcontainers/helloworld` container on port `8080`.
614639

615640
## Running commands
616641

617-
To run a command inside an already started container, use the exec method.
642+
To run a command inside an already started container, use the exec method.
618643
The command will be run in the container's working directory,
619644
returning the combined output (`output`), standard output (`stdout`), standard error (`stderr`), and exit code (`exitCode`).
620645

packages/modules/azurite/src/azurite-container.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
AbstractStartedContainer,
33
GenericContainer,
4+
getContainerPort,
45
hasHostBinding,
56
PortWithOptionalBinding,
67
StartedTestContainer,
@@ -171,23 +172,26 @@ export class StartedAzuriteContainer extends AbstractStartedContainer {
171172
if (hasHostBinding(this.blobPort)) {
172173
return this.blobPort.host;
173174
} else {
174-
return this.getMappedPort(this.blobPort);
175+
const containerPort = getContainerPort(this.blobPort);
176+
return this.getMappedPort(containerPort);
175177
}
176178
}
177179

178180
public getQueuePort(): number {
179181
if (hasHostBinding(this.queuePort)) {
180182
return this.queuePort.host;
181183
} else {
182-
return this.getMappedPort(this.queuePort);
184+
const containerPort = getContainerPort(this.queuePort);
185+
return this.getMappedPort(containerPort);
183186
}
184187
}
185188

186189
public getTablePort(): number {
187190
if (hasHostBinding(this.tablePort)) {
188191
return this.tablePort.host;
189192
} else {
190-
return this.getMappedPort(this.tablePort);
193+
const containerPort = getContainerPort(this.tablePort);
194+
return this.getMappedPort(containerPort);
191195
}
192196
}
193197

packages/testcontainers/src/generic-container/abstract-started-container.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,12 @@ export class AbstractStartedContainer implements StartedTestContainer {
4343
return this.startedTestContainer.getFirstMappedPort();
4444
}
4545

46-
public getMappedPort(port: number): number {
46+
public getMappedPort(port: number, protocol?: string): number;
47+
public getMappedPort(portWithProtocol: `${number}/${"tcp" | "udp"}`): number;
48+
public getMappedPort(port: number | `${number}/${"tcp" | "udp"}`, protocol?: string): number {
49+
if (typeof port === "number") {
50+
return this.startedTestContainer.getMappedPort(port, protocol);
51+
}
4752
return this.startedTestContainer.getMappedPort(port);
4853
}
4954

packages/testcontainers/src/generic-container/generic-container.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import { getContainerRuntimeClient } from "../container-runtime";
55
import { PullPolicy } from "../utils/pull-policy";
66
import {
77
checkContainerIsHealthy,
8+
checkContainerIsHealthyUdp,
89
getDockerEventStream,
910
getRunningContainerNames,
1011
waitForDockerEvent,
1112
} from "../utils/test-helper";
13+
import { Wait } from "../wait-strategies/wait";
1214
import { GenericContainer } from "./generic-container";
1315

1416
describe("GenericContainer", { timeout: 180_000 }, () => {
@@ -22,6 +24,17 @@ describe("GenericContainer", { timeout: 180_000 }, () => {
2224
expect(container.getFirstMappedPort()).toBe(container.getMappedPort(8080));
2325
});
2426

27+
it("should return first mapped port with regardless of protocol", async () => {
28+
await using container = await new GenericContainer("mendhak/udp-listener")
29+
.withWaitStrategy(Wait.forLogMessage("Listening on UDP port 5005"))
30+
.withExposedPorts("5005/udp")
31+
.start();
32+
33+
await checkContainerIsHealthyUdp(container);
34+
expect(container.getFirstMappedPort()).toBe(container.getMappedPort("5005/udp"));
35+
expect(container.getFirstMappedPort()).toBe(container.getMappedPort(5005, "udp"));
36+
});
37+
2538
it("should bind to specified host port", async () => {
2639
const hostPort = await getPort();
2740
await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
@@ -35,6 +48,22 @@ describe("GenericContainer", { timeout: 180_000 }, () => {
3548
expect(container.getMappedPort(8080)).toBe(hostPort);
3649
});
3750

51+
it("should bind to specified host port with a different protocol", async () => {
52+
const hostPort = await getPort();
53+
await using container = await new GenericContainer("mendhak/udp-listener")
54+
.withWaitStrategy(Wait.forLogMessage("Listening on UDP port 5005"))
55+
.withExposedPorts({
56+
container: 5005,
57+
host: hostPort,
58+
protocol: "udp",
59+
})
60+
.start();
61+
62+
await checkContainerIsHealthyUdp(container);
63+
expect(container.getMappedPort("5005/udp")).toBe(hostPort);
64+
expect(container.getMappedPort(5005, "udp")).toBe(hostPort);
65+
});
66+
3867
it("should execute a command on a running container", async () => {
3968
await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
4069
.withExposedPorts(8080)

packages/testcontainers/src/generic-container/generic-container.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
import { BoundPorts } from "../utils/bound-ports";
2828
import { createLabels, LABEL_TESTCONTAINERS_CONTAINER_HASH, LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels";
2929
import { mapInspectResult } from "../utils/map-inspect-result";
30-
import { getContainerPort, hasHostBinding, PortWithOptionalBinding } from "../utils/port";
30+
import { getContainerPort, getProtocol, hasHostBinding, PortWithOptionalBinding } from "../utils/port";
3131
import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy";
3232
import { Wait } from "../wait-strategies/wait";
3333
import { waitForContainer } from "../wait-strategies/wait-for-container";
@@ -364,7 +364,9 @@ export class GenericContainer implements TestContainer {
364364
public withExposedPorts(...ports: PortWithOptionalBinding[]): this {
365365
const exposedPorts: { [port: string]: Record<string, never> } = {};
366366
for (const exposedPort of ports) {
367-
exposedPorts[`${getContainerPort(exposedPort).toString()}/tcp`] = {};
367+
const containerPort = getContainerPort(exposedPort);
368+
const protocol = getProtocol(exposedPort);
369+
exposedPorts[`${containerPort}/${protocol}`] = {};
368370
}
369371

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

376378
const portBindings: Record<string, Array<Record<string, string>>> = {};
377379
for (const exposedPort of ports) {
380+
const protocol = getProtocol(exposedPort);
378381
if (hasHostBinding(exposedPort)) {
379-
portBindings[`${exposedPort.container}/tcp`] = [{ HostPort: exposedPort.host.toString() }];
382+
portBindings[`${exposedPort.container}/${protocol}`] = [{ HostPort: exposedPort.host.toString() }];
380383
} else {
381-
portBindings[`${exposedPort}/tcp`] = [{ HostPort: "0" }];
384+
const containerPort = getContainerPort(exposedPort);
385+
portBindings[`${containerPort}/${protocol}`] = [{ HostPort: "0" }];
382386
}
383387
}
384388

packages/testcontainers/src/generic-container/inspect-container-util-ports-exposed.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ function mockInspectResult(
1717

1818
describe.sequential("inspectContainerUntilPortsExposed", () => {
1919
it("returns the inspect result when all ports are exposed", async () => {
20-
const data = mockInspectResult({ "8080/tcp": [] }, { "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "45000" }] });
20+
const data = mockInspectResult(
21+
{ "8080/tcp": [], "8081/udp": [] },
22+
{ "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "45000" }], "8081/udp": [{ HostIp: "0.0.0.0", HostPort: "45001" }] }
23+
);
2124
const inspectFn = vi.fn().mockResolvedValueOnce(data);
2225

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

packages/testcontainers/src/generic-container/started-generic-container.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { CommitOptions, ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult,
1010
import { BoundPorts } from "../utils/bound-ports";
1111
import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels";
1212
import { mapInspectResult } from "../utils/map-inspect-result";
13+
import { PortWithOptionalBinding } from "../utils/port";
1314
import { waitForContainer } from "../wait-strategies/wait-for-container";
1415
import { WaitStrategy } from "../wait-strategies/wait-strategy";
1516
import { inspectContainerUntilPortsExposed } from "./inspect-container-util-ports-exposed";
@@ -95,7 +96,10 @@ export class StartedGenericContainer implements StartedTestContainer {
9596
}
9697

9798
this.boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter(
98-
Array.from(this.boundPorts.iterator()).map((port) => port[0])
99+
Array.from(this.boundPorts.iterator()).map((port) => {
100+
const [portNumber, protocol] = port[0].split("/");
101+
return `${portNumber}/${protocol}` as PortWithOptionalBinding;
102+
})
99103
);
100104

101105
await waitForContainer(client, this.container, this.waitStrategy, this.boundPorts, startTime);
@@ -136,8 +140,8 @@ export class StartedGenericContainer implements StartedTestContainer {
136140
return this.boundPorts.getFirstBinding();
137141
}
138142

139-
public getMappedPort(port: number): number {
140-
return this.boundPorts.getBinding(port);
143+
public getMappedPort(port: string | number, protocol: string = "tcp"): number {
144+
return this.boundPorts.getBinding(port, protocol);
141145
}
142146

143147
public getId(): string {

packages/testcontainers/src/test-container.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ export interface StartedTestContainer extends AsyncDisposable {
7171
getHost(): string;
7272
getHostname(): string;
7373
getFirstMappedPort(): number;
74-
getMappedPort(port: number): number;
74+
getMappedPort(port: number, protocol?: string): number;
75+
getMappedPort(portWithProtocol: `${number}/${"tcp" | "udp"}`): number;
7576
getName(): string;
7677
getLabels(): Labels;
7778
getId(): string;

packages/testcontainers/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export type ExtraHost = {
6969
export type Labels = { [key: string]: string };
7070

7171
export type HostPortBindings = Array<{ hostIp: string; hostPort: number }>;
72-
export type Ports = { [containerPort: number]: HostPortBindings };
72+
export type Ports = { [containerPortWithProtocol: string]: HostPortBindings };
7373

7474
export type AuthConfig = {
7575
username: string;

packages/testcontainers/src/utils/bound-ports.test.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,27 @@ describe("BoundPorts", () => {
1010
expect(boundPorts.getBinding(1)).toBe(1000);
1111
});
1212

13+
it("should return a binding with protocol", () => {
14+
const boundPorts = new BoundPorts();
15+
boundPorts.setBinding(1, 1000, "tcp");
16+
boundPorts.setBinding(1, 2000, "udp");
17+
18+
expect(boundPorts.getBinding(1)).toBe(1000);
19+
expect(boundPorts.getBinding(1, "tcp")).toBe(1000);
20+
expect(boundPorts.getBinding(1, "udp")).toBe(2000);
21+
});
22+
23+
it("should accept string port keys", () => {
24+
const boundPorts = new BoundPorts();
25+
boundPorts.setBinding("8080/tcp", 1000);
26+
boundPorts.setBinding("8080/udp", 2000);
27+
28+
expect(boundPorts.getBinding("8080/tcp")).toBe(1000);
29+
expect(boundPorts.getBinding("8080/udp")).toBe(2000);
30+
expect(boundPorts.getBinding(8080, "tcp")).toBe(1000);
31+
expect(boundPorts.getBinding(8080, "udp")).toBe(2000);
32+
});
33+
1334
describe("BoundPorts", () => {
1435
it("should return a binding", () => {
1536
const boundPorts = new BoundPorts();
@@ -38,16 +59,17 @@ describe("BoundPorts", () => {
3859
boundPorts.setBinding(1, 1000);
3960

4061
for (const [internalPort, hostPort] of boundPorts.iterator()) {
41-
expect(internalPort).toBe(1);
62+
expect(internalPort).toBe("1/tcp");
4263
expect(hostPort).toBe(1000);
4364
}
4465
});
4566

4667
it("should instantiate from an inspect result", () => {
4768
const inspectResult: Partial<InspectResult> = {
4869
ports: {
49-
8080: [{ hostIp: "0.0.0.0", hostPort: 10000 }],
50-
8081: [{ hostIp: "0.0.0.0", hostPort: 10001 }],
70+
"8080/tcp": [{ hostIp: "0.0.0.0", hostPort: 10000 }],
71+
"8081/tcp": [{ hostIp: "0.0.0.0", hostPort: 10001 }],
72+
"8080/udp": [{ hostIp: "0.0.0.0", hostPort: 10002 }],
5173
},
5274
};
5375
const hostIps: HostIp[] = [{ address: "127.0.0.1", family: 4 }];
@@ -56,6 +78,8 @@ describe("BoundPorts", () => {
5678

5779
expect(boundPorts.getBinding(8080)).toBe(10000);
5880
expect(boundPorts.getBinding(8081)).toBe(10001);
81+
expect(boundPorts.getBinding(8080, "tcp")).toBe(10000);
82+
expect(boundPorts.getBinding(8080, "udp")).toBe(10002);
5983
});
6084

6185
it("should filter port bindings", () => {
@@ -68,6 +92,32 @@ describe("BoundPorts", () => {
6892
expect(() => filtered.getBinding(1)).toThrowError("No port binding found for :1");
6993
expect(filtered.getBinding(2)).toBe(2000);
7094
});
95+
96+
it("should filter port bindings with protocols", () => {
97+
const boundPorts = new BoundPorts();
98+
boundPorts.setBinding(8080, 1000, "tcp");
99+
boundPorts.setBinding(8080, 2000, "udp");
100+
boundPorts.setBinding(9090, 3000, "tcp");
101+
102+
let filtered = boundPorts.filter([8080]);
103+
expect(filtered.getBinding(8080)).toBe(1000);
104+
expect(() => filtered.getBinding(8080, "udp")).toThrowError("No port binding found for :8080/udp");
105+
expect(() => filtered.getBinding(9090)).toThrowError("No port binding found for :9090/tcp");
106+
107+
filtered = boundPorts.filter(["8080/udp"]);
108+
expect(filtered.getBinding(8080, "udp")).toBe(2000);
109+
expect(() => filtered.getBinding(8080, "tcp")).toThrowError("No port binding found for :8080/tcp");
110+
});
111+
112+
it("should handle case-insensitive protocols", () => {
113+
const boundPorts = new BoundPorts();
114+
boundPorts.setBinding(8080, 1000, "tcp");
115+
expect(boundPorts.getBinding(8080, "TCP")).toBe(1000);
116+
117+
boundPorts.setBinding("9090/TCP", 2000);
118+
expect(boundPorts.getBinding(9090, "tcp")).toBe(2000);
119+
expect(boundPorts.getBinding("9090/tcp")).toBe(2000);
120+
});
71121
});
72122

73123
describe("resolveHostPortBinding", () => {

0 commit comments

Comments
 (0)