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

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

View File

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