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:
Fusion
2026-05-03 07:42:36 -07:00
committed by gsxdsm
parent 0948be42fd
commit 93ecc88fee
19 changed files with 1173 additions and 143 deletions

View File

@@ -0,0 +1,79 @@
// @vitest-environment node
import express from "express";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createApiRoutes } from "../../routes.js";
import { request } from "../../test-request.js";
const service = {
listContexts: vi.fn(),
testConnection: vi.fn(),
};
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return { ...actual, DockerClientService: vi.fn().mockImplementation(() => service) };
});
function createStore() {
return {
getTask: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({}),
getSettingsFast: vi.fn().mockResolvedValue({}),
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })),
getRootDir: vi.fn().mockReturnValue("/tmp"),
getFusionDir: vi.fn().mockReturnValue("/tmp/.fusion"),
listWorkflowSteps: vi.fn().mockResolvedValue([]),
getMissionStore: vi.fn(),
on: vi.fn(),
off: vi.fn(),
} as any;
}
function app() {
const server = express();
server.use(express.json());
server.use("/api", createApiRoutes(createStore()));
return server;
}
describe("registerDockerNodeRoutes", () => {
beforeEach(() => vi.clearAllMocks());
it("GET /api/docker/contexts returns contexts", async () => {
service.listContexts.mockResolvedValue([{ name: "default", isCurrentContext: true }]);
const res = await request(app(), "GET", "/api/docker/contexts");
expect(res.status).toBe(200);
expect(res.body).toEqual([{ name: "default", isCurrentContext: true }]);
});
it("POST /api/docker/test-connection validates host protocol", async () => {
const res = await request(app(), "POST", "/api/docker/test-connection", JSON.stringify({ hostConfig: { host: "http://bad" } }), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
});
it("POST /api/docker/test-connection passes hostConfig", async () => {
service.testConnection.mockResolvedValue({ success: true, isLocalDaemon: false });
const hostConfig = { host: "tcp://1.2.3.4:2376", tlsVerify: true };
const res = await request(app(), "POST", "/api/docker/test-connection", JSON.stringify({ hostConfig }), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(service.testConnection).toHaveBeenCalledWith(hostConfig);
});
it("GET /api/docker/local-available maps connection", async () => {
service.testConnection.mockResolvedValue({ success: true, isLocalDaemon: true, dockerVersion: "24.0" });
const res = await request(app(), "GET", "/api/docker/local-available");
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: true, version: "24.0" });
});
it("GET /api/docker/local-available handles errors", async () => {
service.testConnection.mockRejectedValue(new Error("boom"));
const res = await request(app(), "GET", "/api/docker/local-available");
expect(res.status).toBe(200);
expect((res.body as any).available).toBe(false);
});
});

View File

@@ -57,6 +57,50 @@ function sanitizeExtraClis(input: unknown): DockerExtraCli[] {
export const registerDockerNodeRoutes: ApiRouteRegistrar = (ctx) => {
const { router, rethrowAsApiError } = ctx;
router.get("/docker/contexts", async (_req, res) => {
try {
const { DockerClientService } = await import("@fusion/core");
const service = new DockerClientService();
const contexts = await service.listContexts();
res.json(contexts);
} catch (error: unknown) {
if (error instanceof ApiError) throw error;
rethrowAsApiError(error);
}
});
router.post("/docker/test-connection", async (req, res) => {
try {
const hostConfig = ((req.body ?? {}) as { hostConfig?: DockerHostConfig }).hostConfig;
if (hostConfig?.host && !/^(tcp|unix|npipe):\/\//.test(hostConfig.host)) {
throw badRequest("hostConfig.host must start with tcp://, unix://, or npipe://");
}
if (hostConfig?.context !== undefined && typeof hostConfig.context === "string" && hostConfig.context.trim() === "") {
throw badRequest("hostConfig.context must be a non-empty string");
}
const { DockerClientService } = await import("@fusion/core");
const service = new DockerClientService();
const result = await service.testConnection(hostConfig);
res.json(result);
} catch (error: unknown) {
if (error instanceof ApiError) throw error;
rethrowAsApiError(error);
}
});
router.get("/docker/local-available", async (_req, res) => {
try {
const { DockerClientService } = await import("@fusion/core");
const service = new DockerClientService();
const result = await service.testConnection();
res.json({ available: result.success, version: result.dockerVersion, error: result.error });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
res.json({ available: false, error: message });
}
});
router.get("/docker-nodes", async (_req, res) => {
try {
const { CentralCore } = await import("@fusion/core");