feat(FN-3109): add Docker connectivity service and target selector UI
This merge adds Docker connectivity management to Fusion (FN-3109), introducing a Docker client service in `@fusion/core` with typed interfaces for daemon connection configuration (host, socket path, TLS). The dashboard gains a `DockerTargetSelector` component for choosing between local socket and T Fusion-Task-Id: FN-3109
This commit is contained in:
@@ -38,6 +38,7 @@
|
||||
"test": "vitest run --silent=passed-only --reporter=dot"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dockerode": "^3.3.41",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitest/coverage-v8": "^3.1.0",
|
||||
"typescript": "^5.7.0",
|
||||
@@ -51,6 +52,7 @@
|
||||
"bonjour-service": "^1.3.0",
|
||||
"check-disk-space": "^3.4.0",
|
||||
"cron-parser": "^5.5.0",
|
||||
"dockerode": "^4.0.2",
|
||||
"extract-zip": "^2.0.1",
|
||||
"tar": "^7.5.13",
|
||||
"yaml": "^2.8.3"
|
||||
|
||||
134
packages/core/src/__tests__/docker-client.test.ts
Normal file
134
packages/core/src/__tests__/docker-client.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor } = vi.hoisted(() => {
|
||||
const execMock = vi.fn();
|
||||
const readFileMock = vi.fn();
|
||||
const pingMock = vi.fn();
|
||||
const versionMock = vi.fn();
|
||||
const inspectMock = vi.fn();
|
||||
const dockerCtor = vi.fn().mockImplementation(() => ({
|
||||
ping: pingMock,
|
||||
version: versionMock,
|
||||
getContainer: vi.fn(() => ({ inspect: inspectMock })),
|
||||
}));
|
||||
return { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor };
|
||||
});
|
||||
|
||||
vi.mock("dockerode", () => ({ default: dockerCtor }));
|
||||
vi.mock("node:child_process", () => ({ exec: execMock }));
|
||||
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||
|
||||
import { DockerClientService } from "../docker-client";
|
||||
|
||||
describe("DockerClientService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => cb(null, { stdout: "", stderr: "" }));
|
||||
pingMock.mockResolvedValue(undefined);
|
||||
versionMock.mockResolvedValue({ Version: "24.0.0", ApiVersion: "1.43", Os: "linux" });
|
||||
readFileMock.mockResolvedValue(Buffer.from("x"));
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, undefined],
|
||||
[{ host: "tcp://1.2.3.4:2376" }, { host: "tcp://1.2.3.4:2376" }],
|
||||
])("creates docker instance for mode", async (hostConfig, expected) => {
|
||||
const service = new DockerClientService();
|
||||
await service.testConnection(hostConfig as never);
|
||||
if (expected) expect(dockerCtor).toHaveBeenCalledWith(expected);
|
||||
else expect(dockerCtor).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("returns success connection result", async () => {
|
||||
const service = new DockerClientService();
|
||||
const result = await service.testConnection();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.dockerVersion).toBe("24.0.0");
|
||||
expect(result.apiVersion).toBe("1.43");
|
||||
expect(result.operatingSystem).toBe("linux");
|
||||
expect(result.isLocalDaemon).toBe(true);
|
||||
});
|
||||
|
||||
it("marks remote host as non-local daemon", async () => {
|
||||
const service = new DockerClientService();
|
||||
const result = await service.testConnection({ host: "tcp://1.2.3.4:2376" });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.isLocalDaemon).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ mode: "context", hostConfig: { context: "my-remote" } },
|
||||
{
|
||||
mode: "host+tls",
|
||||
hostConfig: {
|
||||
host: "tcp://1.2.3.4:2376",
|
||||
tlsVerify: true,
|
||||
tlsCaPath: "/ca.pem",
|
||||
tlsCertPath: "/cert.pem",
|
||||
tlsKeyPath: "/key.pem",
|
||||
},
|
||||
},
|
||||
])("covers additional mode $mode", async ({ hostConfig }) => {
|
||||
if ((hostConfig as any).context) {
|
||||
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => {
|
||||
if (cmd.includes("context inspect")) cb(null, { stdout: '[{"Endpoints":{"docker":{"Host":"tcp://ctx:2376"}}}]', stderr: "" });
|
||||
else cb(null, { stdout: "", stderr: "" });
|
||||
});
|
||||
}
|
||||
const service = new DockerClientService();
|
||||
await service.testConnection(hostConfig as any);
|
||||
expect(dockerCtor).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses docker context", async () => {
|
||||
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => {
|
||||
if (cmd.includes("context inspect")) cb(null, { stdout: '[{"Endpoints":{"docker":{"Host":"tcp://ctx:2376"}}}]', stderr: "" });
|
||||
else cb(null, { stdout: "", stderr: "" });
|
||||
});
|
||||
const service = new DockerClientService();
|
||||
await service.testConnection({ context: "my-remote" });
|
||||
expect(execMock.mock.calls[0][0]).toContain("docker context inspect");
|
||||
expect(dockerCtor).toHaveBeenCalledWith({ host: "tcp://ctx:2376" });
|
||||
});
|
||||
|
||||
it("supports host with tls", async () => {
|
||||
const service = new DockerClientService();
|
||||
await service.testConnection({ host: "tcp://1.2.3.4:2376", tlsVerify: true, tlsCaPath: "/ca.pem", tlsCertPath: "/cert.pem", tlsKeyPath: "/key.pem" });
|
||||
expect(readFileMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("returns failure when ping fails", async () => {
|
||||
pingMock.mockRejectedValue(new Error("connect ECONNREFUSED"));
|
||||
const service = new DockerClientService();
|
||||
const result = await service.testConnection();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("ECONNREFUSED");
|
||||
});
|
||||
|
||||
it("lists contexts and ENOENT fallback", async () => {
|
||||
execMock.mockImplementationOnce((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => cb(null, { stdout: '{"Name":"default","Current":true}\n{"Name":"remote","DockerHost":"tcp://1.2.3.4:2376","Current":false}\n', stderr: "" }));
|
||||
const service = new DockerClientService();
|
||||
const contexts = await service.listContexts();
|
||||
expect(contexts).toHaveLength(2);
|
||||
|
||||
execMock.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (err: unknown) => void) => cb(new Error("ENOENT")));
|
||||
const fallback = await service.listContexts();
|
||||
expect(fallback[0].name).toBe("default");
|
||||
});
|
||||
|
||||
it("gets container info and not found", async () => {
|
||||
inspectMock.mockResolvedValue({ Id: "abc", Name: "/container", Created: "2020-01-01T00:00:00Z", Config: { Image: "img:latest" }, State: { Status: "running", Running: true, Paused: false, Restarting: false, Dead: false } });
|
||||
const service = new DockerClientService();
|
||||
const container = await service.getContainerInfo("abc");
|
||||
expect(container?.name).toBe("container");
|
||||
|
||||
inspectMock.mockRejectedValue(new Error("404 no such container"));
|
||||
const missing = await service.getContainerInfo("missing");
|
||||
expect(missing).toBeNull();
|
||||
});
|
||||
|
||||
it("does not use execSync", async () => {
|
||||
const source = await import("node:fs/promises").then((m) => m.readFile(new URL("../docker-client.ts", import.meta.url), "utf8"));
|
||||
expect(source.includes("execSync")).toBe(false);
|
||||
});
|
||||
});
|
||||
192
packages/core/src/docker-client.ts
Normal file
192
packages/core/src/docker-client.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import Docker from "dockerode";
|
||||
import { exec } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
DockerConnectivityResult,
|
||||
DockerContainerInspectResult,
|
||||
DockerContextInfo,
|
||||
DockerHostConfig,
|
||||
} from "./types.js";
|
||||
|
||||
const EXEC_OPTIONS = {
|
||||
timeout: 15_000,
|
||||
maxBuffer: 5 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
function isLocalDaemonHost(host?: string): boolean {
|
||||
return !host || host.trim() === "" || host === "unix:///var/run/docker.sock";
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
interface DockerContextCliEntry {
|
||||
Name?: string;
|
||||
Description?: string;
|
||||
DockerEndpoint?: string;
|
||||
DockerHost?: string;
|
||||
Current?: boolean;
|
||||
Error?: string;
|
||||
}
|
||||
|
||||
export class DockerClientService {
|
||||
private dockerInstance: Docker | null = null;
|
||||
|
||||
constructor(private readonly defaultHostConfig?: DockerHostConfig) {}
|
||||
|
||||
private async createDockerInstance(hostConfig?: DockerHostConfig): Promise<Docker> {
|
||||
if (hostConfig?.context) {
|
||||
const contextName = hostConfig.context.trim();
|
||||
if (!contextName) throw new Error("Docker context name cannot be empty");
|
||||
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await promisify(exec)(`docker context inspect ${JSON.stringify(contextName)}`, EXEC_OPTIONS));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to inspect Docker context "${contextName}": ${toErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stdout) as Array<{ Endpoints?: { docker?: { Host?: string } } }>;
|
||||
const dockerHost = parsed[0]?.Endpoints?.docker?.Host;
|
||||
if (!dockerHost) throw new Error(`Docker context "${contextName}" does not define a Docker endpoint host`);
|
||||
return new Docker({ host: dockerHost });
|
||||
}
|
||||
|
||||
if (hostConfig?.host) {
|
||||
const options: {
|
||||
host: string;
|
||||
ca?: Buffer;
|
||||
cert?: Buffer;
|
||||
key?: Buffer;
|
||||
rejectUnauthorized?: boolean;
|
||||
} = {
|
||||
host: hostConfig.host,
|
||||
};
|
||||
|
||||
if (hostConfig.tlsCaPath) options.ca = await readFile(hostConfig.tlsCaPath);
|
||||
if (hostConfig.tlsCertPath) options.cert = await readFile(hostConfig.tlsCertPath);
|
||||
if (hostConfig.tlsKeyPath) options.key = await readFile(hostConfig.tlsKeyPath);
|
||||
if (hostConfig.tlsVerify === false) options.rejectUnauthorized = false;
|
||||
|
||||
return new Docker(options);
|
||||
}
|
||||
|
||||
return new Docker();
|
||||
}
|
||||
|
||||
async testConnection(hostConfig?: DockerHostConfig): Promise<DockerConnectivityResult> {
|
||||
const isLocalDaemon = isLocalDaemonHost(hostConfig?.host) && !hostConfig?.context;
|
||||
|
||||
try {
|
||||
const docker = await this.createDockerInstance(hostConfig);
|
||||
await docker.ping();
|
||||
const version = await docker.version();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
dockerVersion: version.Version,
|
||||
apiVersion: version.ApiVersion,
|
||||
operatingSystem: version.Os,
|
||||
isLocalDaemon,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: toErrorMessage(error),
|
||||
isLocalDaemon,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async listContexts(): Promise<DockerContextInfo[]> {
|
||||
try {
|
||||
const { stdout } = await promisify(exec)("docker context ls --format json", EXEC_OPTIONS);
|
||||
const lines = stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return [{ name: "default", isCurrentContext: true, description: "Current Docker context" }];
|
||||
}
|
||||
|
||||
try {
|
||||
return lines.map((line) => {
|
||||
const entry = JSON.parse(line) as DockerContextCliEntry;
|
||||
return {
|
||||
name: entry.Name ?? "default",
|
||||
description: entry.Description,
|
||||
dockerHost: entry.DockerEndpoint ?? entry.DockerHost,
|
||||
isCurrentContext: Boolean(entry.Current),
|
||||
isError: Boolean(entry.Error),
|
||||
errorMessage: entry.Error,
|
||||
} satisfies DockerContextInfo;
|
||||
});
|
||||
} catch {
|
||||
const tableLines = lines.slice(1);
|
||||
const contexts: DockerContextInfo[] = [];
|
||||
for (const line of tableLines) {
|
||||
const parts = line.split(/\s{2,}/).map((part) => part.trim()).filter(Boolean);
|
||||
if (parts.length === 0) continue;
|
||||
const rawName = parts[0] ?? "";
|
||||
const isCurrentContext = rawName.startsWith("*");
|
||||
const name = rawName.replace(/^\*\s*/, "") || "default";
|
||||
contexts.push({
|
||||
name,
|
||||
description: parts[2] || undefined,
|
||||
dockerHost: parts[1] || undefined,
|
||||
isCurrentContext,
|
||||
});
|
||||
}
|
||||
return contexts;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
if (message.includes("ENOENT")) {
|
||||
return [{ name: "default", isCurrentContext: true, description: "Current Docker context" }];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getContainerInfo(containerId: string): Promise<DockerContainerInspectResult | null> {
|
||||
try {
|
||||
const docker = await this.getInstance();
|
||||
const inspect = await docker.getContainer(containerId).inspect();
|
||||
return {
|
||||
id: inspect.Id,
|
||||
name: (inspect.Name ?? "").replace(/^\//, ""),
|
||||
status: inspect.State?.Status ?? "unknown",
|
||||
image: inspect.Config?.Image ?? "",
|
||||
created: inspect.Created ? Date.parse(inspect.Created) : 0,
|
||||
state: {
|
||||
running: Boolean(inspect.State?.Running),
|
||||
paused: Boolean(inspect.State?.Paused),
|
||||
restarting: Boolean(inspect.State?.Restarting),
|
||||
dead: Boolean(inspect.State?.Dead),
|
||||
error: inspect.State?.Error || undefined,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
if (message.includes("404") || message.toLowerCase().includes("no such container")) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async getInstance(): Promise<Docker> {
|
||||
if (!this.dockerInstance) {
|
||||
this.dockerInstance = await this.createDockerInstance(this.defaultHostConfig);
|
||||
}
|
||||
return this.dockerInstance;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.dockerInstance = null;
|
||||
}
|
||||
}
|
||||
@@ -391,6 +391,7 @@ export { NodeConnection } from "./node-connection.js";
|
||||
export { NodeDiscovery } from "./node-discovery.js";
|
||||
export { collectSystemMetrics } from "./system-metrics.js";
|
||||
export { getAppVersion, parseSemver } from "./app-version.js";
|
||||
export { DockerClientService } from "./docker-client.js";
|
||||
export type {
|
||||
ConnectionErrorType,
|
||||
ConnectionOptions,
|
||||
@@ -414,6 +415,9 @@ export type {
|
||||
DockerResourceSizing,
|
||||
DockerVolumeMount,
|
||||
DockerExtraCli,
|
||||
DockerContextInfo,
|
||||
DockerConnectivityResult,
|
||||
DockerContainerInspectResult,
|
||||
ManagedDockerNode,
|
||||
ManagedDockerNodeInput,
|
||||
ManagedDockerNodeUpdate,
|
||||
|
||||
@@ -2581,6 +2581,60 @@ export type ManagedDockerNodeUpdate = Partial<
|
||||
Omit<ManagedDockerNode, "id" | "createdAt">
|
||||
>;
|
||||
|
||||
/** Information about a discovered Docker context */
|
||||
export interface DockerContextInfo {
|
||||
/** Context name (e.g., "default", "my-remote") */
|
||||
name: string;
|
||||
/** Human-readable description */
|
||||
description?: string;
|
||||
/** Docker host URI for this context (e.g., "tcp://192.168.1.50:2376") */
|
||||
dockerHost?: string;
|
||||
/** Whether this is the currently active context */
|
||||
isCurrentContext: boolean;
|
||||
/** Whether this context has a connection error */
|
||||
isError?: boolean;
|
||||
/** Error message if the context is unreachable */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/** Result of testing Docker daemon connectivity */
|
||||
export interface DockerConnectivityResult {
|
||||
/** Whether the connection succeeded */
|
||||
success: boolean;
|
||||
/** Docker Engine version string */
|
||||
dockerVersion?: string;
|
||||
/** Docker API version string */
|
||||
apiVersion?: string;
|
||||
/** Docker Engine OS/arch info */
|
||||
operatingSystem?: string;
|
||||
/** Error message if connection failed */
|
||||
error?: string;
|
||||
/** Whether the target is the local Docker daemon */
|
||||
isLocalDaemon: boolean;
|
||||
}
|
||||
|
||||
/** Minimal container inspection result from Docker */
|
||||
export interface DockerContainerInspectResult {
|
||||
/** Container ID */
|
||||
id: string;
|
||||
/** Container name (with leading / stripped) */
|
||||
name: string;
|
||||
/** Container status string (e.g., "running", "exited") */
|
||||
status: string;
|
||||
/** Image name/tag */
|
||||
image: string;
|
||||
/** Creation timestamp (Unix epoch) */
|
||||
created: number;
|
||||
/** Detailed container state */
|
||||
state: {
|
||||
running: boolean;
|
||||
paused: boolean;
|
||||
restarting: boolean;
|
||||
dead: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** A single plugin's version information for sync comparison */
|
||||
export interface PluginVersionEntry {
|
||||
/** Plugin ID (matches PluginManifest.id) */
|
||||
|
||||
Reference in New Issue
Block a user