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:
@@ -809,6 +809,46 @@ describe("runServe", () => {
|
||||
});
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("uses process.env.PORT as fallback when no explicit CLI port is given", async () => {
|
||||
const originalPort = process.env.PORT;
|
||||
process.env.PORT = "4041";
|
||||
|
||||
try {
|
||||
await runServe(4040, {});
|
||||
expect(mocks.listenCalls[0]).toMatchObject({
|
||||
port: 4041,
|
||||
host: "127.0.0.1",
|
||||
});
|
||||
await triggerSignal("SIGINT");
|
||||
} finally {
|
||||
if (originalPort !== undefined) {
|
||||
process.env.PORT = originalPort;
|
||||
} else {
|
||||
delete process.env.PORT;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores process.env.PORT when explicit CLI port is not the default", async () => {
|
||||
const originalPort = process.env.PORT;
|
||||
process.env.PORT = "4041";
|
||||
|
||||
try {
|
||||
await runServe(3000, {});
|
||||
expect(mocks.listenCalls[0]).toMatchObject({
|
||||
port: 3000,
|
||||
host: "127.0.0.1",
|
||||
});
|
||||
await triggerSignal("SIGINT");
|
||||
} finally {
|
||||
if (originalPort !== undefined) {
|
||||
process.env.PORT = originalPort;
|
||||
} else {
|
||||
delete process.env.PORT;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("runServe — Plugin wiring", () => {
|
||||
|
||||
@@ -223,7 +223,16 @@ export async function runServe(
|
||||
serveStartTime = Date.now();
|
||||
ensureProcessDiagnostics();
|
||||
|
||||
// Port resolution priority: CLI --port arg > process.env.PORT > default (4040)
|
||||
// The env var fallback is critical for Docker containers where the mesh config
|
||||
// injects PORT as an environment variable to control the container's listen port.
|
||||
let selectedPort = port;
|
||||
if (!opts.interactive && (port === 4040 || port === 0) && process.env.PORT) {
|
||||
const envPort = Number(process.env.PORT);
|
||||
if (Number.isFinite(envPort) && envPort > 0) {
|
||||
selectedPort = envPort;
|
||||
}
|
||||
}
|
||||
if (opts.interactive) {
|
||||
try {
|
||||
selectedPort = await promptForPort(port);
|
||||
|
||||
502
packages/core/src/__tests__/mesh-config-generator.test.ts
Normal file
502
packages/core/src/__tests__/mesh-config-generator.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
273
packages/core/src/mesh-config-generator.ts
Normal file
273
packages/core/src/mesh-config-generator.ts
Normal 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";
|
||||
}
|
||||
@@ -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") */
|
||||
|
||||
@@ -8,11 +8,34 @@ import { request } from "../../test-request.js";
|
||||
const service = {
|
||||
listContexts: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
recreateContainer: vi.fn(),
|
||||
};
|
||||
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getManagedDockerNode: vi.fn(),
|
||||
updateManagedDockerNode: vi.fn(),
|
||||
listNodes: vi.fn(),
|
||||
registerNode: vi.fn(),
|
||||
linkManagedDockerNodeToNode: vi.fn(),
|
||||
checkNodeHealth: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
getNode: vi.fn(),
|
||||
};
|
||||
|
||||
const mockGeneratorInstance = {
|
||||
provisionAndRegister: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return { ...actual, DockerClientService: vi.fn().mockImplementation(() => service) };
|
||||
return {
|
||||
...actual,
|
||||
DockerClientService: vi.fn().mockImplementation(() => service),
|
||||
CentralCore: vi.fn().mockImplementation(() => mockCentralInstance),
|
||||
MeshConfigGenerator: vi.fn().mockImplementation(() => mockGeneratorInstance),
|
||||
};
|
||||
});
|
||||
|
||||
function createStore() {
|
||||
@@ -77,3 +100,260 @@ describe("registerDockerNodeRoutes", () => {
|
||||
expect((res.body as any).available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mesh config routes", () => {
|
||||
const managedNode = {
|
||||
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(),
|
||||
};
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
// ── apply-mesh-config ──────────────────────────────────────────────────
|
||||
|
||||
it("POST /api/docker/nodes/:managedId/apply-mesh-config — 201 success", async () => {
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValue(managedNode);
|
||||
mockCentralInstance.listNodes.mockResolvedValue([
|
||||
{ id: "node-local", name: "local", type: "local", apiKey: "local-api-key" },
|
||||
]);
|
||||
mockGeneratorInstance.provisionAndRegister.mockResolvedValue({
|
||||
config: { nodeApiKey: "new-key" },
|
||||
node: { id: "node_new", name: "test-node" },
|
||||
isHealthy: true,
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app(),
|
||||
"POST",
|
||||
"/api/docker/nodes/dn_test123/apply-mesh-config",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect((res.body as any).isHealthy).toBe(true);
|
||||
expect(mockGeneratorInstance.provisionAndRegister).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
managedNode,
|
||||
orchestratorApiKey: "local-api-key",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/docker/nodes/:managedId/apply-mesh-config — 404 not found", async () => {
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValue(undefined);
|
||||
|
||||
const res = await request(
|
||||
app(),
|
||||
"POST",
|
||||
"/api/docker/nodes/dn_test123/apply-mesh-config",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST /api/docker/nodes/:managedId/apply-mesh-config — 400 already running", async () => {
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValue({
|
||||
...managedNode,
|
||||
status: "running",
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app(),
|
||||
"POST",
|
||||
"/api/docker/nodes/dn_test123/apply-mesh-config",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /api/docker/nodes/:managedId/apply-mesh-config — 400 node in error state", async () => {
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValue({
|
||||
...managedNode,
|
||||
status: "error",
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app(),
|
||||
"POST",
|
||||
"/api/docker/nodes/dn_test123/apply-mesh-config",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /api/docker/nodes/:managedId/apply-mesh-config — 400 no orchestrator API key", async () => {
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValue(managedNode);
|
||||
mockCentralInstance.listNodes.mockResolvedValue([
|
||||
{ id: "node-local", name: "local", type: "local", apiKey: undefined },
|
||||
]);
|
||||
|
||||
const res = await request(
|
||||
app(),
|
||||
"POST",
|
||||
"/api/docker/nodes/dn_test123/apply-mesh-config",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /api/docker/nodes/:managedId/apply-mesh-config — 500 container recreation failure", async () => {
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValue(managedNode);
|
||||
mockCentralInstance.listNodes.mockResolvedValue([
|
||||
{ id: "node-local", name: "local", type: "local", apiKey: "local-key" },
|
||||
]);
|
||||
mockGeneratorInstance.provisionAndRegister.mockRejectedValue(new Error("Container recreation failed"));
|
||||
|
||||
const res = await request(
|
||||
app(),
|
||||
"POST",
|
||||
"/api/docker/nodes/dn_test123/apply-mesh-config",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
// ── regenerate-api-key ────────────────────────────────────────────────
|
||||
|
||||
it("POST /api/docker/nodes/:managedId/regenerate-api-key — 200 updates both records", async () => {
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValue({
|
||||
...managedNode,
|
||||
nodeId: "node_linked",
|
||||
});
|
||||
mockCentralInstance.updateManagedDockerNode.mockResolvedValue({ ...managedNode, apiKey: "new-key" });
|
||||
mockCentralInstance.updateNode.mockResolvedValue({ id: "node_linked", apiKey: "new-key" });
|
||||
|
||||
const res = await request(
|
||||
app(),
|
||||
"POST",
|
||||
"/api/docker/nodes/dn_test123/regenerate-api-key",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any).apiKey).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(mockCentralInstance.updateManagedDockerNode).toHaveBeenCalledWith(
|
||||
"dn_test123",
|
||||
expect.objectContaining({ apiKey: expect.any(String) }),
|
||||
);
|
||||
expect(mockCentralInstance.updateNode).toHaveBeenCalledWith(
|
||||
"node_linked",
|
||||
expect.objectContaining({ apiKey: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/docker/nodes/:managedId/regenerate-api-key — 200 without linked NodeConfig", async () => {
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValue({
|
||||
...managedNode,
|
||||
nodeId: null,
|
||||
});
|
||||
mockCentralInstance.updateManagedDockerNode.mockResolvedValue({ ...managedNode, apiKey: "new-key" });
|
||||
|
||||
const res = await request(
|
||||
app(),
|
||||
"POST",
|
||||
"/api/docker/nodes/dn_test123/regenerate-api-key",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any).apiKey).toMatch(/^[0-9a-f]{32}$/);
|
||||
// Should NOT call updateNode since no linked NodeConfig
|
||||
expect(mockCentralInstance.updateNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POST /api/docker/nodes/:managedId/regenerate-api-key — 404 not found", async () => {
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValue(undefined);
|
||||
|
||||
const res = await request(
|
||||
app(),
|
||||
"POST",
|
||||
"/api/docker/nodes/dn_test123/regenerate-api-key",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// ── mesh-status ──────────────────────────────────────────────────────
|
||||
|
||||
it("GET /api/docker/nodes/:managedId/mesh-status — 200 registered: true", async () => {
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValue({
|
||||
...managedNode,
|
||||
nodeId: "node_linked",
|
||||
reachableUrl: "http://localhost:4041",
|
||||
});
|
||||
mockCentralInstance.getNode.mockResolvedValue({ id: "node_linked", status: "online" });
|
||||
mockCentralInstance.checkNodeHealth.mockResolvedValue("online");
|
||||
|
||||
const res = await request(
|
||||
app(),
|
||||
"GET",
|
||||
"/api/docker/nodes/dn_test123/mesh-status",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any).registered).toBe(true);
|
||||
expect((res.body as any).status).toBe("online");
|
||||
expect((res.body as any).reachableUrl).toBe("http://localhost:4041");
|
||||
expect((res.body as any).lastCheckedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it("GET /api/docker/nodes/:managedId/mesh-status — 200 registered: false", async () => {
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValue({
|
||||
...managedNode,
|
||||
nodeId: null,
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app(),
|
||||
"GET",
|
||||
"/api/docker/nodes/dn_test123/mesh-status",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any).registered).toBe(false);
|
||||
expect((res.body as any).status).toBe("offline");
|
||||
});
|
||||
|
||||
it("GET /api/docker/nodes/:managedId/mesh-status — 404 not found", async () => {
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValue(undefined);
|
||||
|
||||
const res = await request(
|
||||
app(),
|
||||
"GET",
|
||||
"/api/docker/nodes/dn_test123/mesh-status",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DockerExtraCli, DockerHostConfig, DockerVolumeMount, ManagedDockerNodeInput } from "@fusion/core";
|
||||
import type { DockerExtraCli, DockerHostConfig, DockerVolumeMount, ManagedDockerNodeInput, FullProvisioningInput } from "@fusion/core";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
@@ -196,4 +197,177 @@ export const registerDockerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
rethrowAsApiError(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mesh Configuration Routes ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/docker/nodes/:managedId/apply-mesh-config
|
||||
* Generate and apply mesh config to a provisioned Docker node.
|
||||
*/
|
||||
router.post("/docker/nodes/:managedId/apply-mesh-config", async (req, res) => {
|
||||
try {
|
||||
const { managedId } = req.params;
|
||||
const body = (req.body ?? {}) as {
|
||||
orchestratorUrl?: string;
|
||||
orchestratorApiKey?: string;
|
||||
containerPort?: number;
|
||||
};
|
||||
|
||||
const { CentralCore, DockerClientService, MeshConfigGenerator } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
try {
|
||||
const managedNode = await central.getManagedDockerNode(managedId);
|
||||
if (!managedNode) {
|
||||
throw notFound("Managed Docker node not found");
|
||||
}
|
||||
|
||||
// Validate status — only "creating" or "stopped" can receive mesh config
|
||||
if (managedNode.status === "running") {
|
||||
throw badRequest("Node is already running with mesh config applied. Use regenerate-api-key to update credentials.");
|
||||
}
|
||||
if (managedNode.status === "error") {
|
||||
throw badRequest("Node is in error state. Resolve the error before applying mesh config.");
|
||||
}
|
||||
|
||||
// Resolve orchestrator URL and API key
|
||||
let orchestratorUrl = body.orchestratorUrl?.trim();
|
||||
let orchestratorApiKey = body.orchestratorApiKey?.trim();
|
||||
|
||||
// Fall back to local node lookup if not explicitly provided
|
||||
if (!orchestratorUrl || !orchestratorApiKey) {
|
||||
const nodes = await central.listNodes();
|
||||
const localNode = nodes.find((n) => n.type === "local");
|
||||
|
||||
if (localNode?.apiKey) {
|
||||
orchestratorApiKey = orchestratorApiKey || localNode.apiKey;
|
||||
}
|
||||
|
||||
// Construct URL from request hostname if not available
|
||||
if (!orchestratorUrl) {
|
||||
const host = req.hostname || req.get("host") || "localhost";
|
||||
// Strip port from host header if present (we'll add the actual port)
|
||||
const hostname = host.split(":")[0];
|
||||
// Use the request's port or default to the server's port
|
||||
const reqPort = req.socket?.localPort;
|
||||
orchestratorUrl = `http://${hostname}${reqPort && reqPort !== 80 ? `:${reqPort}` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!orchestratorUrl || !orchestratorApiKey) {
|
||||
throw badRequest(
|
||||
"Cannot determine orchestrator URL/API key. " +
|
||||
"Either provide orchestratorUrl and orchestratorApiKey in the request body, " +
|
||||
"or configure the local node with an API key.",
|
||||
);
|
||||
}
|
||||
|
||||
const dockerClient = new DockerClientService(managedNode.hostConfig);
|
||||
const generator = new MeshConfigGenerator({ central, dockerClient });
|
||||
|
||||
const input: FullProvisioningInput = {
|
||||
managedNode,
|
||||
orchestratorUrl,
|
||||
orchestratorApiKey,
|
||||
containerPort: body.containerPort,
|
||||
};
|
||||
|
||||
const result = await generator.provisionAndRegister(input);
|
||||
res.status(201).json(result);
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
rethrowAsApiError(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/docker/nodes/:managedId/regenerate-api-key
|
||||
* Generate a new API key for an existing managed Docker node.
|
||||
*/
|
||||
router.post("/docker/nodes/:managedId/regenerate-api-key", async (req, res) => {
|
||||
try {
|
||||
const { managedId } = req.params;
|
||||
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
try {
|
||||
const managedNode = await central.getManagedDockerNode(managedId);
|
||||
if (!managedNode) {
|
||||
throw notFound("Managed Docker node not found");
|
||||
}
|
||||
|
||||
const newKey = randomUUID().replace(/-/g, "");
|
||||
|
||||
// Update the managed Docker node record
|
||||
await central.updateManagedDockerNode(managedId, { apiKey: newKey });
|
||||
|
||||
// If linked to a NodeConfig, update that too
|
||||
if (managedNode.nodeId) {
|
||||
await central.updateNode(managedNode.nodeId, { apiKey: newKey });
|
||||
}
|
||||
|
||||
res.json({ apiKey: newKey });
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
rethrowAsApiError(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/docker/nodes/:managedId/mesh-status
|
||||
* Check mesh connectivity status for a managed Docker node.
|
||||
*/
|
||||
router.get("/docker/nodes/:managedId/mesh-status", async (req, res) => {
|
||||
try {
|
||||
const { managedId } = req.params;
|
||||
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
try {
|
||||
const managedNode = await central.getManagedDockerNode(managedId);
|
||||
if (!managedNode) {
|
||||
throw notFound("Managed Docker node not found");
|
||||
}
|
||||
|
||||
// If not linked to a mesh node yet
|
||||
if (!managedNode.nodeId) {
|
||||
res.json({
|
||||
registered: false,
|
||||
status: "offline",
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check health of the linked node
|
||||
const node = await central.getNode(managedNode.nodeId);
|
||||
await central.checkNodeHealth(managedNode.nodeId);
|
||||
// Re-fetch to get updated status after health check
|
||||
const updatedNode = await central.getNode(managedNode.nodeId);
|
||||
|
||||
res.json({
|
||||
registered: true,
|
||||
status: updatedNode?.status ?? node?.status ?? "offline",
|
||||
reachableUrl: managedNode.reachableUrl,
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
});
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
rethrowAsApiError(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user