feat(FN-3110): add docker provisioning service, API routes, and status UI c

Merges FN-3110 to add Docker provisioning support across the stack: TypeScript types and DockerProvisioningService in core, API routes in dashboard for provisioning operations, a useDockerProvisioning hook for frontend integration, and a DockerProvisioningStatus UI component — all covered by unit an

Fusion-Task-Id: FN-3110
This commit is contained in:
Fusion
2026-05-03 14:10:11 -07:00
committed by gsxdsm
parent 9309c8cc95
commit 0568d5d41b
13 changed files with 2430 additions and 0 deletions

View File

@@ -0,0 +1,324 @@
// @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 provisionMock = vi.fn();
const deprovisionMock = vi.fn();
const startContainerMock = vi.fn();
const stopContainerMock = vi.fn();
const restartContainerMock = vi.fn();
const getContainerStatusMock = vi.fn();
const registerNodeMock = vi.fn();
const createManagedDockerNodeMock = vi.fn();
const updateManagedDockerNodeMock = vi.fn();
const listManagedDockerNodesMock = vi.fn();
const deleteManagedDockerNodeMock = vi.fn();
const closeMock = vi.fn();
const initMock = vi.fn().mockResolvedValue(undefined);
const dockerClientServiceMock = {
getDockerInstance: vi.fn(),
getContainerInfo: vi.fn(),
};
vi.mock("@fusion/core", () => ({
DockerProvisioningService: vi.fn().mockImplementation(() => ({
provision: provisionMock,
deprovision: deprovisionMock,
startContainer: startContainerMock,
stopContainer: stopContainerMock,
restartContainer: restartContainerMock,
getContainerStatus: getContainerStatusMock,
})),
DockerClientService: vi.fn().mockImplementation(() => dockerClientServiceMock),
CentralCore: vi.fn().mockImplementation(() => ({
init: initMock,
close: closeMock,
registerNode: registerNodeMock,
createManagedDockerNode: createManagedDockerNodeMock,
updateManagedDockerNode: updateManagedDockerNodeMock,
listManagedDockerNodes: listManagedDockerNodesMock,
deleteManagedDockerNode: deleteManagedDockerNodeMock,
})),
}));
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;
}
const VALID_PROVISION_BODY = {
nodeName: "test-node",
hostConfig: {},
imageConfig: { image: "runfusion/fusion", tag: "latest", pullImage: true },
autoGenerateApiKey: true,
};
describe("registerDockerProvisioningRoutes", () => {
beforeEach(() => {
vi.clearAllMocks();
initMock.mockResolvedValue(undefined);
closeMock.mockResolvedValue(undefined);
});
describe("POST /api/docker/provision", () => {
it("returns success result on valid input", async () => {
provisionMock.mockResolvedValue({
success: true,
containerId: "abc123",
containerName: "fusion-test-node-abc12345",
apiKey: "fn_testkey",
portMapping: "4040:49152",
durationMs: 1000,
});
registerNodeMock.mockResolvedValue({ id: "node_abc123" });
createManagedDockerNodeMock.mockResolvedValue({ id: "dn_abc" });
updateManagedDockerNodeMock.mockResolvedValue({ id: "dn_abc" });
const res = await request(
app(),
"POST",
"/api/docker/provision",
JSON.stringify(VALID_PROVISION_BODY),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
const body = res.body as Record<string, unknown>;
expect(body.success).toBe(true);
expect(body.containerId).toBe("abc123");
expect(body.nodeId).toBe("node_abc123");
});
it("returns 400 for missing nodeName", async () => {
const res = await request(
app(),
"POST",
"/api/docker/provision",
JSON.stringify({ ...VALID_PROVISION_BODY, nodeName: "" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
});
it("returns 400 for missing hostConfig", async () => {
const { hostConfig: _, ...body } = VALID_PROVISION_BODY;
const res = await request(
app(),
"POST",
"/api/docker/provision",
JSON.stringify(body),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
});
it("returns 400 for missing imageConfig", async () => {
const { imageConfig: _, ...body } = VALID_PROVISION_BODY;
const res = await request(
app(),
"POST",
"/api/docker/provision",
JSON.stringify(body),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
});
it("returns 400 for invalid image characters", async () => {
const res = await request(
app(),
"POST",
"/api/docker/provision",
JSON.stringify({ ...VALID_PROVISION_BODY, imageConfig: { image: "bad image$", tag: "latest", pullImage: true } }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
});
it("returns 400 for invalid tag characters", async () => {
const res = await request(
app(),
"POST",
"/api/docker/provision",
JSON.stringify({ ...VALID_PROVISION_BODY, imageConfig: { image: "runfusion/fusion", tag: "bad tag!", pullImage: true } }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
});
it("returns 400 when autoGenerateApiKey=false and no apiKey", async () => {
const res = await request(
app(),
"POST",
"/api/docker/provision",
JSON.stringify({ ...VALID_PROVISION_BODY, autoGenerateApiKey: false }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
});
it("returns 400 when autoGenerateApiKey is missing", async () => {
const { autoGenerateApiKey: _, ...body } = VALID_PROVISION_BODY;
const res = await request(
app(),
"POST",
"/api/docker/provision",
JSON.stringify(body),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
});
it("returns 400 for nodeName over 64 chars", async () => {
const res = await request(
app(),
"POST",
"/api/docker/provision",
JSON.stringify({ ...VALID_PROVISION_BODY, nodeName: "x".repeat(65) }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
});
});
describe("POST /api/docker/deprovision", () => {
it("returns success on valid deprovision", async () => {
deprovisionMock.mockResolvedValue({ success: true });
listManagedDockerNodesMock.mockResolvedValue([]);
const res = await request(
app(),
"POST",
"/api/docker/deprovision",
JSON.stringify({ containerId: "abc123", hostConfig: {} }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect((res.body as Record<string, unknown>).success).toBe(true);
});
it("returns 400 for missing containerId", async () => {
const res = await request(
app(),
"POST",
"/api/docker/deprovision",
JSON.stringify({ hostConfig: {} }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
});
});
describe("POST /api/docker/containers/:containerId/start", () => {
it("returns success result", async () => {
startContainerMock.mockResolvedValue({ success: true });
const res = await request(
app(),
"POST",
"/api/docker/containers/abc123/start",
JSON.stringify({ hostConfig: {} }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect((res.body as Record<string, unknown>).success).toBe(true);
});
});
describe("POST /api/docker/containers/:containerId/stop", () => {
it("returns success result", async () => {
stopContainerMock.mockResolvedValue({ success: true });
const res = await request(
app(),
"POST",
"/api/docker/containers/abc123/stop",
JSON.stringify({ hostConfig: {} }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect((res.body as Record<string, unknown>).success).toBe(true);
});
});
describe("POST /api/docker/containers/:containerId/restart", () => {
it("returns success result", async () => {
restartContainerMock.mockResolvedValue({ success: true });
const res = await request(
app(),
"POST",
"/api/docker/containers/abc123/restart",
JSON.stringify({ hostConfig: {} }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect((res.body as Record<string, unknown>).success).toBe(true);
});
});
describe("GET /api/docker/containers/:containerId/status", () => {
it("returns container status", async () => {
getContainerStatusMock.mockResolvedValue({
id: "abc123",
name: "fusion-test",
status: "running",
image: "runfusion/fusion:latest",
created: 1704067200000,
state: { running: true, paused: false, restarting: false, dead: false },
});
const res = await request(app(), "GET", "/api/docker/containers/abc123/status");
expect(res.status).toBe(200);
expect((res.body as Record<string, unknown>).id).toBe("abc123");
expect((res.body as Record<string, unknown>).status).toBe("running");
});
it("returns null for missing container", async () => {
getContainerStatusMock.mockResolvedValue(null);
const res = await request(app(), "GET", "/api/docker/containers/abc123/status");
expect(res.status).toBe(200);
expect(res.body).toBeNull();
});
});
describe("GET /api/docker/default-image", () => {
it("returns default image config", async () => {
const res = await request(app(), "GET", "/api/docker/default-image");
expect(res.status).toBe(200);
expect(res.body).toEqual({ image: "runfusion/fusion", tag: "latest" });
});
});
});

View File

@@ -0,0 +1,349 @@
import type { DockerHostConfig, DockerProvisionInput } from "@fusion/core";
import { ApiError, badRequest } from "../api-error.js";
import type { ApiRouteRegistrar } from "./types.js";
const IMAGE_PATTERN = /^[a-zA-Z0-9._/-]+$/;
const TAG_PATTERN = /^[a-zA-Z0-9._-]+$/;
function sanitizeHostConfig(input: unknown): DockerHostConfig {
const host = (input ?? {}) as Partial<DockerHostConfig>;
return {
host: typeof host.host === "string" ? host.host.trim() : undefined,
context: typeof host.context === "string" ? host.context.trim() : undefined,
tlsVerify: host.tlsVerify === undefined ? undefined : Boolean(host.tlsVerify),
tlsCaPath: typeof host.tlsCaPath === "string" ? host.tlsCaPath.trim() : undefined,
tlsCertPath: typeof host.tlsCertPath === "string" ? host.tlsCertPath.trim() : undefined,
tlsKeyPath: typeof host.tlsKeyPath === "string" ? host.tlsKeyPath.trim() : undefined,
};
}
function parseHostConfigQuery(raw: string | string[] | undefined): DockerHostConfig {
if (!raw || Array.isArray(raw)) return {};
try {
const parsed = JSON.parse(decodeURIComponent(raw));
return sanitizeHostConfig(parsed);
} catch {
return {};
}
}
export const registerDockerProvisioningRoutes: ApiRouteRegistrar = (ctx) => {
const { router, rethrowAsApiError } = ctx;
// POST /api/docker/provision — Provision a new Docker node
router.post("/docker/provision", async (req, res) => {
try {
const body = req.body ?? {};
const nodeName = typeof body.nodeName === "string" ? body.nodeName.trim() : "";
const hostConfig = body.hostConfig;
const imageConfig = body.imageConfig;
const autoGenerateApiKey = body.autoGenerateApiKey;
// Validation
if (!nodeName || nodeName.length > 64) {
throw badRequest("nodeName is required and must be 1-64 characters");
}
if (!hostConfig || typeof hostConfig !== "object") {
throw badRequest("hostConfig is required");
}
if (!imageConfig || typeof imageConfig !== "object") {
throw badRequest("imageConfig is required");
}
if (typeof imageConfig.image !== "string" || !imageConfig.image.trim()) {
throw badRequest("imageConfig.image is required");
}
if (!IMAGE_PATTERN.test(imageConfig.image)) {
throw badRequest("imageConfig.image contains invalid characters");
}
if (typeof imageConfig.tag !== "string" || !imageConfig.tag.trim()) {
throw badRequest("imageConfig.tag is required");
}
if (!TAG_PATTERN.test(imageConfig.tag)) {
throw badRequest("imageConfig.tag contains invalid characters");
}
if (typeof autoGenerateApiKey !== "boolean") {
throw badRequest("autoGenerateApiKey is required and must be a boolean");
}
if (!autoGenerateApiKey && (!body.apiKey || typeof body.apiKey !== "string" || !body.apiKey.trim())) {
throw badRequest("apiKey is required when autoGenerateApiKey is false");
}
const input: DockerProvisionInput = {
nodeName,
hostConfig: sanitizeHostConfig(hostConfig),
imageConfig: {
image: imageConfig.image.trim(),
tag: imageConfig.tag.trim(),
pullImage: Boolean(imageConfig.pullImage),
registryUsername: typeof imageConfig.registryUsername === "string" ? imageConfig.registryUsername : undefined,
registryPassword: typeof imageConfig.registryPassword === "string" ? imageConfig.registryPassword : undefined,
},
resourceConfig: body.resourceConfig ?? undefined,
environment: Array.isArray(body.environment) ? body.environment : undefined,
volumeMounts: Array.isArray(body.volumeMounts) ? body.volumeMounts : undefined,
persistentVolume: typeof body.persistentVolume === "string" ? body.persistentVolume : undefined,
extraClis: Array.isArray(body.extraClis) ? body.extraClis : undefined,
reachableUrl: typeof body.reachableUrl === "string" ? body.reachableUrl.trim() : undefined,
autoGenerateApiKey,
apiKey: typeof body.apiKey === "string" ? body.apiKey.trim() : undefined,
maxConcurrent: typeof body.maxConcurrent === "number" ? body.maxConcurrent : undefined,
network: typeof body.network === "string" ? body.network.trim() : undefined,
labels: body.labels && typeof body.labels === "object" && !Array.isArray(body.labels) ? body.labels : undefined,
};
const { DockerProvisioningService, DockerClientService, CentralCore } = await import("@fusion/core");
// Create Docker client with the provided host config as default
const dockerClientService = new DockerClientService(input.hostConfig);
const provisionService = new DockerProvisioningService(dockerClientService);
// Run provisioning
const result = await provisionService.provision(input);
if (!result.success) {
res.json(result);
return;
}
// Calculate reachable URL for node registration
let reachableUrl = input.reachableUrl;
if (!reachableUrl && result.portMapping) {
const hostPort = result.portMapping.split(":")[1];
const dockerHost = input.hostConfig.host;
if (!dockerHost || dockerHost === "unix:///var/run/docker.sock") {
reachableUrl = `http://localhost:${hostPort}`;
} else {
// Extract hostname from Docker host URI
try {
const url = new URL(dockerHost);
reachableUrl = `http://${url.hostname}:${hostPort}`;
} catch {
reachableUrl = `http://localhost:${hostPort}`;
}
}
}
// Register node in CentralCore
let nodeId: string | undefined;
try {
const central = new CentralCore();
await central.init();
try {
const registeredNode = await central.registerNode({
name: input.nodeName,
type: "remote",
url: reachableUrl,
apiKey: result.apiKey,
maxConcurrent: input.maxConcurrent ?? 2,
});
nodeId = registeredNode.id;
// Persist Docker metadata using runtime feature check
if (typeof central.createManagedDockerNode === "function") {
try {
const dockerNode = await central.createManagedDockerNode({
nodeId: registeredNode.id,
name: input.nodeName,
imageName: input.imageConfig.image,
imageTag: input.imageConfig.tag,
hostConfig: input.hostConfig,
envVars: input.environment
? Object.fromEntries(
input.environment
.filter((e) => e.includes("="))
.map((e) => {
const idx = e.indexOf("=");
return [e.slice(0, idx), e.slice(idx + 1)];
}),
)
: {},
volumeMounts: [],
resourceSizing: {
memoryMB: input.resourceConfig?.memoryLimitMb,
cpus: input.resourceConfig?.cpuLimit,
memorySwapMB: input.resourceConfig?.memorySwapMb,
},
extraClis: (input.extraClis ?? []) as Array<"claude-cli" | "droid-cli">,
persistentStorage: !!input.persistentVolume,
reachableUrl: reachableUrl ?? null,
apiKey: result.apiKey ?? null,
});
// Update with container details after creation
await central.updateManagedDockerNode(dockerNode.id, {
containerId: result.containerId!,
status: "running",
});
} catch (metaError) {
// Non-fatal: node is registered but Docker metadata couldn't be persisted
console.warn(
"[docker-provisioning] Failed to persist managed Docker node metadata:",
metaError instanceof Error ? metaError.message : String(metaError),
);
}
}
} finally {
await central.close();
}
} catch (registerError) {
// Container is running but unregistered — log warning, return result with error
console.warn(
"[docker-provisioning] Container created but node registration failed:",
registerError instanceof Error ? registerError.message : String(registerError),
);
res.json({
...result,
success: false,
nodeId: undefined,
error: `Container created but node registration failed: ${registerError instanceof Error ? registerError.message : String(registerError)}`,
failedStage: "node-register" as const,
});
return;
}
res.json({ ...result, nodeId });
} catch (error: unknown) {
if (error instanceof ApiError) throw error;
rethrowAsApiError(error);
}
});
// POST /api/docker/deprovision — Stop and remove a Docker node container
router.post("/docker/deprovision", async (req, res) => {
try {
const body = req.body ?? {};
const containerId = typeof body.containerId === "string" ? body.containerId.trim() : "";
if (!containerId) {
throw badRequest("containerId is required");
}
const hostConfig = sanitizeHostConfig(body.hostConfig);
const removeVolumes = Boolean(body.removeVolumes);
const { DockerProvisioningService, DockerClientService, CentralCore } = await import("@fusion/core");
const dockerClientService = new DockerClientService(hostConfig);
const provisionService = new DockerProvisioningService(dockerClientService);
const result = await provisionService.deprovision(containerId, hostConfig, removeVolumes);
if (result.success) {
// Attempt to unregister the node from CentralCore
try {
const central = new CentralCore();
await central.init();
try {
// Use runtime feature check for managed Docker node support
if (typeof central.listManagedDockerNodes === "function") {
const allNodes = await central.listManagedDockerNodes();
const match = allNodes.find((n) => n.containerId === containerId);
if (match?.nodeId) {
try {
await central.unregisterNode(match.nodeId);
} catch {
// Node unregistration failed — container is removed but node registration persists
}
}
// Clean up managed Docker node record
if (match) {
try {
await central.deleteManagedDockerNode(match.id);
} catch {
// Best effort
}
}
}
} finally {
await central.close();
}
} catch {
// CentralCore access failed — container is removed, that's the important part
}
}
res.json(result);
} catch (error: unknown) {
if (error instanceof ApiError) throw error;
rethrowAsApiError(error);
}
});
// POST /api/docker/containers/:containerId/start — Start a stopped container
router.post("/docker/containers/:containerId/start", async (req, res) => {
try {
const containerId = req.params.containerId;
const hostConfig = sanitizeHostConfig((req.body ?? {}).hostConfig);
const { DockerProvisioningService, DockerClientService } = await import("@fusion/core");
const dockerClientService = new DockerClientService(hostConfig);
const provisionService = new DockerProvisioningService(dockerClientService);
const result = await provisionService.startContainer(containerId, hostConfig);
res.json(result);
} catch (error: unknown) {
if (error instanceof ApiError) throw error;
rethrowAsApiError(error);
}
});
// POST /api/docker/containers/:containerId/stop — Stop a running container
router.post("/docker/containers/:containerId/stop", async (req, res) => {
try {
const containerId = req.params.containerId;
const hostConfig = sanitizeHostConfig((req.body ?? {}).hostConfig);
const { DockerProvisioningService, DockerClientService } = await import("@fusion/core");
const dockerClientService = new DockerClientService(hostConfig);
const provisionService = new DockerProvisioningService(dockerClientService);
const result = await provisionService.stopContainer(containerId, hostConfig);
res.json(result);
} catch (error: unknown) {
if (error instanceof ApiError) throw error;
rethrowAsApiError(error);
}
});
// POST /api/docker/containers/:containerId/restart — Restart a container
router.post("/docker/containers/:containerId/restart", async (req, res) => {
try {
const containerId = req.params.containerId;
const hostConfig = sanitizeHostConfig((req.body ?? {}).hostConfig);
const { DockerProvisioningService, DockerClientService } = await import("@fusion/core");
const dockerClientService = new DockerClientService(hostConfig);
const provisionService = new DockerProvisioningService(dockerClientService);
const result = await provisionService.restartContainer(containerId, hostConfig);
res.json(result);
} catch (error: unknown) {
if (error instanceof ApiError) throw error;
rethrowAsApiError(error);
}
});
// GET /api/docker/containers/:containerId/status — Get container runtime status
router.get("/docker/containers/:containerId/status", async (req, res) => {
try {
const containerId = req.params.containerId;
const rawHostConfig = req.query.hostConfig as string | string[] | undefined;
const hostConfig = parseHostConfigQuery(rawHostConfig);
const { DockerProvisioningService, DockerClientService } = await import("@fusion/core");
const dockerClientService = new DockerClientService(hostConfig);
const provisionService = new DockerProvisioningService(dockerClientService);
const result = await provisionService.getContainerStatus(containerId, hostConfig);
res.json(result);
} catch (error: unknown) {
if (error instanceof ApiError) throw error;
rethrowAsApiError(error);
}
});
// GET /api/docker/default-image — Get the default Fusion image configuration
router.get("/docker/default-image", (_req, res) => {
res.json({ image: "runfusion/fusion", tag: "latest" });
});
};