feat(FN-3111): add mesh configuration API routes and generator service

The merge lands the mesh configuration system (FN-3111) with type definitions, a `MeshConfigGenerator` service using a new `DockerClientService.recreateContainer()` method, and backend API routes for managing Docker-based mesh nodes. It also adds a "New Chat" button to the ChatView header on desktop

Fusion-Task-Id: FN-3111
This commit is contained in:
Fusion
2026-05-03 15:13:08 -07:00
committed by gsxdsm
parent 1e841df3c7
commit 7773e4e4ad
10 changed files with 1420 additions and 2 deletions

View File

@@ -0,0 +1,502 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ManagedDockerNode, MeshConnectionConfig } from "../types.js";
// ── Mocks ──────────────────────────────────────────────────────────────────
const mockCentral = {
getManagedDockerNode: vi.fn(),
updateManagedDockerNode: vi.fn(),
registerNode: vi.fn(),
linkManagedDockerNodeToNode: vi.fn(),
checkNodeHealth: vi.fn(),
};
const mockDockerClient = {
recreateContainer: vi.fn(),
};
vi.mock("../central-core.js", () => ({
CentralCore: vi.fn(),
}));
vi.mock("../docker-client.js", () => ({
DockerClientService: vi.fn(),
}));
// ── Helpers ────────────────────────────────────────────────────────────────
function createManagedNode(overrides: Partial<ManagedDockerNode> = {}): ManagedDockerNode {
return {
id: "dn_test123",
nodeId: null,
name: "test-node",
imageName: "runfusion/fusion",
imageTag: "latest",
containerId: "container_abc",
status: "creating",
hostConfig: { host: undefined },
envVars: {},
volumeMounts: [],
resourceSizing: { memoryMB: 4096, cpus: 2 },
extraClis: [],
persistentStorage: true,
reachableUrl: null,
apiKey: null,
errorMessage: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
// Import after mocks are set up
const { MeshConfigGenerator } = await import("../mesh-config-generator.js");
function createGenerator() {
return new MeshConfigGenerator({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
central: mockCentral as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dockerClient: mockDockerClient as any,
});
}
// ── Tests ──────────────────────────────────────────────────────────────────
describe("MeshConfigGenerator", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── generateConfig ─────────────────────────────────────────────────────
describe("generateConfig", () => {
it("uses managed node's reachableUrl when set", () => {
const generator = createGenerator();
const node = createManagedNode({ reachableUrl: "http://custom:5000" });
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
});
expect(config.reachableUrl).toBe("http://custom:5000");
});
it("auto-generates 32-char hex API key when none provided", () => {
const generator = createGenerator();
const node = createManagedNode();
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
});
expect(config.nodeApiKey).toMatch(/^[0-9a-f]{32}$/);
});
it("preserves user-provided API key", () => {
const generator = createGenerator();
const node = createManagedNode();
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
nodeApiKey: "user-provided-key",
});
expect(config.nodeApiKey).toBe("user-provided-key");
});
it("assembles all mesh env vars with correct values", () => {
const generator = createGenerator();
const node = createManagedNode({ name: "my-node" });
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
nodeApiKey: "test-api-key",
containerPort: 5000,
});
expect(config.envVars).toMatchObject({
FUSION_DAEMON_TOKEN: "test-api-key",
PORT: "5000",
FUSION_NODE_NAME: "my-node",
});
});
it("merges with existing user env vars, mesh config overrides on conflict", () => {
const generator = createGenerator();
const node = createManagedNode({
envVars: { PORT: "3000", CUSTOM_VAR: "custom-value" },
});
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
nodeApiKey: "test-key",
});
// User env var preserved
expect(config.envVars.CUSTOM_VAR).toBe("custom-value");
// Mesh config overrides user PORT
expect(config.envVars.PORT).toBe("4041");
});
it("defaults container port to 4041", () => {
const generator = createGenerator();
const node = createManagedNode();
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
});
expect(config.containerPort).toBe(4041);
expect(config.envVars.PORT).toBe("4041");
});
it("uses explicit containerPort override", () => {
const generator = createGenerator();
const node = createManagedNode();
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
containerPort: 5050,
});
expect(config.containerPort).toBe(5050);
});
it("passes orchestrator URL and API key through to config", () => {
const generator = createGenerator();
const node = createManagedNode();
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key-123",
});
expect(config.orchestratorUrl).toBe("http://orchestrator:4040");
expect(config.orchestratorApiKey).toBe("orch-key-123");
});
it("resolves localhost URL for local Docker daemon", () => {
const generator = createGenerator();
const node = createManagedNode({ hostConfig: { host: undefined } });
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
});
expect(config.reachableUrl).toBe("http://localhost:4041");
});
it("resolves remote host URL from hostConfig", () => {
const generator = createGenerator();
const node = createManagedNode({
hostConfig: { host: "tcp://192.168.1.50:2376" },
});
const config = generator.generateConfig({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
containerPort: 5000,
});
expect(config.reachableUrl).toBe("http://192.168.1.50:5000");
});
});
// ── applyConfig ────────────────────────────────────────────────────────
describe("applyConfig", () => {
const config: MeshConnectionConfig = {
nodeApiKey: "test-key",
reachableUrl: "http://localhost:4041",
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
containerPort: 4041,
envVars: {
FUSION_DAEMON_TOKEN: "test-key",
PORT: "4041",
FUSION_NODE_NAME: "test-node",
},
};
it("sets status to recreating, recreates container, updates to running", async () => {
const generator = createGenerator();
const node = createManagedNode();
mockCentral.getManagedDockerNode.mockResolvedValue(node);
mockDockerClient.recreateContainer.mockResolvedValue("new-container-id");
mockCentral.updateManagedDockerNode.mockResolvedValue({
...node,
status: "running",
containerId: "new-container-id",
});
await generator.applyConfig("dn_test123", config, { host: undefined });
// Status set to "recreating" first
expect(mockCentral.updateManagedDockerNode).toHaveBeenCalledWith(
"dn_test123",
expect.objectContaining({ status: "recreating" }),
);
// Container recreated with correct params
expect(mockDockerClient.recreateContainer).toHaveBeenCalledWith(
"container_abc",
expect.objectContaining({
envVars: config.envVars,
imageName: "runfusion/fusion:latest",
volumeMounts: [],
}),
);
// Final update with running status
expect(mockCentral.updateManagedDockerNode).toHaveBeenCalledWith(
"dn_test123",
expect.objectContaining({
status: "running",
containerId: "new-container-id",
apiKey: "test-key",
reachableUrl: "http://localhost:4041",
envVars: config.envVars,
}),
);
});
it("throws descriptive error when node has no containerId", async () => {
const generator = createGenerator();
const node = createManagedNode({ containerId: null });
mockCentral.getManagedDockerNode.mockResolvedValue(node);
await expect(
generator.applyConfig("dn_test123", config, { host: undefined }),
).rejects.toThrow("has no container ID");
});
it("sets status to error and re-throws when recreation fails", async () => {
const generator = createGenerator();
const node = createManagedNode();
mockCentral.getManagedDockerNode.mockResolvedValue(node);
mockDockerClient.recreateContainer.mockRejectedValue(new Error("Docker error"));
await expect(
generator.applyConfig("dn_test123", config, { host: undefined }),
).rejects.toThrow("Docker error");
// Status should be set to error
expect(mockCentral.updateManagedDockerNode).toHaveBeenCalledWith(
"dn_test123",
expect.objectContaining({
status: "error",
errorMessage: "Docker error",
}),
);
});
});
// ── registerInMesh ────────────────────────────────────────────────────
describe("registerInMesh", () => {
it("registers node, links it, and returns healthy result", async () => {
const generator = createGenerator();
const node = createManagedNode();
const registeredNode = { id: "node_new", name: "test-node", type: "remote" as const };
mockCentral.getManagedDockerNode.mockResolvedValue(node);
mockCentral.registerNode.mockResolvedValue(registeredNode);
mockCentral.linkManagedDockerNodeToNode.mockResolvedValue(node);
mockCentral.checkNodeHealth.mockResolvedValue("online");
const config: MeshConnectionConfig = {
nodeApiKey: "test-key",
reachableUrl: "http://localhost:4041",
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
containerPort: 4041,
envVars: {},
};
const result = await generator.registerInMesh("dn_test123", config);
expect(mockCentral.registerNode).toHaveBeenCalledWith({
name: "test-node",
type: "remote",
url: "http://localhost:4041",
apiKey: "test-key",
maxConcurrent: 2,
});
expect(mockCentral.linkManagedDockerNodeToNode).toHaveBeenCalledWith(
"dn_test123",
"node_new",
);
expect(result.isHealthy).toBe(true);
expect(result.node).toBe(registeredNode);
expect(result.config).toBe(config);
});
it("returns unhealthy when health check times out", async () => {
const generator = createGenerator();
const node = createManagedNode();
const registeredNode = { id: "node_new", name: "test-node", type: "remote" as const };
mockCentral.getManagedDockerNode.mockResolvedValue(node);
mockCentral.registerNode.mockResolvedValue(registeredNode);
mockCentral.linkManagedDockerNodeToNode.mockResolvedValue(node);
// Always return "offline" — simulates timeout
mockCentral.checkNodeHealth.mockResolvedValue("offline");
const config: MeshConnectionConfig = {
nodeApiKey: "test-key",
reachableUrl: "http://localhost:4041",
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
containerPort: 4041,
envVars: {},
};
// Use fake timers to speed up the timeout test
vi.useFakeTimers();
const resultPromise = generator.registerInMesh("dn_test123", config);
// Fast-forward through the polling
await vi.advanceTimersByTimeAsync(35_000);
const result = await resultPromise;
expect(result.isHealthy).toBe(false);
expect(result.error).toContain("did not reach online status");
vi.useRealTimers();
});
it("re-throws when registration fails", async () => {
const generator = createGenerator();
const node = createManagedNode();
mockCentral.getManagedDockerNode.mockResolvedValue(node);
mockCentral.registerNode.mockRejectedValue(new Error("Name collision"));
const config: MeshConnectionConfig = {
nodeApiKey: "test-key",
reachableUrl: "http://localhost:4041",
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
containerPort: 4041,
envVars: {},
};
await expect(
generator.registerInMesh("dn_test123", config),
).rejects.toThrow("Name collision");
});
});
// ── provisionAndRegister ──────────────────────────────────────────────
describe("provisionAndRegister", () => {
it("runs full end-to-end flow: generate → apply → register", async () => {
const generator = createGenerator();
const node = createManagedNode();
const registeredNode = { id: "node_new", name: "test-node", type: "remote" as const };
mockCentral.getManagedDockerNode.mockResolvedValue(node);
mockDockerClient.recreateContainer.mockResolvedValue("new-container-id");
mockCentral.updateManagedDockerNode.mockImplementation((_id: string, updates: Record<string, unknown>) =>
Promise.resolve({ ...node, ...updates }),
);
mockCentral.registerNode.mockResolvedValue(registeredNode);
mockCentral.linkManagedDockerNodeToNode.mockResolvedValue(node);
mockCentral.checkNodeHealth.mockResolvedValue("online");
const result = await generator.provisionAndRegister({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
nodeApiKey: "my-key",
containerPort: 4041,
});
expect(result.isHealthy).toBe(true);
expect(result.config.nodeApiKey).toBe("my-key");
expect(result.config.envVars.FUSION_DAEMON_TOKEN).toBe("my-key");
expect(result.node).toBe(registeredNode);
});
it("sets managed node to error when apply fails", async () => {
const generator = createGenerator();
const node = createManagedNode();
mockCentral.getManagedDockerNode.mockResolvedValue(node);
mockCentral.updateManagedDockerNode.mockImplementation((_id: string, updates: Record<string, unknown>) =>
Promise.resolve({ ...node, ...updates }),
);
mockDockerClient.recreateContainer.mockRejectedValue(new Error("Recreate failed"));
await expect(
generator.provisionAndRegister({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
}),
).rejects.toThrow("Recreate failed");
// Error status update should have happened
expect(mockCentral.updateManagedDockerNode).toHaveBeenCalledWith(
"dn_test123",
expect.objectContaining({
status: "error",
errorMessage: "Recreate failed",
}),
);
});
it("sets managed node to error when register fails", async () => {
const generator = createGenerator();
const node = createManagedNode();
mockCentral.getManagedDockerNode.mockResolvedValue(node);
mockCentral.updateManagedDockerNode.mockImplementation((_id: string, updates: Record<string, unknown>) =>
Promise.resolve({ ...node, ...updates }),
);
mockDockerClient.recreateContainer.mockResolvedValue("new-container-id");
mockCentral.registerNode.mockRejectedValue(new Error("Registration failed"));
await expect(
generator.provisionAndRegister({
managedNode: node,
orchestratorUrl: "http://orchestrator:4040",
orchestratorApiKey: "orch-key",
}),
).rejects.toThrow("Registration failed");
expect(mockCentral.updateManagedDockerNode).toHaveBeenCalledWith(
"dn_test123",
expect.objectContaining({
status: "error",
errorMessage: "Registration failed",
}),
);
});
});
});

View File

@@ -7,6 +7,7 @@ import type {
DockerContainerInspectResult,
DockerContextInfo,
DockerHostConfig,
DockerVolumeMount,
} from "./types.js";
const EXEC_OPTIONS = {
@@ -198,6 +199,77 @@ export class DockerClientService {
return this.dockerInstance;
}
/**
* Recreate a container with updated environment variables.
*
* Docker environment variables are baked in at container creation time and
* cannot be changed without recreating the container. This method:
* 1. Inspects the old container to capture its configuration
* 2. Stops and removes the old container
* 3. Creates a new container with the same image and volumes but updated env vars
* 4. Starts the new container
* 5. Returns the new container ID
*
* Volume mounts are preserved across recreation. If persistentStorage is false,
* volumes are not included in the new container.
*/
async recreateContainer(
containerId: string,
options: {
envVars: Record<string, string>;
imageName: string;
volumeMounts: DockerVolumeMount[];
hostConfig?: DockerHostConfig;
},
): Promise<string> {
const docker = await this.getDockerInstance(options.hostConfig);
const container = docker.getContainer(containerId);
// Inspect the old container to capture its config
const inspect = await container.inspect();
const oldName = (inspect.Name ?? "").replace(/^\//, "");
// Build environment variable array from the provided map
const envArray = Object.entries(options.envVars).map(
([key, value]) => `${key}=${value}`,
);
// Build binds for volume mounts
const binds = options.volumeMounts.map(
(mount) => `${mount.hostPath}:${mount.containerPath}:${mount.mode}`,
);
// Stop and remove the old container
try {
await container.stop({ t: 5 });
} catch (error) {
// Container may already be stopped
const message = toErrorMessage(error);
if (!message.includes("is not running") && !message.includes("already stopped")) {
throw error;
}
}
await container.remove({ force: true });
// Create the new container with updated env vars
const newContainer = await docker.createContainer({
name: oldName || undefined,
Image: options.imageName,
Env: envArray,
HostConfig: {
Binds: binds.length > 0 ? binds : undefined,
RestartPolicy: {
Name: "unless-stopped",
},
},
});
// Start the new container
await newContainer.start();
return newContainer.id;
}
dispose(): void {
this.dockerInstance = null;
}

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 { MeshConfigGenerator } from "./mesh-config-generator.js";
export { DockerProvisioningService } from "./docker-provisioning.js";
export type {
ConnectionErrorType,
@@ -426,6 +427,10 @@ export type {
ManagedDockerNode,
ManagedDockerNodeInput,
ManagedDockerNodeUpdate,
MeshConfigGeneratorInput,
FullProvisioningInput,
MeshConnectionConfig,
MeshConfigResult,
NodeDiscoveryEvent,
DiscoveryConfig,
DiscoveredNode,

View File

@@ -0,0 +1,273 @@
import { randomUUID } from "node:crypto";
import type {
DockerHostConfig,
FullProvisioningInput,
ManagedDockerNode,
MeshConfigGeneratorInput,
MeshConfigResult,
MeshConnectionConfig,
NodeStatus,
} from "./types.js";
import type { CentralCore } from "./central-core.js";
import type { DockerClientService } from "./docker-client.js";
/** Default container port — NOT 4040 (reserved for the production dashboard per AGENTS.md). */
const DEFAULT_CONTAINER_PORT = 4041;
/** Maximum time to wait for a new node to report healthy (ms). */
const HEALTH_CHECK_TIMEOUT_MS = 30_000;
/** Polling interval between health check attempts (ms). */
const HEALTH_CHECK_INTERVAL_MS = 3_000;
/** Brief pause after container recreation to allow startup (ms). */
const POST_RECREATE_DELAY_MS = 2_500;
/**
* Service for generating mesh connection configuration for newly provisioned Docker nodes.
*
* Generates an API key, assembles connection environment variables, injects them into
* the running container (via recreation), registers the node in the mesh, and verifies
* connectivity. This is the glue that turns a provisioned container into a reachable mesh peer.
*/
export class MeshConfigGenerator {
constructor(
private readonly deps: {
central: CentralCore;
dockerClient: DockerClientService;
},
) {}
/**
* Assemble the mesh connection configuration from the provided inputs.
* Pure function — no side effects.
*/
generateConfig(input: MeshConfigGeneratorInput): MeshConnectionConfig {
const { managedNode, orchestratorUrl, orchestratorApiKey, nodeApiKey, containerPort } = input;
const resolvedApiKey = nodeApiKey ?? this.generateApiKey();
const resolvedPort = containerPort ?? DEFAULT_CONTAINER_PORT;
const resolvedUrl = this.determineReachableUrl(managedNode, resolvedPort);
// Build mesh env vars (these override any user-provided values with the same keys)
const meshEnvVars: Record<string, string> = {
FUSION_DAEMON_TOKEN: resolvedApiKey,
PORT: String(resolvedPort),
FUSION_NODE_NAME: managedNode.name,
};
// Merge with existing user env vars — mesh config keys take precedence
const envVars: Record<string, string> = {
...(managedNode.envVars ?? {}),
...meshEnvVars,
};
return {
nodeApiKey: resolvedApiKey,
reachableUrl: resolvedUrl,
orchestratorUrl,
orchestratorApiKey,
containerPort: resolvedPort,
envVars,
};
}
/**
* Apply the mesh configuration to a provisioned Docker node by recreating
* its container with updated environment variables.
*/
async applyConfig(
managedNodeId: string,
config: MeshConnectionConfig,
hostConfig: DockerHostConfig,
): Promise<void> {
const managedNode = await this.deps.central.getManagedDockerNode(managedNodeId);
if (!managedNode) {
throw new Error(`Managed Docker node not found: ${managedNodeId}`);
}
if (!managedNode.containerId) {
throw new Error(
`Cannot apply config: node "${managedNode.name}" (${managedNodeId}) has no container ID. ` +
"The node must be provisioned first.",
);
}
// Set status to "recreating" before touching the container
await this.deps.central.updateManagedDockerNode(managedNodeId, {
status: "recreating",
});
try {
const newContainerId = await this.deps.dockerClient.recreateContainer(
managedNode.containerId,
{
envVars: config.envVars,
imageName: `${managedNode.imageName}:${managedNode.imageTag}`,
volumeMounts: managedNode.volumeMounts ?? [],
hostConfig,
},
);
// Brief pause to allow container to start
await new Promise((resolve) => setTimeout(resolve, POST_RECREATE_DELAY_MS));
// Update the managed node record with new state
await this.deps.central.updateManagedDockerNode(managedNodeId, {
apiKey: config.nodeApiKey,
reachableUrl: config.reachableUrl,
envVars: config.envVars,
status: "running",
containerId: newContainerId,
});
} catch (error) {
// Update status to error and re-throw
const message = error instanceof Error ? error.message : String(error);
await this.deps.central.updateManagedDockerNode(managedNodeId, {
status: "error",
errorMessage: message,
});
throw error;
}
}
/**
* Register the provisioned node in the mesh and verify connectivity.
*/
async registerInMesh(
managedNodeId: string,
config: MeshConnectionConfig,
): Promise<MeshConfigResult> {
const managedNode = await this.deps.central.getManagedDockerNode(managedNodeId);
if (!managedNode) {
throw new Error(`Managed Docker node not found: ${managedNodeId}`);
}
// Register a new NodeConfig in the mesh
const node = await this.deps.central.registerNode({
name: managedNode.name,
type: "remote",
url: config.reachableUrl,
apiKey: config.nodeApiKey,
maxConcurrent: 2,
});
// Link the managed Docker node to the new NodeConfig
await this.deps.central.linkManagedDockerNodeToNode(managedNodeId, node.id);
// Wait for the node to come online (polling health check)
const { healthy, latencyMs } = await this.waitForNodeHealth(
node.id,
HEALTH_CHECK_TIMEOUT_MS,
HEALTH_CHECK_INTERVAL_MS,
);
const result: MeshConfigResult = {
config,
node,
isHealthy: healthy,
healthCheckLatencyMs: latencyMs,
};
if (!healthy) {
result.error = `Node did not reach online status within ${HEALTH_CHECK_TIMEOUT_MS / 1000}s`;
}
return result;
}
/**
* End-to-end convenience method: generate config → apply → register.
* On failure at any step, sets managed node status to "error" and re-throws.
*/
async provisionAndRegister(input: FullProvisioningInput): Promise<MeshConfigResult> {
const managedNodeId = input.managedNode.id;
try {
const config = this.generateConfig(input);
await this.applyConfig(managedNodeId, config, input.managedNode.hostConfig);
return await this.registerInMesh(managedNodeId, config);
} catch (error) {
// Ensure the managed node is in error state
const message = error instanceof Error ? error.message : String(error);
try {
await this.deps.central.updateManagedDockerNode(managedNodeId, {
status: "error",
errorMessage: message,
});
} catch {
// Best-effort status update — the original error is more important
}
throw error;
}
}
// ─── Private helpers ──────────────────────────────────────────────────
/** Generate a 32-character hex API key from randomUUID(). */
private generateApiKey(): string {
return randomUUID().replace(/-/g, "");
}
/** Resolve the reachable URL from the managed node configuration. */
private determineReachableUrl(
managedNode: ManagedDockerNode,
containerPort: number,
): string {
// Use explicit user-provided URL if set
if (managedNode.reachableUrl) {
return managedNode.reachableUrl;
}
// Determine from host config
const host = managedNode.hostConfig?.host;
if (!host || isLocalDaemonHost(host)) {
return `http://localhost:${containerPort}`;
}
// Extract hostname from the Docker host URL (e.g., "tcp://192.168.1.50:2376" → "192.168.1.50")
try {
const url = new URL(host);
return `http://${url.hostname}:${containerPort}`;
} catch {
// Fallback: use the raw host value
return `http://${host}:${containerPort}`;
}
}
/**
* Poll the node health check until it reports online or times out.
* Returns whether the node is healthy and the latency of the successful check.
*/
private async waitForNodeHealth(
nodeId: string,
timeoutMs: number,
intervalMs: number,
): Promise<{ healthy: boolean; latencyMs?: number }> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const checkStart = Date.now();
try {
const status: NodeStatus = await this.deps.central.checkNodeHealth(nodeId);
const latencyMs = Date.now() - checkStart;
if (status === "online") {
return { healthy: true, latencyMs };
}
} catch {
// Health check failed — node not ready yet
}
// Wait before next poll
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return { healthy: false };
}
}
/** Check if the Docker host is a local daemon. */
function isLocalDaemonHost(host?: string): boolean {
return !host || host.trim() === "" || host === "unix:///var/run/docker.sock";
}

View File

@@ -2581,6 +2581,64 @@ export type ManagedDockerNodeUpdate = Partial<
Omit<ManagedDockerNode, "id" | "createdAt">
>;
/** Input to the mesh configuration generation process. */
export interface MeshConfigGeneratorInput {
/** The managed Docker node record (from FN-3107). */
managedNode: ManagedDockerNode;
/** The orchestrating node's URL (e.g., "http://192.168.1.10:4040"). */
orchestratorUrl: string;
/** The orchestrating node's API key for authentication. */
orchestratorApiKey: string;
/** Optional user-provided API key. If omitted, one is auto-generated. */
nodeApiKey?: string;
/** Optional container port override. If omitted, defaults to 4041. */
containerPort?: number;
}
/** Input to the end-to-end provision-and-register flow. */
export interface FullProvisioningInput {
/** The managed Docker node to configure and register. */
managedNode: ManagedDockerNode;
/** The orchestrating node's URL. */
orchestratorUrl: string;
/** The orchestrating node's API key. */
orchestratorApiKey: string;
/** Optional user-provided API key for the new node. */
nodeApiKey?: string;
/** Optional container port override. */
containerPort?: number;
}
/** Configuration bundle needed for a new node to join the mesh. */
export interface MeshConnectionConfig {
/** API key for authenticating to this node. Auto-generated if not provided by user. */
nodeApiKey: string;
/** The URL the orchestrating node uses to reach the new container. */
reachableUrl: string;
/** Orchestrating node's URL, pushed to the container so it knows its mesh parent. */
orchestratorUrl: string;
/** Orchestrating node's API key for inbound settings sync authentication. */
orchestratorApiKey: string;
/** Port the container's Fusion server will listen on. */
containerPort: number;
/** Environment variables assembled from the above for injection into the container. */
envVars: Record<string, string>;
}
/** Result of applying mesh config to a provisioned node. */
export interface MeshConfigResult {
/** The generated/applied connection config. */
config: MeshConnectionConfig;
/** The registered NodeConfig in the mesh. */
node: NodeConfig;
/** Whether the node health check passed after registration. */
isHealthy: boolean;
/** Latency of the health check in ms, if successful. */
healthCheckLatencyMs?: number;
/** Error if health check or registration failed. */
error?: string;
}
/** Information about a discovered Docker context */
export interface DockerContextInfo {
/** Context name (e.g., "default", "my-remote") */