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