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

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

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

View File

@@ -0,0 +1,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);
});
});
});

View File

@@ -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();

View 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 };
}
}
}

View File

@@ -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,

View File

@@ -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) */