fix(dashboard): use active node registry authority (#2145)
## Summary - reuse the dashboard server's initialized `centralCore` for all node-management routes - preserve the legacy fallback only when no shared central authority is provided - never close the server-owned central authority from an individual request - cover node list and registration with regression tests that fail if a route constructs its own `CentralCore` ## Problem With the dashboard running on the PostgreSQL backend, `/api/nodes` and `POST /api/nodes` bypassed the server's initialized PostgreSQL-backed `centralCore` and constructed a separate legacy `CentralCore()`. Reads could hit the wrong registry, while writes failed with a null SQLite handle (`Cannot read properties of null (reading 'prepare')`). The same pattern affected node detail, health, path-mapping, version, plugin-sync, and Docker-config endpoints. ## Verification - targeted regression: 2 tests passed - dashboard typecheck passed - ESLint passed for changed TypeScript files - strict changeset validation passed - dashboard production build passed - `pnpm test:gate` passed against an isolated PostgreSQL 16 cluster: - engine core: 294 tests - PostgreSQL gate: 122 tests - CLI CI-shape: 63 tests ## Changeset Patch release for `@runfusion/fusion`. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved multi-node management by keeping node operations connected to the active PostgreSQL registry. * Updated node listing, registration, configuration, health, version, plugin, path, and Docker configuration routes for more consistent behavior. * Preserved Docker configuration validation and safe response handling. * **Tests** * Added coverage for retrieving and registering nodes through the active registry. * Verified successful node creation responses and handling of optional configuration fields. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
7
.changeset/postgres-node-registry-routes.md
Normal file
7
.changeset/postgres-node-registry-routes.md
Normal file
@@ -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.
|
||||||
@@ -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<typeof import("@fusion/core")>("@fusion/core");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
CentralCore: legacyCentralConstructor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildApp(centralCore: Record<string, unknown> | 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<string, unknown>) => ({
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { ApiError, badRequest, notFound } from "../api-error.js";
|
||||||
import type { ApiRouteRegistrar } from "./types.js";
|
import type { ApiRouteRegistrar } from "./types.js";
|
||||||
|
|
||||||
@@ -44,7 +46,41 @@ function isDiscoveredRemoteProject(value: unknown): value is DiscoveredRemotePro
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
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 <T>(fn: (central: CentralCore) => T | Promise<T>): Promise<T> => {
|
||||||
|
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) ───────────────────────────
|
// ── Node Management Routes (Multi-Node Support) ───────────────────────────
|
||||||
|
|
||||||
@@ -55,12 +91,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/nodes", async (_req, res) => {
|
router.get("/nodes", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const nodes = await withCentralCore((central) => central.listNodes());
|
||||||
const central = new CentralCore();
|
|
||||||
await central.init();
|
|
||||||
|
|
||||||
const nodes = await central.listNodes();
|
|
||||||
await central.close();
|
|
||||||
|
|
||||||
nodes.sort((a, b) => a.name.localeCompare(b.name));
|
nodes.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
res.json(nodes);
|
res.json(nodes);
|
||||||
@@ -106,11 +137,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
throw badRequest("capabilities must be an array of strings");
|
throw badRequest("capabilities must be an array of strings");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const node = await withCentralCore((central) => central.registerNode({
|
||||||
const central = new CentralCore();
|
|
||||||
await central.init();
|
|
||||||
|
|
||||||
const node = await central.registerNode({
|
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
type: nodeType,
|
type: nodeType,
|
||||||
url: typeof url === "string" ? url.trim() : undefined,
|
url: typeof url === "string" ? url.trim() : undefined,
|
||||||
@@ -118,9 +145,8 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
maxConcurrent,
|
maxConcurrent,
|
||||||
capabilities,
|
capabilities,
|
||||||
dockerConfig,
|
dockerConfig,
|
||||||
});
|
}));
|
||||||
|
|
||||||
await central.close();
|
|
||||||
res.status(201).json(node);
|
res.status(201).json(node);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
@@ -204,12 +230,10 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
* List all project path mappings for a node.
|
* List all project path mappings for a node.
|
||||||
*/
|
*/
|
||||||
router.get("/nodes/:id/path-mappings", async (req, res) => {
|
router.get("/nodes/:id/path-mappings", async (req, res) => {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
|
||||||
const central = new CentralCore();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await central.init();
|
const mappings = await withCentralCore((central) =>
|
||||||
const mappings = await central.listProjectNodePathMappingsForNode(req.params.id);
|
central.listProjectNodePathMappingsForNode(req.params.id),
|
||||||
|
);
|
||||||
res.json(mappings);
|
res.json(mappings);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
@@ -218,8 +242,6 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
const status = message.includes("Node not found") ? 404 : 500;
|
const status = message.includes("Node not found") ? 404 : 500;
|
||||||
throw new ApiError(status, message);
|
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) => {
|
router.get("/nodes/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const node = await withCentralCore((central) => central.getNode(req.params.id));
|
||||||
const central = new CentralCore();
|
|
||||||
await central.init();
|
|
||||||
|
|
||||||
const node = await central.getNode(req.params.id);
|
|
||||||
await central.close();
|
|
||||||
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
throw notFound("Node not found");
|
throw notFound("Node not found");
|
||||||
@@ -257,21 +274,16 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
try {
|
try {
|
||||||
const { name, url, apiKey, maxConcurrent, status, capabilities, dockerConfig } = req.body;
|
const { name, url, apiKey, maxConcurrent, status, capabilities, dockerConfig } = req.body;
|
||||||
|
|
||||||
const updates: Partial<Omit<import("@fusion/core").NodeConfig, "id" | "createdAt">> = {};
|
const updates: Partial<Omit<NodeConfig, "id" | "createdAt">> = {};
|
||||||
if (name !== undefined) updates.name = name;
|
if (name !== undefined) updates.name = name;
|
||||||
if (url !== undefined) updates.url = url;
|
if (url !== undefined) updates.url = url;
|
||||||
if (apiKey !== undefined) updates.apiKey = apiKey;
|
if (apiKey !== undefined) updates.apiKey = apiKey;
|
||||||
if (maxConcurrent !== undefined) updates.maxConcurrent = maxConcurrent;
|
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 (capabilities !== undefined) updates.capabilities = capabilities;
|
||||||
if (dockerConfig !== undefined) updates.dockerConfig = dockerConfig;
|
if (dockerConfig !== undefined) updates.dockerConfig = dockerConfig;
|
||||||
|
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const node = await withCentralCore((central) => central.updateNode(req.params.id, updates));
|
||||||
const central = new CentralCore();
|
|
||||||
await central.init();
|
|
||||||
|
|
||||||
const node = await central.updateNode(req.params.id, updates);
|
|
||||||
await central.close();
|
|
||||||
|
|
||||||
res.json(node);
|
res.json(node);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -293,11 +305,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/nodes/:id/docker-config", async (req, res) => {
|
router.get("/nodes/:id/docker-config", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore, sanitizeDockerNodeConfigForResponse } = await import("@fusion/core");
|
const node = await withCentralCore((central) => central.getNode(req.params.id));
|
||||||
const central = new CentralCore();
|
|
||||||
await central.init();
|
|
||||||
const node = await central.getNode(req.params.id);
|
|
||||||
await central.close();
|
|
||||||
if (!node) throw notFound("Node not found");
|
if (!node) throw notFound("Node not found");
|
||||||
res.json(node.dockerConfig ? sanitizeDockerNodeConfigForResponse(node.dockerConfig) : null);
|
res.json(node.dockerConfig ? sanitizeDockerNodeConfigForResponse(node.dockerConfig) : null);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -312,20 +320,17 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
*/
|
*/
|
||||||
router.put("/nodes/:id/docker-config", async (req, res) => {
|
router.put("/nodes/:id/docker-config", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse } = await import("@fusion/core");
|
|
||||||
const validation = validateDockerNodeConfig(req.body);
|
const validation = validateDockerNodeConfig(req.body);
|
||||||
if (!validation.valid || !validation.config) {
|
if (!validation.valid || !validation.config) {
|
||||||
throw new ApiError(400, "Invalid Docker config", { errors: validation.errors ?? [] });
|
throw new ApiError(400, "Invalid Docker config", { errors: validation.errors ?? [] });
|
||||||
}
|
}
|
||||||
const central = new CentralCore();
|
const updated = await withCentralCore(async (central) => {
|
||||||
await central.init();
|
const node = await central.getNode(req.params.id);
|
||||||
const node = await central.getNode(req.params.id);
|
if (!node) {
|
||||||
if (!node) {
|
throw notFound("Node not found");
|
||||||
await central.close();
|
}
|
||||||
throw notFound("Node not found");
|
return central.updateNode(req.params.id, { dockerConfig: validation.config });
|
||||||
}
|
});
|
||||||
const updated = await central.updateNode(req.params.id, { dockerConfig: validation.config });
|
|
||||||
await central.close();
|
|
||||||
res.json(sanitizeDockerNodeConfigForResponse(updated.dockerConfig!));
|
res.json(sanitizeDockerNodeConfigForResponse(updated.dockerConfig!));
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) throw err;
|
if (err instanceof ApiError) throw err;
|
||||||
@@ -335,47 +340,42 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
|
|
||||||
router.patch("/nodes/:id/docker-config", async (req, res) => {
|
router.patch("/nodes/:id/docker-config", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse } = await import("@fusion/core");
|
const updated = await withCentralCore(async (central) => {
|
||||||
const central = new CentralCore();
|
const node = await central.getNode(req.params.id);
|
||||||
await central.init();
|
if (!node) {
|
||||||
const node = await central.getNode(req.params.id);
|
throw notFound("Node not found");
|
||||||
if (!node) {
|
}
|
||||||
await central.close();
|
const existing = node.dockerConfig;
|
||||||
throw notFound("Node not found");
|
if (!existing) {
|
||||||
}
|
throw badRequest("Node has no existing Docker config; use PUT first");
|
||||||
const existing = node.dockerConfig;
|
}
|
||||||
if (!existing) {
|
|
||||||
await central.close();
|
|
||||||
throw badRequest("Node has no existing Docker config; use PUT first");
|
|
||||||
}
|
|
||||||
|
|
||||||
const patch = req.body as Record<string, unknown>;
|
const patch = req.body as Record<string, unknown>;
|
||||||
const mergedEnvironment: Record<string, string> = { ...existing.environment };
|
const mergedEnvironment: Record<string, string> = { ...existing.environment };
|
||||||
if (patch.environment && typeof patch.environment === "object" && !Array.isArray(patch.environment)) {
|
if (patch.environment && typeof patch.environment === "object" && !Array.isArray(patch.environment)) {
|
||||||
for (const [key, value] of Object.entries(patch.environment as Record<string, unknown>)) {
|
for (const [key, value] of Object.entries(patch.environment as Record<string, unknown>)) {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
delete mergedEnvironment[key];
|
delete mergedEnvironment[key];
|
||||||
} else if (typeof value === "string") {
|
} else if (typeof value === "string") {
|
||||||
mergedEnvironment[key] = value;
|
mergedEnvironment[key] = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const merged = {
|
const merged = {
|
||||||
...existing,
|
...existing,
|
||||||
...patch,
|
...patch,
|
||||||
environment: mergedEnvironment,
|
environment: mergedEnvironment,
|
||||||
volumeMounts: patch.volumeMounts !== undefined ? patch.volumeMounts : existing.volumeMounts,
|
volumeMounts: patch.volumeMounts !== undefined ? patch.volumeMounts : existing.volumeMounts,
|
||||||
};
|
};
|
||||||
|
|
||||||
const validation = validateDockerNodeConfig(merged);
|
const validation = validateDockerNodeConfig(merged);
|
||||||
if (!validation.valid || !validation.config) {
|
if (!validation.valid || !validation.config) {
|
||||||
await central.close();
|
throw new ApiError(400, "Invalid Docker config", { errors: validation.errors ?? [] });
|
||||||
throw new ApiError(400, "Invalid Docker config", { errors: validation.errors ?? [] });
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await central.updateNode(req.params.id, { dockerConfig: validation.config });
|
return central.updateNode(req.params.id, { dockerConfig: validation.config });
|
||||||
await central.close();
|
});
|
||||||
res.json(sanitizeDockerNodeConfigForResponse(updated.dockerConfig!));
|
res.json(sanitizeDockerNodeConfigForResponse(updated.dockerConfig!));
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) throw err;
|
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) => {
|
router.get("/nodes/:id/docker-config/diff", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const node = await withCentralCore((central) => central.getNode(req.params.id));
|
||||||
const central = new CentralCore();
|
|
||||||
await central.init();
|
|
||||||
const node = await central.getNode(req.params.id);
|
|
||||||
await central.close();
|
|
||||||
if (!node) throw notFound("Node not found");
|
if (!node) throw notFound("Node not found");
|
||||||
if (!node.dockerConfig) {
|
if (!node.dockerConfig) {
|
||||||
res.json({ config: null });
|
res.json({ config: null });
|
||||||
@@ -412,18 +408,14 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
*/
|
*/
|
||||||
router.delete("/nodes/:id", async (req, res) => {
|
router.delete("/nodes/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
await withCentralCore(async (central) => {
|
||||||
const central = new CentralCore();
|
const existing = await central.getNode(req.params.id);
|
||||||
await central.init();
|
if (!existing) {
|
||||||
|
throw notFound("Node not found");
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await central.getNode(req.params.id);
|
await central.unregisterNode(req.params.id);
|
||||||
if (!existing) {
|
});
|
||||||
await central.close();
|
|
||||||
throw notFound("Node not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
await central.unregisterNode(req.params.id);
|
|
||||||
await central.close();
|
|
||||||
|
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -440,12 +432,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
*/
|
*/
|
||||||
router.post("/nodes/:id/health-check", async (req, res) => {
|
router.post("/nodes/:id/health-check", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const healthStatus = await withCentralCore((central) => central.checkNodeHealth(req.params.id));
|
||||||
const central = new CentralCore();
|
|
||||||
await central.init();
|
|
||||||
|
|
||||||
const healthStatus = await central.checkNodeHealth(req.params.id);
|
|
||||||
await central.close();
|
|
||||||
|
|
||||||
res.json({ status: healthStatus });
|
res.json({ status: healthStatus });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -463,12 +450,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/nodes/:id/metrics", async (req, res) => {
|
router.get("/nodes/:id/metrics", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const node = await withCentralCore((central) => central.getNode(req.params.id));
|
||||||
const central = new CentralCore();
|
|
||||||
await central.init();
|
|
||||||
|
|
||||||
const node = await central.getNode(req.params.id);
|
|
||||||
await central.close();
|
|
||||||
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
throw notFound("Node not found");
|
throw notFound("Node not found");
|
||||||
@@ -491,12 +473,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/nodes/:id/version", async (req, res) => {
|
router.get("/nodes/:id/version", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const node = await withCentralCore((central) => central.getNode(req.params.id));
|
||||||
const central = new CentralCore();
|
|
||||||
await central.init();
|
|
||||||
|
|
||||||
const node = await central.getNode(req.params.id);
|
|
||||||
await central.close();
|
|
||||||
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
throw notFound("Node not found");
|
throw notFound("Node not found");
|
||||||
@@ -519,34 +496,28 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
*/
|
*/
|
||||||
router.post("/nodes/:id/sync-plugins", async (req, res) => {
|
router.post("/nodes/:id/sync-plugins", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const result = await withCentralCore(async (central) => {
|
||||||
const central = new CentralCore();
|
// Validate target node exists
|
||||||
await central.init();
|
const targetNode = await central.getNode(req.params.id);
|
||||||
|
if (!targetNode) {
|
||||||
|
throw notFound("Node not found");
|
||||||
|
}
|
||||||
|
|
||||||
// Validate target node exists
|
// Reject local target nodes - sync-plugins is for remote nodes only
|
||||||
const targetNode = await central.getNode(req.params.id);
|
if (targetNode.type === "local") {
|
||||||
if (!targetNode) {
|
throw badRequest("Cannot sync plugins to a local node - sync-plugins is for remote nodes only");
|
||||||
await central.close();
|
}
|
||||||
throw notFound("Node not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject local target nodes - sync-plugins is for remote nodes only
|
// Find the local node
|
||||||
if (targetNode.type === "local") {
|
const nodes = await central.listNodes();
|
||||||
await central.close();
|
const localNode = nodes.find((n) => n.type === "local");
|
||||||
throw badRequest("Cannot sync plugins to a local node - sync-plugins is for remote nodes only");
|
if (!localNode) {
|
||||||
}
|
throw badRequest("Local node not registered - cannot perform sync");
|
||||||
|
}
|
||||||
|
|
||||||
// Find the local node
|
// Perform plugin sync comparison
|
||||||
const nodes = await central.listNodes();
|
return central.syncPlugins(localNode.id, targetNode.id);
|
||||||
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();
|
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -564,45 +535,38 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/nodes/:id/compatibility", async (req, res) => {
|
router.get("/nodes/:id/compatibility", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const result = await withCentralCore(async (central) => {
|
||||||
const central = new CentralCore();
|
// Validate target node exists
|
||||||
await central.init();
|
const targetNode = await central.getNode(req.params.id);
|
||||||
|
if (!targetNode) {
|
||||||
|
throw notFound("Node not found");
|
||||||
|
}
|
||||||
|
|
||||||
// Validate target node exists
|
// Find the local node
|
||||||
const targetNode = await central.getNode(req.params.id);
|
const nodes = await central.listNodes();
|
||||||
if (!targetNode) {
|
const localNode = nodes.find((n) => n.type === "local");
|
||||||
await central.close();
|
if (!localNode) {
|
||||||
throw notFound("Node not found");
|
throw badRequest("Local node not registered - cannot check compatibility");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the local node
|
// Get version info for both nodes
|
||||||
const nodes = await central.listNodes();
|
const localVersionInfo = await central.getNodeVersionInfo(localNode.id);
|
||||||
const localNode = nodes.find((n) => n.type === "local");
|
const targetVersionInfo = await central.getNodeVersionInfo(targetNode.id);
|
||||||
if (!localNode) {
|
|
||||||
await central.close();
|
|
||||||
throw badRequest("Local node not registered - cannot check compatibility");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get version info for both nodes
|
// Validate both have version info
|
||||||
const localVersionInfo = await central.getNodeVersionInfo(localNode.id);
|
if (!localVersionInfo) {
|
||||||
const targetVersionInfo = await central.getNodeVersionInfo(targetNode.id);
|
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
|
// Check compatibility using version strings
|
||||||
if (!localVersionInfo) {
|
return central.checkVersionCompatibility(
|
||||||
await central.close();
|
localVersionInfo.appVersion,
|
||||||
throw badRequest("Local node has no version info yet");
|
targetVersionInfo.appVersion,
|
||||||
}
|
);
|
||||||
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();
|
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
Reference in New Issue
Block a user