Skip to content

Commit

Permalink
Reuse PortForwarder container if it is running (#680)
Browse files Browse the repository at this point in the history
  • Loading branch information
cristianrgreco authored Nov 24, 2023
1 parent e2dd217 commit d593d47
Show file tree
Hide file tree
Showing 5 changed files with 229 additions and 59 deletions.
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

0 comments on commit d593d47

Please sign in to comment.