Skip to content
Open
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
35 changes: 19 additions & 16 deletions packages/testcontainers/src/container-runtime/clients/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { NpipeSocketStrategy } from "../strategies/npipe-socket-strategy";
import { RootlessUnixSocketStrategy } from "../strategies/rootless-unix-socket-strategy";
import type { ContainerRuntimeClientStrategy } from "../strategies/strategy";
import { TestcontainersHostStrategy } from "../strategies/testcontainers-host-strategy";
import type { ContainerRuntimeClientStrategyResult } from "../strategies/types";
import { UnixSocketStrategy } from "../strategies/unix-socket-strategy";
import { lookupHostIps } from "../utils/lookup-host-ips";
import { getRemoteContainerRuntimeSocketPath } from "../utils/remote-container-runtime-socket-path";
Expand All @@ -17,6 +18,7 @@ import { DockerImageClient } from "./image/docker-image-client";
import type { ImageClient } from "./image/image-client";
import { DockerNetworkClient } from "./network/docker-network-client";
import type { NetworkClient } from "./network/network-client";
import { createNoWorkingStrategyError, type StrategyAttempt } from "./no-working-strategy-error";
import type { ContainerRuntimeInfo, HostIp, Info, NodeInfo } from "./types";

type DockerodeInfo = Awaited<ReturnType<Dockerode["info"]>>;
Expand Down Expand Up @@ -46,33 +48,34 @@ export async function getContainerRuntimeClient(): Promise<ContainerRuntimeClien
new NpipeSocketStrategy(),
];

const attempts: StrategyAttempt[] = [];

for (const strategy of strategies) {
const name = strategy.getName();
try {
log.debug(`Checking container runtime strategy "${strategy.getName()}"...`);
const client = await initStrategy(strategy);
if (client) {
log.debug(`Container runtime strategy "${strategy.getName()}" works`);
containerRuntimeClient = client;
return client;
log.debug(`Checking container runtime strategy "${name}"...`);
const result = await strategy.getResult();
if ("notApplicable" in result) {
log.debug(`Container runtime strategy "${name}" is not applicable: ${result.notApplicable}`);
attempts.push({ name, reason: `not applicable: ${result.notApplicable}` });
continue;
}
const client = await initStrategy(result);
log.debug(`Container runtime strategy "${name}" works`);
containerRuntimeClient = client;
return client;
} catch (err) {
log.debug(`Container runtime strategy "${strategy.getName()}" does not work: "${err}"`);
log.debug(`Container runtime strategy "${name}" does not work: "${err}"`);
if (err !== null && typeof err === "object" && "stack" in err && typeof err.stack === "string") {
log.debug(err.stack);
}
attempts.push({ name, reason: `failed: ${err instanceof Error ? err.message : String(err)}`, error: err });
}
}
throw new Error("Could not find a working container runtime strategy");
throw createNoWorkingStrategyError(attempts);
}

async function initStrategy(strategy: ContainerRuntimeClientStrategy): Promise<ContainerRuntimeClient | undefined> {
const result = await strategy.getResult();

if (!result) {
log.debug(`Container runtime strategy "${strategy.getName()}" is not applicable`);
return undefined;
}

async function initStrategy(result: ContainerRuntimeClientStrategyResult): Promise<ContainerRuntimeClient> {
const dockerodeOptions: DockerOptions = {
...result.dockerOptions,
headers: { ...result.dockerOptions.headers, "User-Agent": `tc-node/${LIB_VERSION}` },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { createNoWorkingStrategyError } from "./no-working-strategy-error";

describe("createNoWorkingStrategyError", () => {
it("should list each strategy and why it was skipped", () => {
const error = createNoWorkingStrategyError([
{ name: "ConfigurationStrategy", reason: "not applicable: DOCKER_HOST is not set" },
{ name: "UnixSocketStrategy", reason: "not applicable: /var/run/docker.sock does not exist" },
]);

expect(error.message).toBe(
[
"Could not find a working container runtime strategy. Tried:",
" - ConfigurationStrategy: not applicable: DOCKER_HOST is not set",
" - UnixSocketStrategy: not applicable: /var/run/docker.sock does not exist",
"See https://node.testcontainers.org/supported-container-runtimes/",
].join("\n")
);
expect(error.cause).toBeUndefined();
});

it("should attach the errors of strategies that failed as the cause", () => {
const permissionDenied = new Error("connect EACCES /var/run/docker.sock");

const error = createNoWorkingStrategyError([
{ name: "NpipeSocketStrategy", reason: "not applicable: only used on Windows" },
{ name: "UnixSocketStrategy", reason: `failed: ${permissionDenied.message}`, error: permissionDenied },
]);

expect(error.message).toContain(" - UnixSocketStrategy: failed: connect EACCES /var/run/docker.sock");
expect(error.cause).toBeInstanceOf(AggregateError);
expect((error.cause as AggregateError).errors).toEqual([permissionDenied]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export type StrategyAttempt = { name: string; reason: string; error?: unknown };

export function createNoWorkingStrategyError(attempts: StrategyAttempt[]): Error {
const lines = attempts.map(({ name, reason }) => ` - ${name}: ${reason}`);
const errors = attempts.map(({ error }) => error).filter((error) => error !== undefined);

return new Error(
[
"Could not find a working container runtime strategy. Tried:",
...lines,
"See https://node.testcontainers.org/supported-container-runtimes/",
].join("\n"),
errors.length > 0 ? { cause: new AggregateError(errors) } : undefined
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,31 @@ const mockGetContainerRuntimeConfig = vi.mocked(getContainerRuntimeConfig);
const mockReadFile = vi.mocked(readFile);

describe("ConfigurationStrategy", { concurrent: false }, () => {
it("should return undefined if no docker host is set", async () => {
it("should not apply if no docker host is set", async () => {
mockGetContainerRuntimeConfig.mockResolvedValueOnce({ dockerHost: undefined });

const result = await new ConfigurationStrategy().getResult();

expect(result).toBeUndefined();
expect(result).toEqual({
notApplicable: "DOCKER_HOST is not set in the environment or ~/.testcontainers.properties",
});
});

it("should set host and port when docker host is a URL", async () => {
mockGetContainerRuntimeConfig.mockResolvedValueOnce({ dockerHost: "tcp://docker:8025" });

const result = await new ConfigurationStrategy().getResult();

expect(result?.uri).toEqual("tcp://docker:8025");
expect(result?.dockerOptions).toEqual(
assert(!("notApplicable" in result));

expect(result.uri).toEqual("tcp://docker:8025");
expect(result.dockerOptions).toEqual(
expect.objectContaining({
host: "docker",
port: "8025",
})
);
expect(result?.composeEnvironment).toEqual(
expect(result.composeEnvironment).toEqual(
expect.objectContaining({
DOCKER_HOST: "tcp://docker:8025",
})
Expand All @@ -41,13 +45,15 @@ describe("ConfigurationStrategy", { concurrent: false }, () => {

const result = await new ConfigurationStrategy().getResult();

expect(result?.uri).toEqual("unix:///var/run/docker.sock");
expect(result?.dockerOptions).toEqual(
assert(!("notApplicable" in result));

expect(result.uri).toEqual("unix:///var/run/docker.sock");
expect(result.dockerOptions).toEqual(
expect.objectContaining({
socketPath: "/var/run/docker.sock",
})
);
expect(result?.composeEnvironment).toEqual(
expect(result.composeEnvironment).toEqual(
expect.objectContaining({
DOCKER_HOST: "unix:///var/run/docker.sock",
})
Expand All @@ -59,7 +65,9 @@ describe("ConfigurationStrategy", { concurrent: false }, () => {

const result = await new ConfigurationStrategy().getResult();

expect(result?.allowUserOverrides).toEqual(true);
assert(!("notApplicable" in result));

expect(result.allowUserOverrides).toEqual(true);
});

it("should set SSL options when TLS is enabled", async () => {
Expand All @@ -83,15 +91,17 @@ describe("ConfigurationStrategy", { concurrent: false }, () => {

const result = await new ConfigurationStrategy().getResult();

expect(result?.uri).toEqual("tcp://docker:8025");
expect(result?.dockerOptions).toEqual(
assert(!("notApplicable" in result));

expect(result.uri).toEqual("tcp://docker:8025");
expect(result.dockerOptions).toEqual(
expect.objectContaining({
ca: "ca",
cert: "cert",
key: "key",
})
);
expect(result?.composeEnvironment).toEqual(
expect(result.composeEnvironment).toEqual(
expect.objectContaining({
DOCKER_TLS_VERIFY: "1",
DOCKER_CERT_PATH: "/certs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@ import path from "node:path";
import { URL } from "node:url";
import type { DockerOptions } from "dockerode";
import type { ContainerRuntimeClientStrategy } from "./strategy";
import type { ContainerRuntimeClientStrategyResult } from "./types";
import type { ContainerRuntimeClientStrategyNotApplicable, ContainerRuntimeClientStrategyResult } from "./types";
import { getContainerRuntimeConfig } from "./utils/config";

export class ConfigurationStrategy implements ContainerRuntimeClientStrategy {
getName(): string {
return "ConfigurationStrategy";
}

async getResult(): Promise<ContainerRuntimeClientStrategyResult | undefined> {
async getResult(): Promise<ContainerRuntimeClientStrategyResult | ContainerRuntimeClientStrategyNotApplicable> {
const { dockerHost, dockerTlsVerify, dockerCertPath } = await getContainerRuntimeConfig();

if (!dockerHost) {
return undefined;
return { notApplicable: "DOCKER_HOST is not set in the environment or ~/.testcontainers.properties" };
}

const dockerOptions: DockerOptions = {};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { NpipeSocketStrategy } from "./npipe-socket-strategy";

describe("NpipeSocketStrategy", () => {
it("should return undefined when platform is not win32", async () => {
it("should not apply when platform is not win32", async () => {
const result = await new NpipeSocketStrategy("linux").getResult();
expect(result).toBeUndefined();
expect(result).toEqual({ notApplicable: "only used on Windows" });
});

it("should return expected result", async () => {
const result = await new NpipeSocketStrategy("win32").getResult();
assert(!("notApplicable" in result));

expect(result?.uri).toEqual("npipe:////./pipe/docker_engine");
expect(result?.dockerOptions).toEqual({ socketPath: "//./pipe/docker_engine" });
expect(result?.composeEnvironment).toEqual({});
expect(result?.allowUserOverrides).toEqual(true);
expect(result.uri).toEqual("npipe:////./pipe/docker_engine");
expect(result.dockerOptions).toEqual({ socketPath: "//./pipe/docker_engine" });
expect(result.composeEnvironment).toEqual({});
expect(result.allowUserOverrides).toEqual(true);
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ContainerRuntimeClientStrategy } from "./strategy";
import type { ContainerRuntimeClientStrategyResult } from "./types";
import type { ContainerRuntimeClientStrategyNotApplicable, ContainerRuntimeClientStrategyResult } from "./types";

export class NpipeSocketStrategy implements ContainerRuntimeClientStrategy {
constructor(private readonly platform: NodeJS.Platform = process.platform) {}
Expand All @@ -8,9 +8,9 @@ export class NpipeSocketStrategy implements ContainerRuntimeClientStrategy {
return "NpipeSocketStrategy";
}

async getResult(): Promise<ContainerRuntimeClientStrategyResult | undefined> {
async getResult(): Promise<ContainerRuntimeClientStrategyResult | ContainerRuntimeClientStrategyNotApplicable> {
if (this.platform !== "win32") {
return;
return { notApplicable: "only used on Windows" };
}

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,22 @@ vi.mock("node:fs", () => ({ existsSync: vi.fn() }));
describe("RootlessUnixSocketStrategy", { concurrent: false }, () => {
const mockExistsSync = vi.mocked(existsSync);

it("should return undefined for non-linux and non-darwin platforms", async () => {
it("should not apply for non-linux and non-darwin platforms", async () => {
mockExistsSync.mockReturnValue(true);

const strategy = new RootlessUnixSocketStrategy("win32", {});
const result = await strategy.getResult();

expect(result).toBeUndefined();
expect(result).toEqual({ notApplicable: "only used on Linux and macOS" });
});

it("should return undefined when socket does not exist", async () => {
it("should not apply when socket does not exist", async () => {
mockExistsSync.mockReturnValue(false);

const strategy = new RootlessUnixSocketStrategy("linux", {});
const result = await strategy.getResult();

expect(result).toBeUndefined();
expect(result).toEqual({ notApplicable: expect.stringMatching(/^no socket found at /) });
});

it("should return client for socket from XDG_RUNTIME_DIR", async () => {
Expand All @@ -32,8 +32,9 @@ describe("RootlessUnixSocketStrategy", { concurrent: false }, () => {

const strategy = new RootlessUnixSocketStrategy("linux", { XDG_RUNTIME_DIR: "/tmp" });
const result = await strategy.getResult();
assert(!("notApplicable" in result));

expect(result?.uri).toEqual(`unix://${socketPath}`);
expect(result.uri).toEqual(`unix://${socketPath}`);
});

it("should return client for socket from home run dir", async () => {
Expand All @@ -42,8 +43,9 @@ describe("RootlessUnixSocketStrategy", { concurrent: false }, () => {

const strategy = new RootlessUnixSocketStrategy("linux", {});
const result = await strategy.getResult();
assert(!("notApplicable" in result));

expect(result?.uri).toEqual(`unix://${socketPath}`);
expect(result.uri).toEqual(`unix://${socketPath}`);
});

it("should return Docker client for socket from home desktop dir", async () => {
Expand All @@ -52,8 +54,9 @@ describe("RootlessUnixSocketStrategy", { concurrent: false }, () => {

const strategy = new RootlessUnixSocketStrategy("linux", {});
const result = await strategy.getResult();
assert(!("notApplicable" in result));

expect(result?.uri).toEqual(`unix://${socketPath}`);
expect(result.uri).toEqual(`unix://${socketPath}`);
});

it("should return Docker client for socket from run dir", async () => {
Expand All @@ -62,7 +65,8 @@ describe("RootlessUnixSocketStrategy", { concurrent: false }, () => {

const strategy = new RootlessUnixSocketStrategy("linux", {});
const result = await strategy.getResult();
assert(!("notApplicable" in result));

expect(result?.uri).toEqual(`unix://${socketPath}`);
expect(result.uri).toEqual(`unix://${socketPath}`);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { isDefined } from "../../common";
import type { ContainerRuntimeClientStrategy } from "./strategy";
import type { ContainerRuntimeClientStrategyResult } from "./types";
import type { ContainerRuntimeClientStrategyNotApplicable, ContainerRuntimeClientStrategyResult } from "./types";

export class RootlessUnixSocketStrategy implements ContainerRuntimeClientStrategy {
constructor(
Expand All @@ -15,22 +15,21 @@ export class RootlessUnixSocketStrategy implements ContainerRuntimeClientStrateg
return "RootlessUnixSocketStrategy";
}

async getResult(): Promise<ContainerRuntimeClientStrategyResult | undefined> {
async getResult(): Promise<ContainerRuntimeClientStrategyResult | ContainerRuntimeClientStrategyNotApplicable> {
if (this.platform !== "linux" && this.platform !== "darwin") {
return;
return { notApplicable: "only used on Linux and macOS" };
}

const socketPath = [
const candidateSocketPaths = [
this.getSocketPathFromEnv(),
this.getSocketPathFromHomeRunDir(),
this.getSocketPathFromHomeDesktopDir(),
this.getSocketPathFromRunDir(),
]
.filter(isDefined)
.find((candidateSocketPath) => existsSync(candidateSocketPath));
].filter(isDefined);
const socketPath = candidateSocketPaths.find((candidateSocketPath) => existsSync(candidateSocketPath));

if (!socketPath) {
return;
return { notApplicable: `no socket found at ${candidateSocketPaths.join(", ")}` };
}

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ContainerRuntimeClientStrategyResult } from "./types";
import type { ContainerRuntimeClientStrategyNotApplicable, ContainerRuntimeClientStrategyResult } from "./types";

export interface ContainerRuntimeClientStrategy {
getName(): string;
getResult(): Promise<ContainerRuntimeClientStrategyResult | undefined>;
getResult(): Promise<ContainerRuntimeClientStrategyResult | ContainerRuntimeClientStrategyNotApplicable>;
}
Loading