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