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:
491
packages/core/src/__tests__/docker-provisioning.test.ts
Normal file
491
packages/core/src/__tests__/docker-provisioning.test.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const {
|
||||
getDockerInstanceMock,
|
||||
pullMock,
|
||||
followProgressMock,
|
||||
createContainerMock,
|
||||
startMock,
|
||||
stopMock,
|
||||
removeMock,
|
||||
restartMock,
|
||||
inspectMock,
|
||||
getImageInspectMock,
|
||||
getContainerMock,
|
||||
dockerCtor,
|
||||
} = vi.hoisted(() => {
|
||||
const pullMock = vi.fn();
|
||||
const followProgressMock = vi.fn();
|
||||
const createContainerMock = vi.fn();
|
||||
const startMock = vi.fn();
|
||||
const stopMock = vi.fn();
|
||||
const removeMock = vi.fn();
|
||||
const restartMock = vi.fn();
|
||||
const inspectMock = vi.fn();
|
||||
const getImageInspectMock = vi.fn();
|
||||
const getContainerMock = vi.fn();
|
||||
const dockerCtor = vi.fn();
|
||||
|
||||
getContainerMock.mockReturnValue({
|
||||
start: startMock,
|
||||
stop: stopMock,
|
||||
remove: removeMock,
|
||||
restart: restartMock,
|
||||
inspect: inspectMock,
|
||||
});
|
||||
|
||||
return {
|
||||
getDockerInstanceMock: vi.fn(),
|
||||
pullMock,
|
||||
followProgressMock,
|
||||
createContainerMock,
|
||||
startMock,
|
||||
stopMock,
|
||||
removeMock,
|
||||
restartMock,
|
||||
inspectMock,
|
||||
getImageInspectMock,
|
||||
getContainerMock,
|
||||
dockerCtor,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../docker-client.js", () => ({
|
||||
DockerClientService: vi.fn().mockImplementation(() => ({
|
||||
getDockerInstance: getDockerInstanceMock,
|
||||
getContainerInfo: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { DockerProvisioningService } from "../docker-provisioning";
|
||||
import { DockerClientService } from "../docker-client";
|
||||
import type { DockerProvisionInput } from "../types";
|
||||
|
||||
function createMockDocker() {
|
||||
return {
|
||||
pull: pullMock,
|
||||
modem: { followProgress: followProgressMock },
|
||||
createContainer: createContainerMock,
|
||||
getImage: vi.fn(() => ({ inspect: getImageInspectMock })),
|
||||
getContainer: getContainerMock,
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseInput(overrides?: Partial<DockerProvisionInput>): DockerProvisionInput {
|
||||
return {
|
||||
nodeName: "test-node",
|
||||
hostConfig: {},
|
||||
imageConfig: {
|
||||
image: "runfusion/fusion",
|
||||
tag: "latest",
|
||||
pullImage: true,
|
||||
},
|
||||
autoGenerateApiKey: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DockerProvisioningService", () => {
|
||||
let service: DockerProvisioningService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-01"));
|
||||
|
||||
const mockDocker = createMockDocker();
|
||||
getDockerInstanceMock.mockResolvedValue(mockDocker);
|
||||
pullMock.mockResolvedValue("stream");
|
||||
followProgressMock.mockImplementation((_stream: unknown, cb: (err: Error | null) => void) => cb(null));
|
||||
createContainerMock.mockResolvedValue({
|
||||
id: "container-abc123",
|
||||
start: startMock,
|
||||
inspect: inspectMock,
|
||||
remove: removeMock,
|
||||
});
|
||||
startMock.mockResolvedValue(undefined);
|
||||
inspectMock.mockResolvedValue({
|
||||
Id: "container-abc123",
|
||||
NetworkSettings: { Ports: { "4040/tcp": [{ HostPort: "49152" }] } },
|
||||
});
|
||||
removeMock.mockResolvedValue(undefined);
|
||||
stopMock.mockResolvedValue(undefined);
|
||||
restartMock.mockResolvedValue(undefined);
|
||||
getImageInspectMock.mockResolvedValue({});
|
||||
|
||||
const clientService = new DockerClientService();
|
||||
service = new DockerProvisioningService(clientService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("provision", () => {
|
||||
it("provisions a container successfully", async () => {
|
||||
const result = await service.provision(createBaseInput());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.containerId).toBe("container-abc123");
|
||||
expect(result.containerName).toMatch(/^fusion-test-node-[a-f0-9]{8}$/);
|
||||
expect(result.apiKey).toMatch(/^fn_[a-f0-9]+$/);
|
||||
expect(result.portMapping).toBe("4040:49152");
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Verify delegation to DockerClientService
|
||||
expect(getDockerInstanceMock).toHaveBeenCalledWith({});
|
||||
|
||||
// Verify image pull
|
||||
expect(pullMock).toHaveBeenCalledWith("runfusion/fusion:latest", undefined);
|
||||
|
||||
// Verify container creation
|
||||
expect(createContainerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Image: "runfusion/fusion:latest",
|
||||
ExposedPorts: { "4040/tcp": {} },
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify environment includes required vars
|
||||
const createCall = createContainerMock.mock.calls[0][0];
|
||||
expect(createCall.Env).toContain("FUSION_NODE_NAME=test-node");
|
||||
expect(createCall.Env).toContain("FUSION_MODE=serve");
|
||||
expect(createCall.Env).toContain("FUSION_PORT=4040");
|
||||
expect(createCall.Env).toContain("FUSION_DATA_DIR=/data");
|
||||
expect(createCall.Env.some((e: string) => e.startsWith("FUSION_API_KEY="))).toBe(true);
|
||||
|
||||
// Verify labels
|
||||
expect(createCall.Labels).toEqual(
|
||||
expect.objectContaining({
|
||||
"fusion.managed": "true",
|
||||
"fusion.node-name": "test-node",
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify HostConfig
|
||||
expect(createCall.HostConfig.RestartPolicy).toEqual({ Name: "unless-stopped" });
|
||||
expect(createCall.HostConfig.PortBindings).toEqual({ "4040/tcp": [{ HostPort: "0" }] });
|
||||
});
|
||||
|
||||
it("includes extra CLIs in environment", async () => {
|
||||
const result = await service.provision(
|
||||
createBaseInput({ extraClis: ["claude", "droid"] }),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const createCall = createContainerMock.mock.calls[0][0];
|
||||
expect(createCall.Env).toContain("FUSION_EXTRA_CLIS=claude,droid");
|
||||
});
|
||||
|
||||
it("applies resource limits to HostConfig", async () => {
|
||||
await service.provision(
|
||||
createBaseInput({
|
||||
resourceConfig: {
|
||||
cpuLimit: 2,
|
||||
memoryLimitMb: 4096,
|
||||
memorySwapMb: 8192,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const hostConfig = createContainerMock.mock.calls[0][0].HostConfig;
|
||||
expect(hostConfig.Memory).toBe(4096 * 1024 * 1024);
|
||||
expect(hostConfig.NanoCpus).toBe(2_000_000_000);
|
||||
expect(hostConfig.MemorySwap).toBe(8192 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("applies custom network config", async () => {
|
||||
await service.provision(
|
||||
createBaseInput({ network: "fusion-net" }),
|
||||
);
|
||||
|
||||
const createCall = createContainerMock.mock.calls[0][0];
|
||||
expect(createCall.NetworkingConfig).toEqual({
|
||||
EndpointsConfig: { "fusion-net": {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses persistent volume mount", async () => {
|
||||
await service.provision(
|
||||
createBaseInput({ persistentVolume: "fusion-data" }),
|
||||
);
|
||||
|
||||
const hostConfig = createContainerMock.mock.calls[0][0].HostConfig;
|
||||
expect(hostConfig.Binds).toContain("fusion-data:/data");
|
||||
});
|
||||
|
||||
it("appends user-provided volume mounts", async () => {
|
||||
await service.provision(
|
||||
createBaseInput({
|
||||
persistentVolume: "fusion-data",
|
||||
volumeMounts: ["/host/path:/container/path"],
|
||||
}),
|
||||
);
|
||||
|
||||
const hostConfig = createContainerMock.mock.calls[0][0].HostConfig;
|
||||
expect(hostConfig.Binds).toEqual(["fusion-data:/data", "/host/path:/container/path"]);
|
||||
});
|
||||
|
||||
it("appends user-provided environment variables", async () => {
|
||||
await service.provision(
|
||||
createBaseInput({ environment: ["CUSTOM_VAR=value"] }),
|
||||
);
|
||||
|
||||
const createCall = createContainerMock.mock.calls[0][0];
|
||||
expect(createCall.Env).toContain("CUSTOM_VAR=value");
|
||||
});
|
||||
|
||||
it("uses user-provided API key when autoGenerateApiKey is false", async () => {
|
||||
const result = await service.provision(
|
||||
createBaseInput({
|
||||
autoGenerateApiKey: false,
|
||||
apiKey: "my-custom-key",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.apiKey).toBe("my-custom-key");
|
||||
const createCall = createContainerMock.mock.calls[0][0];
|
||||
expect(createCall.Env).toContain("FUSION_API_KEY=my-custom-key");
|
||||
});
|
||||
|
||||
it("returns error on image pull failure", async () => {
|
||||
pullMock.mockRejectedValue(new Error("registry unavailable"));
|
||||
|
||||
const result = await service.provision(createBaseInput());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failedStage).toBe("image-pull");
|
||||
expect(result.error).toContain("registry unavailable");
|
||||
});
|
||||
|
||||
it("returns error when local image not found and pullImage is false", async () => {
|
||||
getImageInspectMock.mockRejectedValue(new Error("no such image"));
|
||||
|
||||
const result = await service.provision(
|
||||
createBaseInput({ imageConfig: { image: "runfusion/fusion", tag: "latest", pullImage: false } }),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failedStage).toBe("image-pull");
|
||||
expect(result.error).toContain("not found locally");
|
||||
});
|
||||
|
||||
it("returns error on container create failure", async () => {
|
||||
createContainerMock.mockRejectedValue(new Error("name conflict"));
|
||||
|
||||
const result = await service.provision(createBaseInput());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failedStage).toBe("container-create");
|
||||
expect(result.error).toContain("name conflict");
|
||||
});
|
||||
|
||||
it("returns error on container start failure and cleans up", async () => {
|
||||
startMock.mockRejectedValue(new Error("port already in use"));
|
||||
|
||||
const result = await service.provision(createBaseInput());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failedStage).toBe("container-start");
|
||||
expect(result.error).toContain("port already in use");
|
||||
expect(removeMock).toHaveBeenCalledWith({ force: true });
|
||||
});
|
||||
|
||||
it("passes registry auth when credentials are provided", async () => {
|
||||
await service.provision(
|
||||
createBaseInput({
|
||||
imageConfig: {
|
||||
image: "ghcr.io/runfusion/fusion",
|
||||
tag: "v1",
|
||||
pullImage: true,
|
||||
registryUsername: "user",
|
||||
registryPassword: "pass",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(pullMock).toHaveBeenCalledWith(
|
||||
"ghcr.io/runfusion/fusion:v1",
|
||||
{ authconfig: { username: "user", password: "pass" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("delegates getDockerInstance to DockerClientService", async () => {
|
||||
const hostConfig = { host: "tcp://1.2.3.4:2376" };
|
||||
await service.provision(createBaseInput({ hostConfig }));
|
||||
|
||||
expect(getDockerInstanceMock).toHaveBeenCalledWith(hostConfig);
|
||||
});
|
||||
|
||||
it("includes reachableUrl in environment when provided", async () => {
|
||||
await service.provision(
|
||||
createBaseInput({ reachableUrl: "http://my-node:4040" }),
|
||||
);
|
||||
|
||||
const createCall = createContainerMock.mock.calls[0][0];
|
||||
expect(createCall.Env).toContain("FUSION_REACHABLE_URL=http://my-node:4040");
|
||||
});
|
||||
|
||||
it("merges user-provided labels", async () => {
|
||||
await service.provision(
|
||||
createBaseInput({ labels: { env: "production" } }),
|
||||
);
|
||||
|
||||
const createCall = createContainerMock.mock.calls[0][0];
|
||||
expect(createCall.Labels).toEqual({
|
||||
"fusion.managed": "true",
|
||||
"fusion.node-name": "test-node",
|
||||
env: "production",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deprovision", () => {
|
||||
it("stops and removes container", async () => {
|
||||
const result = await service.deprovision("container-123", {}, false);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(getContainerMock).toHaveBeenCalledWith("container-123");
|
||||
expect(stopMock).toHaveBeenCalledWith({ t: 10 });
|
||||
expect(removeMock).toHaveBeenCalledWith({ force: true, v: false });
|
||||
});
|
||||
|
||||
it("passes removeVolumes flag to remove", async () => {
|
||||
await service.deprovision("container-123", {}, true);
|
||||
|
||||
expect(removeMock).toHaveBeenCalledWith({ force: true, v: true });
|
||||
});
|
||||
|
||||
it("proceeds to remove when container is already stopped", async () => {
|
||||
stopMock.mockRejectedValue(new Error("is not running"));
|
||||
|
||||
const result = await service.deprovision("container-123", {}, false);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(removeMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("proceeds to remove on 'already stopped' error", async () => {
|
||||
stopMock.mockRejectedValue(new Error("already stopped"));
|
||||
|
||||
const result = await service.deprovision("container-123", {}, false);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(removeMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns error on remove failure", async () => {
|
||||
removeMock.mockRejectedValue(new Error("removal failed"));
|
||||
|
||||
const result = await service.deprovision("container-123", {}, false);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("removal failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("startContainer", () => {
|
||||
it("starts a container", async () => {
|
||||
const result = await service.startContainer("container-123", {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(getContainerMock).toHaveBeenCalledWith("container-123");
|
||||
expect(startMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns error on start failure", async () => {
|
||||
startMock.mockRejectedValue(new Error("already running"));
|
||||
|
||||
const result = await service.startContainer("container-123", {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("already running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopContainer", () => {
|
||||
it("stops a container", async () => {
|
||||
const result = await service.stopContainer("container-123", {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(stopMock).toHaveBeenCalledWith({ t: 10 });
|
||||
});
|
||||
|
||||
it("returns error on stop failure", async () => {
|
||||
stopMock.mockRejectedValue(new Error("not running"));
|
||||
|
||||
const result = await service.stopContainer("container-123", {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("not running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("restartContainer", () => {
|
||||
it("restarts a container", async () => {
|
||||
const result = await service.restartContainer("container-123", {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(restartMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns error on restart failure", async () => {
|
||||
restartMock.mockRejectedValue(new Error("timeout"));
|
||||
|
||||
const result = await service.restartContainer("container-123", {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("timeout");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContainerStatus", () => {
|
||||
it("returns container status via DockerClientService", async () => {
|
||||
const mockDocker = createMockDocker();
|
||||
mockDocker.getContainer = vi.fn().mockReturnValue({
|
||||
inspect: vi.fn().mockResolvedValue({
|
||||
Id: "container-abc",
|
||||
Name: "/fusion-test",
|
||||
State: { Status: "running", Running: true, Paused: false, Restarting: false, Dead: false },
|
||||
Config: { Image: "runfusion/fusion:latest" },
|
||||
Created: "2025-01-01T00:00:00Z",
|
||||
}),
|
||||
});
|
||||
getDockerInstanceMock.mockResolvedValue(mockDocker);
|
||||
|
||||
const result = await service.getContainerStatus("container-abc", {});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe("container-abc");
|
||||
expect(result!.name).toBe("fusion-test");
|
||||
expect(result!.status).toBe("running");
|
||||
expect(result!.state.running).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null for non-existent container", async () => {
|
||||
const mockDocker = createMockDocker();
|
||||
mockDocker.getContainer = vi.fn().mockReturnValue({
|
||||
inspect: vi.fn().mockRejectedValue(new Error("404 no such container")),
|
||||
});
|
||||
getDockerInstanceMock.mockResolvedValue(mockDocker);
|
||||
|
||||
const result = await service.getContainerStatus("nonexistent", {});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("delegates to getDockerInstance with hostConfig", async () => {
|
||||
const hostConfig = { host: "tcp://1.2.3.4:2376" };
|
||||
const mockDocker = createMockDocker();
|
||||
mockDocker.getContainer = vi.fn().mockReturnValue({
|
||||
inspect: vi.fn().mockRejectedValue(new Error("404")),
|
||||
});
|
||||
getDockerInstanceMock.mockResolvedValue(mockDocker);
|
||||
|
||||
await service.getContainerStatus("abc", hostConfig);
|
||||
|
||||
expect(getDockerInstanceMock).toHaveBeenCalledWith(hostConfig);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -152,6 +152,18 @@ export class DockerClientService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Docker instance for the given host config.
|
||||
* If no host config is provided, uses the default config (cached).
|
||||
* Otherwise creates a fresh instance for the custom config.
|
||||
*/
|
||||
async getDockerInstance(hostConfig?: DockerHostConfig): Promise<Docker> {
|
||||
if (!hostConfig || hostConfig === this.defaultHostConfig) {
|
||||
return this.getInstance();
|
||||
}
|
||||
return this.createDockerInstance(hostConfig);
|
||||
}
|
||||
|
||||
async getContainerInfo(containerId: string): Promise<DockerContainerInspectResult | null> {
|
||||
try {
|
||||
const docker = await this.getInstance();
|
||||
|
||||
341
packages/core/src/docker-provisioning.ts
Normal file
341
packages/core/src/docker-provisioning.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type Docker from "dockerode";
|
||||
import type {
|
||||
DockerContainerInspectResult,
|
||||
DockerHostConfig,
|
||||
DockerProvisionInput,
|
||||
DockerProvisionResult,
|
||||
} from "./types.js";
|
||||
import { DockerClientService } from "./docker-client.js";
|
||||
|
||||
const log = {
|
||||
info: (...args: unknown[]) => console.log("[docker-provisioning]", ...args),
|
||||
error: (...args: unknown[]) => console.error("[docker-provisioning]", ...args),
|
||||
warn: (...args: unknown[]) => console.warn("[docker-provisioning]", ...args),
|
||||
};
|
||||
|
||||
/**
|
||||
* Service for provisioning Docker-based Fusion nodes.
|
||||
* Creates containers from a prebuilt image, manages their lifecycle,
|
||||
* and provides start/stop/restart operations.
|
||||
*/
|
||||
export class DockerProvisioningService {
|
||||
constructor(private readonly dockerClientService: DockerClientService) {}
|
||||
|
||||
/**
|
||||
* Provision a new Docker container for a Fusion node.
|
||||
* Pulls or validates the image, creates and starts the container,
|
||||
* and returns the result with container details.
|
||||
*/
|
||||
async provision(input: DockerProvisionInput): Promise<DockerProvisionResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const docker = await this.dockerClientService.getDockerInstance(input.hostConfig);
|
||||
const { image, tag } = input.imageConfig;
|
||||
const imageRef = `${image}:${tag}`;
|
||||
|
||||
// Step 1: Pull or validate image
|
||||
if (input.imageConfig.pullImage) {
|
||||
log.info(`Pulling image ${imageRef}...`);
|
||||
try {
|
||||
const authOptions =
|
||||
input.imageConfig.registryUsername || input.imageConfig.registryPassword
|
||||
? {
|
||||
authconfig: {
|
||||
username: input.imageConfig.registryUsername ?? "",
|
||||
password: input.imageConfig.registryPassword ?? "",
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
const stream = await docker.pull(imageRef, authOptions);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
docker.modem.followProgress(stream, (err: Error | null) =>
|
||||
err ? reject(err) : resolve(),
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Image pull failed: ${message}`);
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to pull image ${imageRef}: ${message}`,
|
||||
failedStage: "image-pull",
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Validate image exists locally
|
||||
try {
|
||||
await docker.getImage(imageRef).inspect();
|
||||
} catch {
|
||||
log.error(`Image ${imageRef} not found locally`);
|
||||
return {
|
||||
success: false,
|
||||
error: `Image ${imageRef} not found locally. Set pullImage: true to pull it.`,
|
||||
failedStage: "image-pull",
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Generate container name
|
||||
const containerName = `fusion-${input.nodeName.toLowerCase().replace(/[^a-z0-9-]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
// Step 3: Generate API key if needed
|
||||
const apiKey = input.autoGenerateApiKey
|
||||
? `fn_${randomUUID().replace(/-/g, "")}`
|
||||
: input.apiKey ?? "";
|
||||
|
||||
// Step 4: Build environment
|
||||
const envArray: string[] = [
|
||||
`FUSION_NODE_NAME=${input.nodeName}`,
|
||||
`FUSION_API_KEY=${apiKey}`,
|
||||
"FUSION_MODE=serve",
|
||||
"FUSION_PORT=4040",
|
||||
"FUSION_DATA_DIR=/data",
|
||||
];
|
||||
|
||||
if (input.reachableUrl) {
|
||||
envArray.push(`FUSION_REACHABLE_URL=${input.reachableUrl}`);
|
||||
}
|
||||
|
||||
if (input.extraClis && input.extraClis.length > 0) {
|
||||
envArray.push(`FUSION_EXTRA_CLIS=${input.extraClis.join(",")}`);
|
||||
}
|
||||
|
||||
// User-provided environment appended last (allows overrides)
|
||||
if (input.environment) {
|
||||
envArray.push(...input.environment);
|
||||
}
|
||||
|
||||
// Step 5: Build volume mounts
|
||||
const mounts: string[] = [];
|
||||
if (input.persistentVolume) {
|
||||
mounts.push(`${input.persistentVolume}:/data`);
|
||||
}
|
||||
if (input.volumeMounts) {
|
||||
mounts.push(...input.volumeMounts);
|
||||
}
|
||||
|
||||
// Step 6: Build container create options
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const createOptions: any = {
|
||||
name: containerName,
|
||||
Image: imageRef,
|
||||
Env: envArray,
|
||||
HostConfig: {
|
||||
PortBindings: { "4040/tcp": [{ HostPort: "0" }] },
|
||||
...(mounts.length > 0 ? { Binds: mounts } : {}),
|
||||
...(input.resourceConfig?.memoryLimitMb
|
||||
? { Memory: input.resourceConfig.memoryLimitMb * 1024 * 1024 }
|
||||
: {}),
|
||||
...(input.resourceConfig?.cpuLimit
|
||||
? { NanoCpus: Math.round(input.resourceConfig.cpuLimit * 1e9) }
|
||||
: {}),
|
||||
...(input.resourceConfig?.memorySwapMb !== undefined
|
||||
? { MemorySwap: input.resourceConfig.memorySwapMb * 1024 * 1024 }
|
||||
: {}),
|
||||
RestartPolicy: { Name: "unless-stopped" },
|
||||
},
|
||||
...(input.network
|
||||
? { NetworkingConfig: { EndpointsConfig: { [input.network]: {} } } }
|
||||
: {}),
|
||||
Labels: {
|
||||
"fusion.managed": "true",
|
||||
"fusion.node-name": input.nodeName,
|
||||
...(input.labels || {}),
|
||||
},
|
||||
ExposedPorts: { "4040/tcp": {} },
|
||||
};
|
||||
|
||||
// Step 7: Create container
|
||||
let container: Docker.Container;
|
||||
try {
|
||||
container = await docker.createContainer(createOptions);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Container creation failed: ${message}`);
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to create container: ${message}`,
|
||||
failedStage: "container-create",
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 8: Start container
|
||||
try {
|
||||
await container.start();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Container start failed: ${message}, cleaning up...`);
|
||||
// Attempt cleanup
|
||||
try {
|
||||
await container.remove({ force: true });
|
||||
} catch {
|
||||
// Best effort cleanup
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to start container: ${message}`,
|
||||
failedStage: "container-start",
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 9: Inspect to get port mapping
|
||||
let portMapping: string | undefined;
|
||||
try {
|
||||
const inspectResult = await container.inspect();
|
||||
const portInfo = inspectResult.NetworkSettings?.Ports?.["4040/tcp"]?.[0];
|
||||
if (portInfo?.HostPort) {
|
||||
portMapping = `4040:${portInfo.HostPort}`;
|
||||
}
|
||||
} catch {
|
||||
// Container is running, port info is nice-to-have
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
log.info(`Container ${containerName} provisioned successfully in ${durationMs}ms`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
containerId: container.id,
|
||||
containerName,
|
||||
apiKey,
|
||||
portMapping,
|
||||
durationMs,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Unexpected provisioning error: ${message}`);
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
failedStage: "container-create",
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprovision a Docker container — stop and remove it.
|
||||
*/
|
||||
async deprovision(
|
||||
containerId: string,
|
||||
hostConfig: DockerHostConfig,
|
||||
removeVolumes: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const docker = await this.dockerClientService.getDockerInstance(hostConfig);
|
||||
const container = docker.getContainer(containerId);
|
||||
|
||||
// Stop with timeout — ignore "already stopped" errors
|
||||
try {
|
||||
await container.stop({ t: 10 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes("already stopped") && !message.includes("is not running")) {
|
||||
log.warn(`Container stop returned: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await container.remove({ force: true, v: removeVolumes });
|
||||
log.info(`Container ${containerId} deprovisioned`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Deprovision failed for ${containerId}: ${message}`);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the runtime status of a container.
|
||||
* Creates a Docker client for the given hostConfig to query container info.
|
||||
*/
|
||||
async getContainerStatus(
|
||||
containerId: string,
|
||||
hostConfig: DockerHostConfig,
|
||||
): Promise<DockerContainerInspectResult | null> {
|
||||
try {
|
||||
const docker = await this.dockerClientService.getDockerInstance(hostConfig);
|
||||
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 = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("404") || message.toLowerCase().includes("no such container")) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an existing container.
|
||||
*/
|
||||
async startContainer(
|
||||
containerId: string,
|
||||
hostConfig: DockerHostConfig,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const docker = await this.dockerClientService.getDockerInstance(hostConfig);
|
||||
const container = docker.getContainer(containerId);
|
||||
await container.start();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a running container with a 10-second timeout.
|
||||
*/
|
||||
async stopContainer(
|
||||
containerId: string,
|
||||
hostConfig: DockerHostConfig,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const docker = await this.dockerClientService.getDockerInstance(hostConfig);
|
||||
const container = docker.getContainer(containerId);
|
||||
await container.stop({ t: 10 });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart a container (stop then start).
|
||||
*/
|
||||
async restartContainer(
|
||||
containerId: string,
|
||||
hostConfig: DockerHostConfig,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const docker = await this.dockerClientService.getDockerInstance(hostConfig);
|
||||
const container = docker.getContainer(containerId);
|
||||
await container.restart();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,6 +392,7 @@ 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 { DockerProvisioningService } from "./docker-provisioning.js";
|
||||
export type {
|
||||
ConnectionErrorType,
|
||||
ConnectionOptions,
|
||||
@@ -418,6 +419,10 @@ export type {
|
||||
DockerContextInfo,
|
||||
DockerConnectivityResult,
|
||||
DockerContainerInspectResult,
|
||||
DockerNodeImageConfig,
|
||||
DockerNodeResourceConfig,
|
||||
DockerProvisionInput,
|
||||
DockerProvisionResult,
|
||||
ManagedDockerNode,
|
||||
ManagedDockerNodeInput,
|
||||
ManagedDockerNodeUpdate,
|
||||
|
||||
@@ -2635,6 +2635,84 @@ export interface DockerContainerInspectResult {
|
||||
};
|
||||
}
|
||||
|
||||
/** Configuration for the Fusion Docker image to use for provisioning */
|
||||
export interface DockerNodeImageConfig {
|
||||
/** Image name (e.g., "runfusion/fusion" or "ghcr.io/runfusion/fusion") */
|
||||
image: string;
|
||||
/** Image tag (e.g., "latest", "0.14.1") */
|
||||
tag: string;
|
||||
/** Whether to pull the image before creating the container */
|
||||
pullImage: boolean;
|
||||
/** Optional registry authentication — username */
|
||||
registryUsername?: string;
|
||||
/** Optional registry authentication — password/token */
|
||||
registryPassword?: string;
|
||||
}
|
||||
|
||||
/** Resource constraints for a provisioned Docker container */
|
||||
export interface DockerNodeResourceConfig {
|
||||
/** CPU limit in cores (e.g., 2 = 2 CPUs). Undefined = unlimited */
|
||||
cpuLimit?: number;
|
||||
/** Memory limit in megabytes. Undefined = unlimited */
|
||||
memoryLimitMb?: number;
|
||||
/** Memory swap limit in megabytes. -1 = unlimited swap. Undefined = default */
|
||||
memorySwapMb?: number;
|
||||
}
|
||||
|
||||
/** Input for provisioning a new Docker-based Fusion node */
|
||||
export interface DockerProvisionInput {
|
||||
/** Display name for the node (must be unique) */
|
||||
nodeName: string;
|
||||
/** Docker host configuration — where to create the container */
|
||||
hostConfig: DockerHostConfig;
|
||||
/** Image configuration — which Fusion image to use */
|
||||
imageConfig: DockerNodeImageConfig;
|
||||
/** Resource constraints for the container */
|
||||
resourceConfig?: DockerNodeResourceConfig;
|
||||
/** Environment variables to set in the container (KEY=VALUE strings) */
|
||||
environment?: string[];
|
||||
/** Volume mount specifications (e.g., ["fusion-data:/data", "/host/path:/container/path"]) */
|
||||
volumeMounts?: string[];
|
||||
/** Named volume for persistent Fusion data storage. If provided, mounted at /data */
|
||||
persistentVolume?: string;
|
||||
/** Optional extra CLI tools to include in the container (e.g., ["claude", "droid"]) */
|
||||
extraClis?: string[];
|
||||
/** The URL/hostname where this node will be reachable by other nodes */
|
||||
reachableUrl?: string;
|
||||
/** Whether to auto-generate an API key for this node */
|
||||
autoGenerateApiKey: boolean;
|
||||
/** Explicit API key to use (if autoGenerateApiKey is false) */
|
||||
apiKey?: string;
|
||||
/** Maximum concurrent tasks for this node (default: 2) */
|
||||
maxConcurrent?: number;
|
||||
/** Optional Docker network to attach the container to */
|
||||
network?: string;
|
||||
/** Optional container labels (key-value pairs) */
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Result of a Docker node provisioning operation */
|
||||
export interface DockerProvisionResult {
|
||||
/** Whether provisioning succeeded */
|
||||
success: boolean;
|
||||
/** The container ID created by Docker */
|
||||
containerId?: string;
|
||||
/** The container name (generated or specified) */
|
||||
containerName?: string;
|
||||
/** The registered node ID in CentralCore */
|
||||
nodeId?: string;
|
||||
/** The API key generated or assigned for this node */
|
||||
apiKey?: string;
|
||||
/** The port mapping (if applicable) */
|
||||
portMapping?: string;
|
||||
/** Error message if provisioning failed */
|
||||
error?: string;
|
||||
/** The stage at which failure occurred (for error reporting) */
|
||||
failedStage?: "image-pull" | "container-create" | "container-start" | "node-register" | "config-apply";
|
||||
/** Duration of the provisioning operation in ms */
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
/** A single plugin's version information for sync comparison */
|
||||
export interface PluginVersionEntry {
|
||||
/** Plugin ID (matches PluginManifest.id) */
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
.provisioning-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-xl);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.provisioning-status__spinner {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.provisioning-status__dot {
|
||||
display: inline-block;
|
||||
width: var(--space-sm);
|
||||
height: var(--space-sm);
|
||||
border-radius: 50%;
|
||||
background-color: var(--todo);
|
||||
animation: provisioning-pulse var(--transition-normal) ease-in-out infinite;
|
||||
}
|
||||
|
||||
.provisioning-status__dot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.provisioning-status__dot:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes provisioning-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.provisioning-status__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.provisioning-status__icon--success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.provisioning-status__icon--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.provisioning-status__text {
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.provisioning-status__stage {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.provisioning-status__detail {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.provisioning-status__detail code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
background-color: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
||||
padding: 0.125em 0.375em;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.provisioning-status__hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.provisioning-status__hint code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.provisioning-status {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
}
|
||||
125
packages/dashboard/app/components/DockerProvisioningStatus.tsx
Normal file
125
packages/dashboard/app/components/DockerProvisioningStatus.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { CheckCircle, AlertCircle, ExternalLink, RefreshCw, Terminal } from "lucide-react";
|
||||
import type { DockerProvisionResult } from "@fusion/core";
|
||||
import "./DockerProvisioningStatus.css";
|
||||
|
||||
const STAGES = [
|
||||
"Pulling image...",
|
||||
"Creating container...",
|
||||
"Starting container...",
|
||||
"Registering node...",
|
||||
];
|
||||
|
||||
const STAGE_INTERVAL_MS = 2000;
|
||||
|
||||
export interface DockerProvisioningStatusProps {
|
||||
result?: DockerProvisionResult;
|
||||
isProvisioning: boolean;
|
||||
error?: string | null;
|
||||
onRetry?: () => void;
|
||||
onViewNode?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export function DockerProvisioningStatus({
|
||||
result,
|
||||
isProvisioning,
|
||||
error,
|
||||
onRetry,
|
||||
onViewNode,
|
||||
}: DockerProvisioningStatusProps) {
|
||||
const [stageIndex, setStageIndex] = useState(0);
|
||||
|
||||
// Animate stages during provisioning
|
||||
useEffect(() => {
|
||||
if (!isProvisioning || result) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStageIndex(0);
|
||||
const timer = setInterval(() => {
|
||||
setStageIndex((prev) => (prev < STAGES.length - 1 ? prev + 1 : prev));
|
||||
}, STAGE_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isProvisioning, result]);
|
||||
|
||||
// Provisioning state
|
||||
if (isProvisioning && !result) {
|
||||
return (
|
||||
<div className="provisioning-status provisioning-status--loading">
|
||||
<div className="provisioning-status__spinner">
|
||||
<span className="provisioning-status__dot" />
|
||||
<span className="provisioning-status__dot" />
|
||||
<span className="provisioning-status__dot" />
|
||||
</div>
|
||||
<div className="provisioning-status__text">Creating Docker node...</div>
|
||||
<div className="provisioning-status__stage">{STAGES[stageIndex]}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (result?.success) {
|
||||
const durationSec = result.durationMs ? (result.durationMs / 1000).toFixed(1) : null;
|
||||
|
||||
return (
|
||||
<div className="provisioning-status provisioning-status--success">
|
||||
<div className="provisioning-status__icon provisioning-status__icon--success">
|
||||
<CheckCircle size={24} />
|
||||
</div>
|
||||
<div className="provisioning-status__text">Node created successfully!</div>
|
||||
{result.containerId && (
|
||||
<div className="provisioning-status__detail">
|
||||
Container: <code>{result.containerId.slice(0, 12)}</code>
|
||||
</div>
|
||||
)}
|
||||
{durationSec && (
|
||||
<div className="provisioning-status__detail">
|
||||
Provisioned in {durationSec}s
|
||||
</div>
|
||||
)}
|
||||
{result.nodeId && onViewNode && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => onViewNode(result.nodeId!)}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
View Node
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Failure state
|
||||
const displayError = error ?? result?.error;
|
||||
const failedStage = result?.failedStage;
|
||||
|
||||
return (
|
||||
<div className="provisioning-status provisioning-status--error">
|
||||
<div className="provisioning-status__icon provisioning-status__icon--error">
|
||||
<AlertCircle size={24} />
|
||||
</div>
|
||||
<div className="provisioning-status__text">
|
||||
{displayError ?? "Provisioning failed"}
|
||||
</div>
|
||||
{failedStage && (
|
||||
<div className="provisioning-status__detail">
|
||||
Failed at: {failedStage}
|
||||
</div>
|
||||
)}
|
||||
{result?.containerName && (
|
||||
<div className="provisioning-status__hint">
|
||||
<Terminal size={14} />
|
||||
<code>docker logs {result.containerName}</code>
|
||||
</div>
|
||||
)}
|
||||
{onRetry && (
|
||||
<button className="btn btn-sm" onClick={onRetry}>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { DockerProvisioningStatus } from "../DockerProvisioningStatus";
|
||||
import type { DockerProvisionResult } from "@fusion/core";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
CheckCircle: () => <span data-testid="icon-check">CheckCircle</span>,
|
||||
AlertCircle: () => <span data-testid="icon-alert">AlertCircle</span>,
|
||||
ExternalLink: () => <span data-testid="icon-external">ExternalLink</span>,
|
||||
RefreshCw: () => <span data-testid="icon-refresh">RefreshCw</span>,
|
||||
Terminal: () => <span data-testid="icon-terminal">Terminal</span>,
|
||||
}));
|
||||
|
||||
describe("DockerProvisioningStatus", () => {
|
||||
describe("provisioning state", () => {
|
||||
it("renders spinner and stage text when isProvisioning=true", () => {
|
||||
const { container } = render(
|
||||
<DockerProvisioningStatus isProvisioning={true} />,
|
||||
);
|
||||
|
||||
// Should show loading state with dots
|
||||
const dots = container.querySelectorAll(".provisioning-status__dot");
|
||||
expect(dots.length).toBe(3);
|
||||
|
||||
expect(screen.getByText("Creating Docker node...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("success state", () => {
|
||||
const successResult: DockerProvisionResult = {
|
||||
success: true,
|
||||
containerId: "abc123def456ghi789jkl012mno345pqr678",
|
||||
containerName: "fusion-test-abc12345",
|
||||
nodeId: "node_abc123",
|
||||
apiKey: "fn_testkey",
|
||||
portMapping: "4040:49152",
|
||||
durationMs: 2500,
|
||||
};
|
||||
|
||||
it("renders success message and container ID", () => {
|
||||
render(
|
||||
<DockerProvisioningStatus
|
||||
isProvisioning={false}
|
||||
result={successResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Node created successfully!")).toBeInTheDocument();
|
||||
// Container ID is truncated (first 12 chars)
|
||||
expect(screen.getByText("abc123def456")).toBeInTheDocument();
|
||||
// Duration displayed
|
||||
expect(screen.getByText("Provisioned in 2.5s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders View Node button when onViewNode is provided with nodeId", () => {
|
||||
const onViewNode = vi.fn();
|
||||
render(
|
||||
<DockerProvisioningStatus
|
||||
isProvisioning={false}
|
||||
result={successResult}
|
||||
onViewNode={onViewNode}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByText("View Node");
|
||||
expect(button).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(onViewNode).toHaveBeenCalledWith("node_abc123");
|
||||
});
|
||||
|
||||
it("does not render View Node button without nodeId", () => {
|
||||
const onViewNode = vi.fn();
|
||||
render(
|
||||
<DockerProvisioningStatus
|
||||
isProvisioning={false}
|
||||
result={{ ...successResult, nodeId: undefined }}
|
||||
onViewNode={onViewNode}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("View Node")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("failure state", () => {
|
||||
const failureResult: DockerProvisionResult = {
|
||||
success: false,
|
||||
error: "Image pull failed: not found",
|
||||
failedStage: "image-pull",
|
||||
durationMs: 500,
|
||||
};
|
||||
|
||||
it("renders error message and failed stage", () => {
|
||||
render(
|
||||
<DockerProvisioningStatus
|
||||
isProvisioning={false}
|
||||
result={failureResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Image pull failed: not found")).toBeInTheDocument();
|
||||
expect(screen.getByText("Failed at: image-pull")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Retry button when onRetry is provided", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(
|
||||
<DockerProvisioningStatus
|
||||
isProvisioning={false}
|
||||
result={failureResult}
|
||||
onRetry={onRetry}
|
||||
/>,
|
||||
);
|
||||
|
||||
const retryButton = screen.getByText("Retry");
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not render Retry button when onRetry is not provided", () => {
|
||||
render(
|
||||
<DockerProvisioningStatus
|
||||
isProvisioning={false}
|
||||
result={failureResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Retry")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders docker logs hint when containerName is available", () => {
|
||||
render(
|
||||
<DockerProvisioningStatus
|
||||
isProvisioning={false}
|
||||
result={{
|
||||
...failureResult,
|
||||
containerName: "fusion-test-abc12345",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("docker logs fusion-test-abc12345"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays error from prop when result has no error", () => {
|
||||
render(
|
||||
<DockerProvisioningStatus
|
||||
isProvisioning={false}
|
||||
result={{ success: false }}
|
||||
error="Something went wrong"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays fallback error message when no error is available", () => {
|
||||
render(
|
||||
<DockerProvisioningStatus
|
||||
isProvisioning={false}
|
||||
result={{ success: false }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Provisioning failed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
// Must be called before importing the hook
|
||||
const fetchMock = vi.fn();
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// Import the hook after mock setup
|
||||
import { useDockerProvisioning } from "../useDockerProvisioning";
|
||||
|
||||
function jsonOk(body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(body),
|
||||
});
|
||||
}
|
||||
|
||||
function jsonError(status: number, body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("useDockerProvisioning", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
describe("provision", () => {
|
||||
it("posts to /api/docker/provision and returns result", async () => {
|
||||
const result = {
|
||||
success: true,
|
||||
containerId: "abc",
|
||||
containerName: "fusion-test-abc12345",
|
||||
apiKey: "fn_key",
|
||||
portMapping: "4040:49152",
|
||||
durationMs: 1000,
|
||||
};
|
||||
fetchMock.mockReturnValue(jsonOk(result));
|
||||
|
||||
const { result: hookResult } = renderHook(() => useDockerProvisioning());
|
||||
|
||||
let provisionResult;
|
||||
await act(async () => {
|
||||
provisionResult = await hookResult.current.provision({
|
||||
nodeName: "test",
|
||||
hostConfig: {},
|
||||
imageConfig: { image: "runfusion/fusion", tag: "latest", pullImage: true },
|
||||
autoGenerateApiKey: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/docker/provision",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
expect(provisionResult!.success).toBe(true);
|
||||
expect(hookResult.current.provisionResult).toEqual(result);
|
||||
expect(hookResult.current.isProvisioning).toBe(false);
|
||||
});
|
||||
|
||||
it("sets provisionError on non-ok response", async () => {
|
||||
fetchMock.mockReturnValue(
|
||||
jsonError(500, { success: false, error: "internal error" }),
|
||||
);
|
||||
|
||||
const { result: hookResult } = renderHook(() => useDockerProvisioning());
|
||||
|
||||
await act(async () => {
|
||||
await hookResult.current.provision({
|
||||
nodeName: "test",
|
||||
hostConfig: {},
|
||||
imageConfig: { image: "runfusion/fusion", tag: "latest", pullImage: true },
|
||||
autoGenerateApiKey: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(hookResult.current.provisionError).toBe("internal error");
|
||||
});
|
||||
|
||||
it("sets provisionError on fetch rejection", async () => {
|
||||
fetchMock.mockRejectedValue(new Error("network error"));
|
||||
|
||||
const { result: hookResult } = renderHook(() => useDockerProvisioning());
|
||||
|
||||
await act(async () => {
|
||||
await hookResult.current.provision({
|
||||
nodeName: "test",
|
||||
hostConfig: {},
|
||||
imageConfig: { image: "runfusion/fusion", tag: "latest", pullImage: true },
|
||||
autoGenerateApiKey: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(hookResult.current.provisionError).toBe("network error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deprovision", () => {
|
||||
it("posts to /api/docker/deprovision with correct body", async () => {
|
||||
fetchMock.mockReturnValue(jsonOk({ success: true }));
|
||||
|
||||
const { result: hookResult } = renderHook(() => useDockerProvisioning());
|
||||
|
||||
await act(async () => {
|
||||
await hookResult.current.deprovision("abc123", { host: "tcp://1.2.3.4:2376" }, true);
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/docker/deprovision",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
containerId: "abc123",
|
||||
hostConfig: { host: "tcp://1.2.3.4:2376" },
|
||||
removeVolumes: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(hookResult.current.isDeprovisioning).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startContainer", () => {
|
||||
it("posts to the correct URL", async () => {
|
||||
fetchMock.mockReturnValue(jsonOk({ success: true }));
|
||||
|
||||
const { result: hookResult } = renderHook(() => useDockerProvisioning());
|
||||
|
||||
await act(async () => {
|
||||
await hookResult.current.startContainer("abc123", {});
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/docker/containers/abc123/start",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopContainer", () => {
|
||||
it("posts to the correct URL", async () => {
|
||||
fetchMock.mockReturnValue(jsonOk({ success: true }));
|
||||
|
||||
const { result: hookResult } = renderHook(() => useDockerProvisioning());
|
||||
|
||||
await act(async () => {
|
||||
await hookResult.current.stopContainer("abc123", {});
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/docker/containers/abc123/stop",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restartContainer", () => {
|
||||
it("posts to the correct URL", async () => {
|
||||
fetchMock.mockReturnValue(jsonOk({ success: true }));
|
||||
|
||||
const { result: hookResult } = renderHook(() => useDockerProvisioning());
|
||||
|
||||
await act(async () => {
|
||||
await hookResult.current.restartContainer("abc123", {});
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/docker/containers/abc123/restart",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultImage", () => {
|
||||
it("fetches from /api/docker/default-image", async () => {
|
||||
fetchMock.mockReturnValue(jsonOk({ image: "runfusion/fusion", tag: "latest" }));
|
||||
|
||||
const { result: hookResult } = renderHook(() => useDockerProvisioning());
|
||||
|
||||
let result;
|
||||
await act(async () => {
|
||||
result = await hookResult.current.getDefaultImage();
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/docker/default-image");
|
||||
expect(result).toEqual({ image: "runfusion/fusion", tag: "latest" });
|
||||
});
|
||||
});
|
||||
});
|
||||
236
packages/dashboard/app/hooks/useDockerProvisioning.ts
Normal file
236
packages/dashboard/app/hooks/useDockerProvisioning.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import type {
|
||||
DockerContainerInspectResult,
|
||||
DockerHostConfig,
|
||||
DockerProvisionInput,
|
||||
DockerProvisionResult,
|
||||
} from "@fusion/core";
|
||||
|
||||
/** Result of a Docker lifecycle operation (start/stop/restart) */
|
||||
export interface DockerLifecycleResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Default Fusion Docker image config returned by the API */
|
||||
export interface DockerDefaultImageConfig {
|
||||
image: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
/** Return type for the useDockerProvisioning hook */
|
||||
export interface UseDockerProvisioningResult {
|
||||
/** Whether a provision operation is in progress */
|
||||
isProvisioning: boolean;
|
||||
/** Result of the last provision operation */
|
||||
provisionResult: DockerProvisionResult | null;
|
||||
/** Error from the last provision operation */
|
||||
provisionError: string | null;
|
||||
/** Whether a deprovision operation is in progress */
|
||||
isDeprovisioning: boolean;
|
||||
/** Error from the last deprovision operation */
|
||||
deprovisionError: string | null;
|
||||
/** Provision a new Docker node */
|
||||
provision: (input: DockerProvisionInput) => Promise<DockerProvisionResult>;
|
||||
/** Deprovision (stop and remove) a Docker node container */
|
||||
deprovision: (
|
||||
containerId: string,
|
||||
hostConfig: DockerHostConfig,
|
||||
removeVolumes?: boolean,
|
||||
) => Promise<DockerLifecycleResult>;
|
||||
/** Start a stopped container */
|
||||
startContainer: (
|
||||
containerId: string,
|
||||
hostConfig: DockerHostConfig,
|
||||
) => Promise<DockerLifecycleResult>;
|
||||
/** Stop a running container */
|
||||
stopContainer: (
|
||||
containerId: string,
|
||||
hostConfig: DockerHostConfig,
|
||||
) => Promise<DockerLifecycleResult>;
|
||||
/** Restart a container */
|
||||
restartContainer: (
|
||||
containerId: string,
|
||||
hostConfig: DockerHostConfig,
|
||||
) => Promise<DockerLifecycleResult>;
|
||||
/** Get the runtime status of a container */
|
||||
getContainerStatus: (
|
||||
containerId: string,
|
||||
hostConfig: DockerHostConfig,
|
||||
) => Promise<DockerContainerInspectResult | null>;
|
||||
/** Get the default Fusion Docker image config */
|
||||
getDefaultImage: () => Promise<DockerDefaultImageConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing Docker node provisioning, deprovisioning, and lifecycle.
|
||||
* Does not auto-load on mount — callers decide when to trigger operations.
|
||||
*/
|
||||
export function useDockerProvisioning(): UseDockerProvisioningResult {
|
||||
const [isProvisioning, setIsProvisioning] = useState(false);
|
||||
const [provisionResult, setProvisionResult] = useState<DockerProvisionResult | null>(null);
|
||||
const [provisionError, setProvisionError] = useState<string | null>(null);
|
||||
const [isDeprovisioning, setIsDeprovisioning] = useState(false);
|
||||
const [deprovisionError, setDeprovisionError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const provision = useCallback(async (input: DockerProvisionInput): Promise<DockerProvisionResult> => {
|
||||
setIsProvisioning(true);
|
||||
setProvisionError(null);
|
||||
setProvisionResult(null);
|
||||
|
||||
// Cancel any in-flight request
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/docker/provision", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const result = (await response.json()) as DockerProvisionResult;
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMsg = result.error ?? `Provisioning failed with status ${response.status}`;
|
||||
setProvisionError(errorMsg);
|
||||
setProvisionResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
setProvisionResult(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setProvisionError(message);
|
||||
const result: DockerProvisionResult = { success: false, error: message };
|
||||
setProvisionResult(result);
|
||||
return result;
|
||||
} finally {
|
||||
setIsProvisioning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deprovision = useCallback(
|
||||
async (
|
||||
containerId: string,
|
||||
hostConfig: DockerHostConfig,
|
||||
removeVolumes?: boolean,
|
||||
): Promise<DockerLifecycleResult> => {
|
||||
setIsDeprovisioning(true);
|
||||
setDeprovisionError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/docker/deprovision", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ containerId, hostConfig, removeVolumes }),
|
||||
});
|
||||
|
||||
const result = (await response.json()) as DockerLifecycleResult;
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMsg = (result as { error?: string }).error ?? `Deprovision failed with status ${response.status}`;
|
||||
setDeprovisionError(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setDeprovisionError(message);
|
||||
return { success: false, error: message };
|
||||
} finally {
|
||||
setIsDeprovisioning(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const startContainer = useCallback(
|
||||
async (containerId: string, hostConfig: DockerHostConfig): Promise<DockerLifecycleResult> => {
|
||||
try {
|
||||
const response = await fetch(`/api/docker/containers/${containerId}/start`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ hostConfig }),
|
||||
});
|
||||
return (await response.json()) as DockerLifecycleResult;
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const stopContainer = useCallback(
|
||||
async (containerId: string, hostConfig: DockerHostConfig): Promise<DockerLifecycleResult> => {
|
||||
try {
|
||||
const response = await fetch(`/api/docker/containers/${containerId}/stop`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ hostConfig }),
|
||||
});
|
||||
return (await response.json()) as DockerLifecycleResult;
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const restartContainer = useCallback(
|
||||
async (containerId: string, hostConfig: DockerHostConfig): Promise<DockerLifecycleResult> => {
|
||||
try {
|
||||
const response = await fetch(`/api/docker/containers/${containerId}/restart`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ hostConfig }),
|
||||
});
|
||||
return (await response.json()) as DockerLifecycleResult;
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getContainerStatus = useCallback(
|
||||
async (containerId: string, hostConfig: DockerHostConfig): Promise<DockerContainerInspectResult | null> => {
|
||||
try {
|
||||
const query = hostConfig
|
||||
? `?hostConfig=${encodeURIComponent(JSON.stringify(hostConfig))}`
|
||||
: "";
|
||||
const response = await fetch(`/api/docker/containers/${containerId}/status${query}`);
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as DockerContainerInspectResult | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getDefaultImage = useCallback(async (): Promise<DockerDefaultImageConfig> => {
|
||||
const response = await fetch("/api/docker/default-image");
|
||||
return (await response.json()) as DockerDefaultImageConfig;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isProvisioning,
|
||||
provisionResult,
|
||||
provisionError,
|
||||
isDeprovisioning,
|
||||
deprovisionError,
|
||||
provision,
|
||||
deprovision,
|
||||
startContainer,
|
||||
stopContainer,
|
||||
restartContainer,
|
||||
getContainerStatus,
|
||||
getDefaultImage,
|
||||
};
|
||||
}
|
||||
@@ -99,6 +99,7 @@ import { registerAgentsProjectsNodesRoutes } from "./routes/register-agents-proj
|
||||
import { registerProjectRoutes } from "./routes/register-project-routes.js";
|
||||
import { registerNodeRoutes } from "./routes/register-node-routes.js";
|
||||
import { registerDockerNodeRoutes } from "./routes/register-docker-node-routes.js";
|
||||
import { registerDockerProvisioningRoutes } from "./routes/register-docker-provisioning-routes.js";
|
||||
import { registerSettingsSyncRoutes } from "./routes/register-settings-sync-routes.js";
|
||||
import { registerMeshRoutes } from "./routes/register-mesh-routes.js";
|
||||
import { registerDiscoveryRoutes } from "./routes/register-discovery-routes.js";
|
||||
@@ -3855,6 +3856,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
registerNodeRoutes(routeContext);
|
||||
registerDockerNodeRoutes(routeContext);
|
||||
registerDockerProvisioningRoutes(routeContext);
|
||||
|
||||
// ── Remote Node Settings Sync Routes ──────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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