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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user