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
342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
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 };
|
|
}
|
|
}
|
|
}
|