Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reuse PortForwarder container if it is running #680

Merged
merged 5 commits into from
Nov 24, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
151 changes: 113 additions & 38 deletions packages/testcontainers/src/port-forwarder/port-forwarder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,59 +10,134 @@ describe("PortForwarder", () => {
let randomPort: number;
let server: Server;

beforeEach(async () => {
randomPort = await new RandomUniquePortGenerator().generatePort();

await new Promise<void>((resolve) => {
server = createServer((req, res) => {
res.writeHead(200);
res.end("hello world");
});
server.listen(randomPort, resolve);
});
});

afterEach(() => {
server.close();
});

it("should expose host ports to the container", async () => {
await TestContainers.exposeHostPorts(randomPort);
describe("Behaviour", () => {
beforeEach(async () => {
randomPort = await new RandomUniquePortGenerator().generatePort();
server = await createTestServer(randomPort);
});

const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").start();
it("should expose host ports to the container", async () => {
await TestContainers.exposeHostPorts(randomPort);

const { output } = await container.exec(["curl", "-s", `http://host.testcontainers.internal:${randomPort}`]);
expect(output).toEqual(expect.stringContaining("hello world"));
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").start();

await container.stop();
});
const { output } = await container.exec(["curl", "-s", `http://host.testcontainers.internal:${randomPort}`]);
expect(output).toEqual(expect.stringContaining("hello world"));

await container.stop();
});

it("should expose host ports to the container with custom network", async () => {
await TestContainers.exposeHostPorts(randomPort);

const network = await new Network().start();
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withNetwork(network).start();

it("should expose host ports to the container with custom network", async () => {
await TestContainers.exposeHostPorts(randomPort);
const { output } = await container.exec(["curl", "-s", `http://host.testcontainers.internal:${randomPort}`]);
expect(output).toEqual(expect.stringContaining("hello world"));

const network = await new Network().start();
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withNetwork(network).start();
await container.stop();
await network.stop();
});

it("should expose host ports to the container with custom network and network alias", async () => {
await TestContainers.exposeHostPorts(randomPort);

const network = await new Network().start();
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
.withNetwork(network)
.withNetworkAliases("foo")
.start();

const { output } = await container.exec(["curl", "-s", `http://host.testcontainers.internal:${randomPort}`]);
expect(output).toEqual(expect.stringContaining("hello world"));
const { output } = await container.exec(["curl", "-s", `http://host.testcontainers.internal:${randomPort}`]);
expect(output).toEqual(expect.stringContaining("hello world"));

await container.stop();
await network.stop();
await container.stop();
await network.stop();
});
});

it("should expose host ports to the container with custom network and network alias", async () => {
await TestContainers.exposeHostPorts(randomPort);
describe("Reuse", () => {
afterEach(() => {
jest.resetModules();
});

const network = await new Network().start();
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
.withNetwork(network)
.withNetworkAliases("foo")
.start();
describe("Different host ports", () => {
beforeEach(async () => {
randomPort = await new RandomUniquePortGenerator().generatePort();
server = await createTestServer(randomPort);
});

it("1", async () => {
const { TestContainers } = await import("../test-containers");
await TestContainers.exposeHostPorts(randomPort);

const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").start();

const { output } = await container.exec(["curl", "-s", `http://host.testcontainers.internal:${randomPort}`]);
expect(output).toEqual(expect.stringContaining("hello world"));

await container.stop();
});

const { output } = await container.exec(["curl", "-s", `http://host.testcontainers.internal:${randomPort}`]);
expect(output).toEqual(expect.stringContaining("hello world"));
it("2", async () => {
const { TestContainers } = await import("../test-containers");
await TestContainers.exposeHostPorts(randomPort);

await container.stop();
await network.stop();
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").start();

const { output } = await container.exec(["curl", "-s", `http://host.testcontainers.internal:${randomPort}`]);
expect(output).toEqual(expect.stringContaining("hello world"));

await container.stop();
});
});

describe("Same host ports", () => {
beforeAll(async () => {
randomPort = await new RandomUniquePortGenerator().generatePort();
});

beforeEach(async () => {
server = await createTestServer(randomPort);
});

it("1", async () => {
const { TestContainers } = await import("../test-containers");
await TestContainers.exposeHostPorts(randomPort);

const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").start();

const { output } = await container.exec(["curl", "-s", `http://host.testcontainers.internal:${randomPort}`]);
expect(output).toEqual(expect.stringContaining("hello world"));

await container.stop();
});

it("2", async () => {
const { TestContainers } = await import("../test-containers");
await TestContainers.exposeHostPorts(randomPort);

const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").start();

const { output } = await container.exec(["curl", "-s", `http://host.testcontainers.internal:${randomPort}`]);
expect(output).toEqual(expect.stringContaining("hello world"));

await container.stop();
});
});
});
});

async function createTestServer(port: number): Promise<Server> {
const server = createServer((req, res) => {
res.writeHead(200);
res.end("hello world");
});
await new Promise<void>((resolve) => server.listen(port, resolve));
return server;
}
104 changes: 85 additions & 19 deletions packages/testcontainers/src/port-forwarder/port-forwarder.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,46 @@
import { createSshConnection, SshConnection } from "ssh-remote-port-forward";
import { GenericContainer } from "../generic-container/generic-container";
import { StartedTestContainer } from "../test-container";
import { log, RandomUuid } from "../common";
import { getContainerRuntimeClient } from "../container-runtime";
import { log, withFileLock } from "../common";
import { ContainerRuntimeClient, getContainerRuntimeClient } from "../container-runtime";
import { getReaper } from "../reaper/reaper";
import { PortWithOptionalBinding } from "../utils/port";
import Dockerode, { ContainerInfo } from "dockerode";
import { LABEL_TESTCONTAINERS_SESSION_ID, LABEL_TESTCONTAINERS_SSHD } from "../utils/labels";

export const SSHD_IMAGE = process.env["SSHD_CONTAINER_IMAGE"] ?? "testcontainers/sshd:1.1.0";

export class PortForwarder {
constructor(private readonly sshConnection: SshConnection, private readonly container: StartedTestContainer) {}
class PortForwarder {
constructor(
private readonly sshConnection: SshConnection,
private readonly containerId: string,
private readonly networkId: string,
private readonly ipAddress: string,
private readonly networkName: string
) {}

public async exposeHostPort(port: number): Promise<void> {
log.info(`Exposing host port ${port}...`);
await this.sshConnection.remoteForward("localhost", port);
log.info(`Exposed host port ${port}`);
}

public getNetworkId(): string {
return this.container.getNetworkId(this.getNetworkName());
public getContainerId(): string {
return this.containerId;
}

public getIpAddress(): string {
return this.container.getIpAddress(this.getNetworkName());
public getNetworkId(): string {
return this.networkId;
}

private getNetworkName(): string {
return this.container.getNetworkNames()[0];
public getIpAddress(): string {
return this.ipAddress;
}
}

export class PortForwarderInstance {
private static readonly USERNAME = "root";
private static readonly PASSWORD = "root";

private static instance: Promise<PortForwarder>;

public static isRunning(): boolean {
Expand All @@ -39,43 +49,99 @@ export class PortForwarderInstance {

public static async getInstance(): Promise<PortForwarder> {
if (!this.instance) {
this.instance = this.createInstance();
await withFileLock("testcontainers-node-sshd.lock", async () => {
const client = await getContainerRuntimeClient();
const reaper = await getReaper(client);
const sessionId = reaper.sessionId;
const portForwarderContainer = await this.findPortForwarderContainer(client, sessionId);

if (portForwarderContainer) {
this.instance = this.reuseInstance(client, portForwarderContainer, sessionId);
} else {
this.instance = this.createInstance();
}
await this.instance;
});
}
return this.instance;
}

private static async findPortForwarderContainer(
client: ContainerRuntimeClient,
sessionId: string
): Promise<ContainerInfo | undefined> {
const containers = await client.container.list();

return containers.find(
(container) =>
container.State === "running" &&
container.Labels[LABEL_TESTCONTAINERS_SSHD] === "true" &&
container.Labels[LABEL_TESTCONTAINERS_SESSION_ID] === sessionId
);
}

private static async reuseInstance(
client: ContainerRuntimeClient,
container: Dockerode.ContainerInfo,
sessionId: string
): Promise<PortForwarder> {
log.debug(`Reusing existing PortForwarder for session "${sessionId}"...`);

const host = client.info.containerRuntime.host;
const port = container.Ports.find((port) => port.PrivatePort == 22)?.PublicPort;
if (!port) {
throw new Error("Expected PortForwarder to map exposed port 22");
}

log.debug(`Connecting to Port Forwarder on "${host}:${port}"...`);
const connection = await createSshConnection({ host, port, username: "root", password: "root" });
log.debug(`Connected to Port Forwarder on "${host}:${port}"`);
connection.unref();

const containerId = container.Id;
const networks = Object.entries(container.NetworkSettings.Networks);
const networkName = networks[0][0];
const networkId = container.NetworkSettings.Networks[networkName].NetworkID;
const ipAddress = container.NetworkSettings.Networks[networkName].IPAddress;

return new PortForwarder(connection, containerId, networkId, ipAddress, networkName);
}

private static async createInstance(): Promise<PortForwarder> {
log.debug(`Creating new Port Forwarder...`);

const client = await getContainerRuntimeClient();
const reaper = await getReaper(client);

const username = "root";
const password = new RandomUuid().nextUuid();

const containerPort: PortWithOptionalBinding = process.env["TESTCONTAINERS_SSHD_PORT"]
? { container: 22, host: Number(process.env["TESTCONTAINERS_SSHD_PORT"]) }
: 22;

const container = await new GenericContainer(SSHD_IMAGE)
.withName(`testcontainers-port-forwarder-${reaper.sessionId}`)
.withExposedPorts(containerPort)
.withEnvironment({ PASSWORD: password })
.withEnvironment({ PASSWORD: this.PASSWORD })
.withLabels({ [LABEL_TESTCONTAINERS_SSHD]: "true" })
.withCommand([
"sh",
"-c",
`echo "${username}:$PASSWORD" | chpasswd && /usr/sbin/sshd -D -o PermitRootLogin=yes -o AddressFamily=inet -o GatewayPorts=yes -o AllowAgentForwarding=yes -o AllowTcpForwarding=yes -o KexAlgorithms=+diffie-hellman-group1-sha1 -o HostkeyAlgorithms=+ssh-rsa`,
`echo "${this.USERNAME}:$PASSWORD" | chpasswd && /usr/sbin/sshd -D -o PermitRootLogin=yes -o AddressFamily=inet -o GatewayPorts=yes -o AllowAgentForwarding=yes -o AllowTcpForwarding=yes -o KexAlgorithms=+diffie-hellman-group1-sha1 -o HostkeyAlgorithms=+ssh-rsa`,
])
.start();

const host = client.info.containerRuntime.host;
const port = container.getMappedPort(22);

log.debug(`Connecting to Port Forwarder on "${host}:${port}"...`);
const connection = await createSshConnection({ host, port, username, password });
const connection = await createSshConnection({ host, port, username: this.USERNAME, password: this.PASSWORD });
log.debug(`Connected to Port Forwarder on "${host}:${port}"`);
connection.unref();

return new PortForwarder(connection, container);
const containerId = container.getId();
const networkName = container.getNetworkNames()[0];
const networkId = container.getNetworkId(networkName);
const ipAddress = container.getIpAddress(networkName);

return new PortForwarder(connection, containerId, networkId, ipAddress, networkName);
}
}
4 changes: 3 additions & 1 deletion packages/testcontainers/src/reaper/reaper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ export async function getReaper(client: ContainerRuntimeClient): Promise<Reaper>

async function findReaperContainer(client: ContainerRuntimeClient): Promise<ContainerInfo | undefined> {
const containers = await client.container.list();
return containers.find((container) => container.Labels["org.testcontainers.ryuk"] === "true");
return containers.find(
(container) => container.State === "running" && container.Labels["org.testcontainers.ryuk"] === "true"
);
}

async function useExistingReaper(reaperContainer: ContainerInfo, sessionId: string, host: string): Promise<Reaper> {
Expand Down
28 changes: 27 additions & 1 deletion packages/testcontainers/src/test-containers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
import { PortForwarderInstance } from "./port-forwarder/port-forwarder";
import { getContainerRuntimeClient } from "./container-runtime";
import { log } from "./common";

export class TestContainers {
public static async exposeHostPorts(...ports: number[]): Promise<void> {
const portForwarder = await PortForwarderInstance.getInstance();
await Promise.all(ports.map((port) => portForwarder.exposeHostPort(port)));

await Promise.all(
ports.map((port) =>
portForwarder.exposeHostPort(port).catch(async (err) => {
if (await this.isHostPortExposed(portForwarder.getContainerId(), port)) {
log.debug(`Host port ${port} is already exposed`);
} else {
throw err;
}
})
)
);
}

private static async isHostPortExposed(portForwarderContainerId: string, hostPort: number): Promise<boolean> {
const client = await getContainerRuntimeClient();
const container = client.container.getById(portForwarderContainerId);

const { exitCode } = await client.container.exec(container, [
"sh",
"-c",
`netstat -tl | grep ${hostPort} | grep LISTEN`,
]);

return exitCode === 0;
}
}
1 change: 1 addition & 0 deletions packages/testcontainers/src/utils/labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const LABEL_TESTCONTAINERS = "org.testcontainers";
export const LABEL_TESTCONTAINERS_LANG = "org.testcontainers.lang";
export const LABEL_TESTCONTAINERS_VERSION = "org.testcontainers.version";
export const LABEL_TESTCONTAINERS_SESSION_ID = "org.testcontainers.session-id";
export const LABEL_TESTCONTAINERS_SSHD = "org.testcontainers.sshd";
export const LABEL_TESTCONTAINERS_CONTAINER_HASH = "org.testcontainers.container-hash";

export function createLabels(): Record<string, string> {
Expand Down