diff --git a/.changeset/postgres-node-registry-routes.md b/.changeset/postgres-node-registry-routes.md new file mode 100644 index 0000000000..e22d853aa6 --- /dev/null +++ b/.changeset/postgres-node-registry-routes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep multi-node management connected to the active PostgreSQL registry. +category: fix +dev: Node CRUD, health, path, version, plugin, and Docker-config routes now reuse the server-injected CentralCore. diff --git a/packages/dashboard/src/routes/__tests__/register-node-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-node-routes.test.ts new file mode 100644 index 0000000000..3000addbab --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/register-node-routes.test.ts @@ -0,0 +1,139 @@ +// @vitest-environment node + +import express from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { request } from "../../test-request.js"; +import { registerNodeRoutes } from "../register-node-routes.js"; +import type { ApiRoutesContext } from "../types.js"; + +const { legacyCentralConstructor } = vi.hoisted(() => ({ + legacyCentralConstructor: vi.fn(function LegacyCentralCore() { + throw new Error("node routes must use the injected central authority"); + }), +})); + +vi.mock("@fusion/core", async () => { + const actual = await vi.importActual("@fusion/core"); + return { + ...actual, + CentralCore: legacyCentralConstructor, + }; +}); + +function buildApp(centralCore: Record | undefined) { + const router = express.Router(); + registerNodeRoutes({ + router, + options: centralCore ? { centralCore: centralCore as never } : {}, + rethrowAsApiError(error: unknown): never { + throw error; + }, + } as unknown as ApiRoutesContext); + + const app = express(); + app.use(express.json()); + app.use("/api", router); + app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(500).json({ error: error.message }); + }); + + return app; +} + +function createFixture() { + const centralCore = { + init: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + listNodes: vi.fn(async () => [ + { id: "node_z", name: "Zulu", type: "remote", status: "online" }, + { id: "node_a", name: "Alpha", type: "local", status: "online" }, + ]), + registerNode: vi.fn(async (input: Record) => ({ + id: "node_remote", + status: "offline", + ...input, + })), + }; + + return { app: buildApp(centralCore), centralCore }; +} + +describe("registerNodeRoutes PostgreSQL authority", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lists nodes through the injected central authority", async () => { + const { app, centralCore } = createFixture(); + + const response = await request(app, "GET", "/api/nodes"); + + expect(response.status).toBe(200); + expect(response.body).toEqual([ + { id: "node_a", name: "Alpha", type: "local", status: "online" }, + { id: "node_z", name: "Zulu", type: "remote", status: "online" }, + ]); + expect(centralCore.listNodes).toHaveBeenCalledOnce(); + expect(centralCore.init).not.toHaveBeenCalled(); + expect(centralCore.close).not.toHaveBeenCalled(); + expect(legacyCentralConstructor).not.toHaveBeenCalled(); + }); + + it("registers nodes through the injected central authority", async () => { + const { app, centralCore } = createFixture(); + + const response = await request( + app, + "POST", + "/api/nodes", + JSON.stringify({ + name: "macbook-air", + type: "remote", + url: "https://macbook-air.example.test:4041", + maxConcurrent: 1, + }), + { "Content-Type": "application/json" }, + ); + + expect(response.status).toBe(201); + expect(centralCore.registerNode).toHaveBeenCalledWith({ + name: "macbook-air", + type: "remote", + url: "https://macbook-air.example.test:4041", + apiKey: undefined, + maxConcurrent: 1, + capabilities: undefined, + dockerConfig: undefined, + }); + expect(centralCore.init).not.toHaveBeenCalled(); + expect(centralCore.close).not.toHaveBeenCalled(); + expect(legacyCentralConstructor).not.toHaveBeenCalled(); + }); + + // FNXC:NodeRegistry — a route-owned fallback authority (no injected centralCore) must close on + // EVERY exit path, including when the underlying store operation throws mid-handler. + it("closes a route-owned fallback authority when listNodes throws", async () => { + const fallbackCentral = { + init: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + listNodes: vi.fn(async () => { + throw new Error("registry unavailable"); + }), + }; + // Only the single fallback construction in this test uses the working fake; the default throwing + // constructor is restored automatically afterward. + legacyCentralConstructor.mockImplementationOnce(function ConstructedFallbackCentral() { + return fallbackCentral; + }); + + const app = buildApp(undefined); + + const response = await request(app, "GET", "/api/nodes"); + + expect(response.status).toBe(500); + expect(legacyCentralConstructor).toHaveBeenCalledTimes(1); + expect(fallbackCentral.init).toHaveBeenCalledOnce(); + expect(fallbackCentral.listNodes).toHaveBeenCalledOnce(); + expect(fallbackCentral.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/dashboard/src/routes/register-node-routes.ts b/packages/dashboard/src/routes/register-node-routes.ts index b4bb5689c3..1e38ae13cf 100644 --- a/packages/dashboard/src/routes/register-node-routes.ts +++ b/packages/dashboard/src/routes/register-node-routes.ts @@ -1,3 +1,5 @@ +import { CentralCore, sanitizeDockerNodeConfigForResponse, validateDockerNodeConfig } from "@fusion/core"; +import type { NodeConfig, NodeStatus } from "@fusion/core"; import { ApiError, badRequest, notFound } from "../api-error.js"; import type { ApiRouteRegistrar } from "./types.js"; @@ -44,7 +46,41 @@ function isDiscoveredRemoteProject(value: unknown): value is DiscoveredRemotePro } export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { - const { router, rethrowAsApiError } = ctx; + const { router, options, rethrowAsApiError } = ctx; + + /* + FNXC:NodeRegistry 2026-07-15-00:00: + Every node route needs a CentralCore authority to read/write the node registry. + When the server injects a shared authority (`options.centralCore`), its lifecycle is owned by the + server: the route must NEVER call `init()`/`close()` on it. When no shared authority is injected the + route owns a fallback: construct once, `init()` once, and guarantee `close()` on EVERY exit path + (success and error). A prior version closed the fallback only on success branches, so any handler that + threw (validation, not-found, upstream failure) leaked the fallback connection. `withCentralCore` + centralizes this so no handler can forget the finally-close, and closes best-effort if `init()` itself + throws so a partially-opened fallback never leaks. + */ + const withCentralCore = async (fn: (central: CentralCore) => T | Promise): Promise => { + const shared = options?.centralCore; + if (shared) { + // Shared authority: server-owned lifecycle — do not init/close here. + return await fn(shared); + } + + const central = new CentralCore(); + try { + await central.init(); + } catch (initErr) { + // init failed → close best-effort so a partially-opened fallback does not leak. + await central.close().catch(() => {}); + throw initErr; + } + + try { + return await fn(central); + } finally { + await central.close(); + } + }; // ── Node Management Routes (Multi-Node Support) ─────────────────────────── @@ -55,12 +91,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { */ router.get("/nodes", async (_req, res) => { try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - - const nodes = await central.listNodes(); - await central.close(); + const nodes = await withCentralCore((central) => central.listNodes()); nodes.sort((a, b) => a.name.localeCompare(b.name)); res.json(nodes); @@ -106,11 +137,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { throw badRequest("capabilities must be an array of strings"); } - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - - const node = await central.registerNode({ + const node = await withCentralCore((central) => central.registerNode({ name: name.trim(), type: nodeType, url: typeof url === "string" ? url.trim() : undefined, @@ -118,9 +145,8 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { maxConcurrent, capabilities, dockerConfig, - }); + })); - await central.close(); res.status(201).json(node); } catch (err: unknown) { if (err instanceof ApiError) { @@ -204,12 +230,10 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { * List all project path mappings for a node. */ router.get("/nodes/:id/path-mappings", async (req, res) => { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - try { - await central.init(); - const mappings = await central.listProjectNodePathMappingsForNode(req.params.id); + const mappings = await withCentralCore((central) => + central.listProjectNodePathMappingsForNode(req.params.id), + ); res.json(mappings); } catch (err: unknown) { if (err instanceof ApiError) { @@ -218,8 +242,6 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { const message = err instanceof Error ? err.message : String(err); const status = message.includes("Node not found") ? 404 : 500; throw new ApiError(status, message); - } finally { - await central.close(); } }); @@ -229,12 +251,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { */ router.get("/nodes/:id", async (req, res) => { try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - - const node = await central.getNode(req.params.id); - await central.close(); + const node = await withCentralCore((central) => central.getNode(req.params.id)); if (!node) { throw notFound("Node not found"); @@ -257,21 +274,16 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { try { const { name, url, apiKey, maxConcurrent, status, capabilities, dockerConfig } = req.body; - const updates: Partial> = {}; + const updates: Partial> = {}; if (name !== undefined) updates.name = name; if (url !== undefined) updates.url = url; if (apiKey !== undefined) updates.apiKey = apiKey; if (maxConcurrent !== undefined) updates.maxConcurrent = maxConcurrent; - if (status !== undefined) updates.status = status as import("@fusion/core").NodeStatus; + if (status !== undefined) updates.status = status as NodeStatus; if (capabilities !== undefined) updates.capabilities = capabilities; if (dockerConfig !== undefined) updates.dockerConfig = dockerConfig; - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - - const node = await central.updateNode(req.params.id, updates); - await central.close(); + const node = await withCentralCore((central) => central.updateNode(req.params.id, updates)); res.json(node); } catch (err: unknown) { @@ -293,11 +305,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { */ router.get("/nodes/:id/docker-config", async (req, res) => { try { - const { CentralCore, sanitizeDockerNodeConfigForResponse } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - const node = await central.getNode(req.params.id); - await central.close(); + const node = await withCentralCore((central) => central.getNode(req.params.id)); if (!node) throw notFound("Node not found"); res.json(node.dockerConfig ? sanitizeDockerNodeConfigForResponse(node.dockerConfig) : null); } catch (err: unknown) { @@ -312,20 +320,17 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { */ router.put("/nodes/:id/docker-config", async (req, res) => { try { - const { CentralCore, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse } = await import("@fusion/core"); const validation = validateDockerNodeConfig(req.body); if (!validation.valid || !validation.config) { throw new ApiError(400, "Invalid Docker config", { errors: validation.errors ?? [] }); } - const central = new CentralCore(); - await central.init(); - const node = await central.getNode(req.params.id); - if (!node) { - await central.close(); - throw notFound("Node not found"); - } - const updated = await central.updateNode(req.params.id, { dockerConfig: validation.config }); - await central.close(); + const updated = await withCentralCore(async (central) => { + const node = await central.getNode(req.params.id); + if (!node) { + throw notFound("Node not found"); + } + return central.updateNode(req.params.id, { dockerConfig: validation.config }); + }); res.json(sanitizeDockerNodeConfigForResponse(updated.dockerConfig!)); } catch (err: unknown) { if (err instanceof ApiError) throw err; @@ -335,47 +340,42 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { router.patch("/nodes/:id/docker-config", async (req, res) => { try { - const { CentralCore, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - const node = await central.getNode(req.params.id); - if (!node) { - await central.close(); - throw notFound("Node not found"); - } - const existing = node.dockerConfig; - if (!existing) { - await central.close(); - throw badRequest("Node has no existing Docker config; use PUT first"); - } + const updated = await withCentralCore(async (central) => { + const node = await central.getNode(req.params.id); + if (!node) { + throw notFound("Node not found"); + } + const existing = node.dockerConfig; + if (!existing) { + throw badRequest("Node has no existing Docker config; use PUT first"); + } - const patch = req.body as Record; - const mergedEnvironment: Record = { ...existing.environment }; - if (patch.environment && typeof patch.environment === "object" && !Array.isArray(patch.environment)) { - for (const [key, value] of Object.entries(patch.environment as Record)) { - if (value === null) { - delete mergedEnvironment[key]; - } else if (typeof value === "string") { - mergedEnvironment[key] = value; + const patch = req.body as Record; + const mergedEnvironment: Record = { ...existing.environment }; + if (patch.environment && typeof patch.environment === "object" && !Array.isArray(patch.environment)) { + for (const [key, value] of Object.entries(patch.environment as Record)) { + if (value === null) { + delete mergedEnvironment[key]; + } else if (typeof value === "string") { + mergedEnvironment[key] = value; + } } } - } - const merged = { - ...existing, - ...patch, - environment: mergedEnvironment, - volumeMounts: patch.volumeMounts !== undefined ? patch.volumeMounts : existing.volumeMounts, - }; + const merged = { + ...existing, + ...patch, + environment: mergedEnvironment, + volumeMounts: patch.volumeMounts !== undefined ? patch.volumeMounts : existing.volumeMounts, + }; - const validation = validateDockerNodeConfig(merged); - if (!validation.valid || !validation.config) { - await central.close(); - throw new ApiError(400, "Invalid Docker config", { errors: validation.errors ?? [] }); - } + const validation = validateDockerNodeConfig(merged); + if (!validation.valid || !validation.config) { + throw new ApiError(400, "Invalid Docker config", { errors: validation.errors ?? [] }); + } - const updated = await central.updateNode(req.params.id, { dockerConfig: validation.config }); - await central.close(); + return central.updateNode(req.params.id, { dockerConfig: validation.config }); + }); res.json(sanitizeDockerNodeConfigForResponse(updated.dockerConfig!)); } catch (err: unknown) { if (err instanceof ApiError) throw err; @@ -385,11 +385,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { router.get("/nodes/:id/docker-config/diff", async (req, res) => { try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - const node = await central.getNode(req.params.id); - await central.close(); + const node = await withCentralCore((central) => central.getNode(req.params.id)); if (!node) throw notFound("Node not found"); if (!node.dockerConfig) { res.json({ config: null }); @@ -412,18 +408,14 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { */ router.delete("/nodes/:id", async (req, res) => { try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); + await withCentralCore(async (central) => { + const existing = await central.getNode(req.params.id); + if (!existing) { + throw notFound("Node not found"); + } - const existing = await central.getNode(req.params.id); - if (!existing) { - await central.close(); - throw notFound("Node not found"); - } - - await central.unregisterNode(req.params.id); - await central.close(); + await central.unregisterNode(req.params.id); + }); res.status(204).end(); } catch (err: unknown) { @@ -440,12 +432,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { */ router.post("/nodes/:id/health-check", async (req, res) => { try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - - const healthStatus = await central.checkNodeHealth(req.params.id); - await central.close(); + const healthStatus = await withCentralCore((central) => central.checkNodeHealth(req.params.id)); res.json({ status: healthStatus }); } catch (err: unknown) { @@ -463,12 +450,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { */ router.get("/nodes/:id/metrics", async (req, res) => { try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - - const node = await central.getNode(req.params.id); - await central.close(); + const node = await withCentralCore((central) => central.getNode(req.params.id)); if (!node) { throw notFound("Node not found"); @@ -491,12 +473,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { */ router.get("/nodes/:id/version", async (req, res) => { try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - - const node = await central.getNode(req.params.id); - await central.close(); + const node = await withCentralCore((central) => central.getNode(req.params.id)); if (!node) { throw notFound("Node not found"); @@ -519,34 +496,28 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { */ router.post("/nodes/:id/sync-plugins", async (req, res) => { try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); + const result = await withCentralCore(async (central) => { + // Validate target node exists + const targetNode = await central.getNode(req.params.id); + if (!targetNode) { + throw notFound("Node not found"); + } - // Validate target node exists - const targetNode = await central.getNode(req.params.id); - if (!targetNode) { - await central.close(); - throw notFound("Node not found"); - } + // Reject local target nodes - sync-plugins is for remote nodes only + if (targetNode.type === "local") { + throw badRequest("Cannot sync plugins to a local node - sync-plugins is for remote nodes only"); + } - // Reject local target nodes - sync-plugins is for remote nodes only - if (targetNode.type === "local") { - await central.close(); - throw badRequest("Cannot sync plugins to a local node - sync-plugins is for remote nodes only"); - } + // Find the local node + const nodes = await central.listNodes(); + const localNode = nodes.find((n) => n.type === "local"); + if (!localNode) { + throw badRequest("Local node not registered - cannot perform sync"); + } - // Find the local node - const nodes = await central.listNodes(); - const localNode = nodes.find((n) => n.type === "local"); - if (!localNode) { - await central.close(); - throw badRequest("Local node not registered - cannot perform sync"); - } - - // Perform plugin sync comparison - const result = await central.syncPlugins(localNode.id, targetNode.id); - await central.close(); + // Perform plugin sync comparison + return central.syncPlugins(localNode.id, targetNode.id); + }); res.json(result); } catch (err: unknown) { @@ -564,45 +535,38 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => { */ router.get("/nodes/:id/compatibility", async (req, res) => { try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); + const result = await withCentralCore(async (central) => { + // Validate target node exists + const targetNode = await central.getNode(req.params.id); + if (!targetNode) { + throw notFound("Node not found"); + } - // Validate target node exists - const targetNode = await central.getNode(req.params.id); - if (!targetNode) { - await central.close(); - throw notFound("Node not found"); - } + // Find the local node + const nodes = await central.listNodes(); + const localNode = nodes.find((n) => n.type === "local"); + if (!localNode) { + throw badRequest("Local node not registered - cannot check compatibility"); + } - // Find the local node - const nodes = await central.listNodes(); - const localNode = nodes.find((n) => n.type === "local"); - if (!localNode) { - await central.close(); - throw badRequest("Local node not registered - cannot check compatibility"); - } + // Get version info for both nodes + const localVersionInfo = await central.getNodeVersionInfo(localNode.id); + const targetVersionInfo = await central.getNodeVersionInfo(targetNode.id); - // Get version info for both nodes - const localVersionInfo = await central.getNodeVersionInfo(localNode.id); - const targetVersionInfo = await central.getNodeVersionInfo(targetNode.id); + // Validate both have version info + if (!localVersionInfo) { + throw badRequest("Local node has no version info yet"); + } + if (!targetVersionInfo) { + throw badRequest("Target node has no version info yet"); + } - // Validate both have version info - if (!localVersionInfo) { - await central.close(); - throw badRequest("Local node has no version info yet"); - } - if (!targetVersionInfo) { - await central.close(); - throw badRequest("Target node has no version info yet"); - } - - // Check compatibility using version strings - const result = central.checkVersionCompatibility( - localVersionInfo.appVersion, - targetVersionInfo.appVersion, - ); - await central.close(); + // Check compatibility using version strings + return central.checkVersionCompatibility( + localVersionInfo.appVersion, + targetVersionInfo.appVersion, + ); + }); res.json(result); } catch (err: unknown) {