feat(FN-3114): stabilize docker config modal recreate indicator test
The merge stabilizes a test case in the NodeDetailModal test file related to the docker config modal's recreate indicator, correcting an assertion that was causing intermittent failures. Fusion-Task-Id: FN-3114
This commit is contained in:
@@ -39,7 +39,7 @@ describe("CentralDatabase", () => {
|
||||
|
||||
it("should initialize schema version", () => {
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(6);
|
||||
expect(db.getSchemaVersion()).toBe(7);
|
||||
});
|
||||
|
||||
it("should seed lastModified on init", () => {
|
||||
@@ -118,7 +118,7 @@ describe("CentralDatabase", () => {
|
||||
expect(columnNames).toContain("knownPeers");
|
||||
});
|
||||
|
||||
it("should include versionInfo and pluginVersions columns on nodes table", () => {
|
||||
it("should include versionInfo, pluginVersions, and dockerConfig columns on nodes table", () => {
|
||||
db.init();
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{
|
||||
@@ -127,6 +127,7 @@ describe("CentralDatabase", () => {
|
||||
const columnNames = columns.map((column) => column.name);
|
||||
expect(columnNames).toContain("versionInfo");
|
||||
expect(columnNames).toContain("pluginVersions");
|
||||
expect(columnNames).toContain("dockerConfig");
|
||||
});
|
||||
|
||||
it("should create peerNodes table with expected columns", () => {
|
||||
@@ -213,7 +214,7 @@ describe("CentralDatabase", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(6);
|
||||
expect(db.getSchemaVersion()).toBe(7);
|
||||
|
||||
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
|
||||
const nodeColumnNames = nodeColumns.map((column) => column.name);
|
||||
@@ -278,7 +279,7 @@ describe("CentralDatabase", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(6);
|
||||
expect(db.getSchemaVersion()).toBe(7);
|
||||
|
||||
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
|
||||
const nodeColumnNames = nodeColumns.map((column) => column.name);
|
||||
@@ -295,7 +296,7 @@ describe("CentralDatabase", () => {
|
||||
expect(row?.pluginVersions).toBeNull();
|
||||
});
|
||||
|
||||
it("should migrate from v5 to v6 with managed Docker node schema", () => {
|
||||
it("should migrate from v5 to v7 with managed Docker node schema and node docker config column", () => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
db.exec(`
|
||||
@@ -366,7 +367,10 @@ describe("CentralDatabase", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(6);
|
||||
expect(db.getSchemaVersion()).toBe(7);
|
||||
|
||||
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
|
||||
expect(nodeColumns.map((column) => column.name)).toContain("dockerConfig");
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(managedDockerNodes)").all() as Array<{ name: string }>;
|
||||
const columnNames = columns.map((column) => column.name);
|
||||
@@ -424,6 +428,22 @@ describe("CentralDatabase", () => {
|
||||
expect(fromJson(row?.volumeMounts, [])).toEqual([]);
|
||||
expect(fromJson(row?.resourceSizing, {})).toEqual({});
|
||||
expect(fromJson(row?.extraClis, [])).toEqual([]);
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, name, type, dockerConfig, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
).run(
|
||||
"node_docker_config",
|
||||
"docker-config-node",
|
||||
"remote",
|
||||
JSON.stringify({ image: "runfusion/fusion:latest", volumeMounts: [], environment: {}, configVersion: 1 }),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
const insertedNode = db.prepare("SELECT dockerConfig FROM nodes WHERE id = ?").get("node_docker_config") as {
|
||||
dockerConfig: string | null;
|
||||
} | undefined;
|
||||
expect(insertedNode?.dockerConfig).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
179
packages/core/src/__tests__/docker-node-config.test.ts
Normal file
179
packages/core/src/__tests__/docker-node-config.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
sanitizeDockerNodeConfigForResponse,
|
||||
validateDockerNodeConfig,
|
||||
type DockerNodeConfig,
|
||||
} from "../types.js";
|
||||
import { CentralCore } from "../central-core.js";
|
||||
|
||||
function createValidConfig(): DockerNodeConfig {
|
||||
return {
|
||||
image: "runfusion/fusion:latest",
|
||||
containerName: "fusion-test",
|
||||
volumeMounts: [{ hostPath: "fusion-data", containerPath: "/data", mode: "rw", type: "volume" }],
|
||||
environment: { PLAIN: "value", API_KEY: "secret" },
|
||||
resources: { memoryBytes: 2147483648, cpuCount: 2, pidsLimit: 256 },
|
||||
host: {
|
||||
contextName: "default",
|
||||
dockerHost: "tcp://127.0.0.1:2376",
|
||||
tlsCaCert: "/certs/ca.pem",
|
||||
tlsCert: "/certs/cert.pem",
|
||||
tlsKey: "/certs/key.pem",
|
||||
tlsVerify: true,
|
||||
},
|
||||
extraClis: ["claude-cli"],
|
||||
persistence: { volumeName: "fusion-data", retainOnDelete: true },
|
||||
configVersion: 1,
|
||||
lastUpdated: "2026-05-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("docker node config validation", () => {
|
||||
it("passes validation for valid config", () => {
|
||||
expect(validateDockerNodeConfig(createValidConfig()).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("returns errors for missing required fields", () => {
|
||||
const result = validateDockerNodeConfig({});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
"image must be a non-empty string",
|
||||
"volumeMounts must be an array",
|
||||
"environment must be an object",
|
||||
"configVersion must be a number >= 1",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns errors for invalid field types", () => {
|
||||
const result = validateDockerNodeConfig({
|
||||
image: "ok",
|
||||
volumeMounts: [{ hostPath: 123, containerPath: false }],
|
||||
environment: { OK: "1", BAD: 2 },
|
||||
configVersion: 1,
|
||||
resources: { memoryBytes: "bad" },
|
||||
host: { tlsVerify: "nope" },
|
||||
persistence: { retainOnDelete: "nope" },
|
||||
extraClis: ["ok", 1],
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
"volumeMounts[0].hostPath must be a string",
|
||||
"volumeMounts[0].containerPath must be a string",
|
||||
"environment.BAD must be a string value",
|
||||
"resources.memoryBytes must be a number",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires configVersion >= 1", () => {
|
||||
const base = createValidConfig();
|
||||
expect(validateDockerNodeConfig({ ...base, configVersion: 0 }).valid).toBe(false);
|
||||
expect(validateDockerNodeConfig({ ...base, configVersion: -1 }).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("docker node config sanitization", () => {
|
||||
it("masks sensitive env vars and tls key path without mutating input", () => {
|
||||
const config = createValidConfig();
|
||||
const sanitized = sanitizeDockerNodeConfigForResponse(config);
|
||||
expect(sanitized.environment.API_KEY).toBe("***");
|
||||
expect(sanitized.host?.tlsKey).toBe("***");
|
||||
expect(config.environment.API_KEY).toBe("secret");
|
||||
});
|
||||
|
||||
it("masks sensitive env vars case-insensitively and preserves non-sensitive values", () => {
|
||||
const config = createValidConfig();
|
||||
config.environment = { plain: "value", service_token: "token", DB_PASSWORD: "password" };
|
||||
const sanitized = sanitizeDockerNodeConfigForResponse(config);
|
||||
expect(sanitized.environment.plain).toBe("value");
|
||||
expect(sanitized.environment.service_token).toBe("***");
|
||||
expect(sanitized.environment.DB_PASSWORD).toBe("***");
|
||||
});
|
||||
});
|
||||
|
||||
describe("docker node config persistence", () => {
|
||||
let tempDir: string;
|
||||
let central: CentralCore;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-01T10:00:00.000Z"));
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-docker-node-config-test-"));
|
||||
central = new CentralCore(tempDir);
|
||||
await central.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await central.close();
|
||||
vi.useRealTimers();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const baseConfig = (): DockerNodeConfig => ({
|
||||
image: "runfusion/fusion:latest",
|
||||
volumeMounts: [{ hostPath: "fusion-data", containerPath: "/data" }],
|
||||
environment: { MODE: "docker" },
|
||||
configVersion: 0,
|
||||
});
|
||||
|
||||
it("register/get/update/list roundtrip with versioning semantics", async () => {
|
||||
const created = await central.registerNode({
|
||||
name: "docker-node",
|
||||
type: "remote",
|
||||
url: "http://127.0.0.1:4041",
|
||||
apiKey: "key",
|
||||
dockerConfig: baseConfig(),
|
||||
});
|
||||
expect(created.dockerConfig?.configVersion).toBe(1);
|
||||
|
||||
const sameOnPartial = await central.updateNode(created.id, { name: "docker-node-2" });
|
||||
expect(sameOnPartial.dockerConfig?.configVersion).toBe(1);
|
||||
|
||||
vi.setSystemTime(new Date("2026-05-01T11:00:00.000Z"));
|
||||
const updated = await central.updateNode(created.id, {
|
||||
dockerConfig: { ...baseConfig(), image: "runfusion/fusion:v2", configVersion: 99 },
|
||||
});
|
||||
expect(updated.dockerConfig?.configVersion).toBe(2);
|
||||
expect(updated.dockerConfig?.lastUpdated).toBe("2026-05-01T11:00:00.000Z");
|
||||
|
||||
const cleared = await central.updateNode(created.id, { dockerConfig: null });
|
||||
expect(cleared.dockerConfig).toBeUndefined();
|
||||
|
||||
const reset = await central.updateNode(created.id, { dockerConfig: { ...baseConfig(), configVersion: 50 } });
|
||||
expect(reset.dockerConfig?.configVersion).toBe(1);
|
||||
|
||||
const list = await central.listNodes();
|
||||
expect(list.find((n) => n.id === created.id)?.dockerConfig?.image).toBe("runfusion/fusion:latest");
|
||||
});
|
||||
|
||||
it("registering without docker config keeps field undefined", async () => {
|
||||
const created = await central.registerNode({
|
||||
name: "plain-node",
|
||||
type: "remote",
|
||||
url: "http://127.0.0.1:4042",
|
||||
apiKey: "key",
|
||||
});
|
||||
expect((await central.getNode(created.id))?.dockerConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws on invalid docker config update", async () => {
|
||||
const created = await central.registerNode({
|
||||
name: "docker-invalid",
|
||||
type: "remote",
|
||||
url: "http://127.0.0.1:4043",
|
||||
apiKey: "key",
|
||||
dockerConfig: baseConfig(),
|
||||
});
|
||||
await expect(
|
||||
central.updateNode(created.id, {
|
||||
dockerConfig: { ...baseConfig(), image: "", configVersion: 1 },
|
||||
}),
|
||||
).rejects.toThrow("Invalid Docker config");
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,7 @@ import type {
|
||||
NodeVersionInfoInput,
|
||||
DockerNodeStatus,
|
||||
DockerHostConfig,
|
||||
DockerNodeConfig,
|
||||
ManagedDockerNode,
|
||||
ManagedDockerNodeInput,
|
||||
ManagedDockerNodeUpdate,
|
||||
@@ -66,6 +67,7 @@ import type {
|
||||
ProviderAuthEntry,
|
||||
} from "./types.js";
|
||||
import { getAppVersion, parseSemver } from "./app-version.js";
|
||||
import { validateDockerNodeConfig } from "./types.js";
|
||||
import { CentralDatabase, toJson, toJsonNullable, fromJson } from "./central-db.js";
|
||||
import { resolveGlobalDir } from "./global-settings.js";
|
||||
import { NodeConnection } from "./node-connection.js";
|
||||
@@ -529,6 +531,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
apiKey?: string;
|
||||
capabilities?: AgentCapability[];
|
||||
maxConcurrent?: number;
|
||||
dockerConfig?: DockerNodeConfig;
|
||||
}): Promise<NodeConfig> {
|
||||
this.ensureInitialized();
|
||||
|
||||
@@ -556,6 +559,20 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let dockerConfig = input.dockerConfig;
|
||||
if (dockerConfig !== undefined) {
|
||||
const normalized = {
|
||||
...dockerConfig,
|
||||
configVersion: dockerConfig.configVersion && dockerConfig.configVersion > 0 ? dockerConfig.configVersion : 1,
|
||||
lastUpdated: now,
|
||||
};
|
||||
const validation = validateDockerNodeConfig(normalized);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid Docker config: ${(validation.errors ?? []).join("; ")}`);
|
||||
}
|
||||
dockerConfig = normalized;
|
||||
}
|
||||
|
||||
const node: NodeConfig = {
|
||||
id: `node_${randomUUID().replace(/-/g, "").slice(0, 16)}`,
|
||||
name,
|
||||
@@ -565,13 +582,14 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
status: "offline",
|
||||
capabilities: input.capabilities,
|
||||
maxConcurrent,
|
||||
dockerConfig,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db!.prepare(
|
||||
`INSERT INTO nodes (id, name, type, url, apiKey, status, capabilities, maxConcurrent, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
`INSERT INTO nodes (id, name, type, url, apiKey, status, capabilities, dockerConfig, maxConcurrent, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
node.id,
|
||||
node.name,
|
||||
@@ -580,6 +598,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
node.apiKey ?? null,
|
||||
node.status,
|
||||
toJsonNullable(node.capabilities),
|
||||
toJsonNullable(node.dockerConfig),
|
||||
node.maxConcurrent,
|
||||
node.createdAt,
|
||||
node.updatedAt
|
||||
@@ -696,6 +715,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
knownPeers: string | null;
|
||||
versionInfo: string | null;
|
||||
pluginVersions: string | null;
|
||||
dockerConfig: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -725,6 +745,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
knownPeers: string | null;
|
||||
versionInfo: string | null;
|
||||
pluginVersions: string | null;
|
||||
dockerConfig: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -753,6 +774,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
knownPeers: string | null;
|
||||
versionInfo: string | null;
|
||||
pluginVersions: string | null;
|
||||
dockerConfig: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -766,7 +788,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
*/
|
||||
async updateNode(
|
||||
id: string,
|
||||
updates: Partial<Omit<NodeConfig, "id" | "createdAt">>
|
||||
updates: Partial<Omit<NodeConfig, "id" | "createdAt">> & { dockerConfig?: DockerNodeConfig | null }
|
||||
): Promise<NodeConfig> {
|
||||
this.ensureInitialized();
|
||||
|
||||
@@ -784,6 +806,24 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if ("dockerConfig" in updates) {
|
||||
if (updates.dockerConfig === null) {
|
||||
updated.dockerConfig = undefined;
|
||||
} else if (updates.dockerConfig !== undefined) {
|
||||
const nextVersion = (node.dockerConfig?.configVersion ?? 0) + 1;
|
||||
const normalized = {
|
||||
...updates.dockerConfig,
|
||||
configVersion: nextVersion,
|
||||
lastUpdated: now,
|
||||
};
|
||||
const validation = validateDockerNodeConfig(normalized);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid Docker config: ${(validation.errors ?? []).join("; ")}`);
|
||||
}
|
||||
updated.dockerConfig = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(updated.maxConcurrent) || updated.maxConcurrent < 1) {
|
||||
throw new Error(`Node maxConcurrent must be >= 1: ${updated.maxConcurrent}`);
|
||||
}
|
||||
@@ -807,6 +847,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
knownPeers = ?,
|
||||
versionInfo = ?,
|
||||
pluginVersions = ?,
|
||||
dockerConfig = ?,
|
||||
maxConcurrent = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?`
|
||||
@@ -821,6 +862,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
toJsonNullable(updated.knownPeers),
|
||||
toJsonNullable(updated.versionInfo),
|
||||
toJsonNullable(updated.pluginVersions),
|
||||
toJsonNullable(updated.dockerConfig),
|
||||
updated.maxConcurrent,
|
||||
updated.updatedAt,
|
||||
id
|
||||
@@ -2212,6 +2254,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
knownPeers: string | null;
|
||||
versionInfo: string | null;
|
||||
pluginVersions: string | null;
|
||||
dockerConfig: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -2228,6 +2271,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
knownPeers: fromJson<string[]>(row.knownPeers),
|
||||
versionInfo: fromJson<NodeVersionInfo>(row.versionInfo),
|
||||
pluginVersions: fromJson<Record<string, string>>(row.pluginVersions),
|
||||
dockerConfig: fromJson<DockerNodeConfig>(row.dockerConfig),
|
||||
maxConcurrent: row.maxConcurrent,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
@@ -2314,6 +2358,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
knownPeers: string | null;
|
||||
versionInfo: string | null;
|
||||
pluginVersions: string | null;
|
||||
dockerConfig: string | null;
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -23,7 +23,7 @@ export { toJson, toJsonNullable, fromJson };
|
||||
|
||||
// ── Schema Definition ───────────────────────────────────────────────────
|
||||
|
||||
const CENTRAL_SCHEMA_VERSION = 6;
|
||||
const CENTRAL_SCHEMA_VERSION = 7;
|
||||
|
||||
const CENTRAL_SCHEMA_SQL = `
|
||||
-- Projects table (project registry)
|
||||
@@ -100,6 +100,7 @@ CREATE TABLE IF NOT EXISTS nodes (
|
||||
knownPeers TEXT,
|
||||
versionInfo TEXT,
|
||||
pluginVersions TEXT,
|
||||
dockerConfig TEXT,
|
||||
maxConcurrent INTEGER NOT NULL DEFAULT 2,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
@@ -253,6 +254,8 @@ CREATE INDEX IF NOT EXISTS idxManagedDockerNodesStatus ON managedDockerNodes(sta
|
||||
CREATE INDEX IF NOT EXISTS idxManagedDockerNodesNodeId ON managedDockerNodes(nodeId);
|
||||
`;
|
||||
|
||||
// V7 migration adds dockerConfig persistence to nodes for Docker-managed runtime config updates.
|
||||
|
||||
// ── Central Database Class ────────────────────────────────────────────────
|
||||
|
||||
export class CentralDatabase {
|
||||
@@ -335,6 +338,13 @@ export class CentralDatabase {
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (currentVersion < 7) {
|
||||
if (!this.hasColumn("nodes", "dockerConfig")) {
|
||||
this.db.exec("ALTER TABLE nodes ADD COLUMN dockerConfig TEXT");
|
||||
}
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (migrated) {
|
||||
this.db
|
||||
.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy, buildResearchDocumentKey } from "./types.js";
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export {
|
||||
@@ -413,6 +413,11 @@ export type {
|
||||
NodeVersionInfo,
|
||||
NodeVersionInfoInput,
|
||||
DockerNodeStatus,
|
||||
DockerNodeConfig,
|
||||
DockerNodeVolumeMount,
|
||||
DockerNodeContainerResourceConfig,
|
||||
DockerNodeHostConfig,
|
||||
DockerNodePersistenceConfig,
|
||||
DockerHostConfig,
|
||||
DockerResourceSizing,
|
||||
DockerVolumeMount,
|
||||
|
||||
@@ -2491,6 +2491,8 @@ export interface NodeConfig {
|
||||
versionInfo?: NodeVersionInfo;
|
||||
/** Snapshot of plugin ID → version mapping */
|
||||
pluginVersions?: Record<string, string>;
|
||||
/** Persisted Docker-managed container configuration, when present. */
|
||||
dockerConfig?: DockerNodeConfig;
|
||||
/** Maximum concurrent tasks/runtimes this node can host */
|
||||
maxConcurrent: number;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
@@ -2499,6 +2501,203 @@ export interface NodeConfig {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Persisted configuration for a Docker-managed Fusion node. */
|
||||
export interface DockerNodeConfig {
|
||||
/** Docker image name (e.g., "runfusion/fusion:latest") */
|
||||
image: string;
|
||||
/** Container name (defaults to "fusion-{nodeId}") */
|
||||
containerName?: string;
|
||||
/** Volume mount definitions */
|
||||
volumeMounts: DockerNodeVolumeMount[];
|
||||
/** Environment variable overrides (key-value pairs) */
|
||||
environment: Record<string, string>;
|
||||
/** Resource limits */
|
||||
resources?: DockerNodeContainerResourceConfig;
|
||||
/** Docker host connection settings */
|
||||
host?: DockerNodeHostConfig;
|
||||
/** Optional CLI tools to include in the container */
|
||||
extraClis?: string[];
|
||||
/** Persistent storage configuration */
|
||||
persistence?: DockerNodePersistenceConfig;
|
||||
/** Config version counter — starts at 1, auto-incremented on every update */
|
||||
configVersion: number;
|
||||
/** ISO timestamp of last config change (auto-set on update) */
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
export interface DockerNodeVolumeMount {
|
||||
/** Host path or named volume */
|
||||
hostPath: string;
|
||||
/** Container mount path */
|
||||
containerPath: string;
|
||||
/** "rw" (default) or "ro" */
|
||||
mode?: "rw" | "ro";
|
||||
/** "volume" (default) for named volumes, "bind" for host bind mounts */
|
||||
type?: "volume" | "bind";
|
||||
}
|
||||
|
||||
export interface DockerNodeContainerResourceConfig {
|
||||
/** Memory limit in bytes (e.g., 2147483648 for 2GB) */
|
||||
memoryBytes?: number;
|
||||
/** CPU count limit (e.g., 2.0 for two cores) */
|
||||
cpuCount?: number;
|
||||
/** PIDs limit */
|
||||
pidsLimit?: number;
|
||||
}
|
||||
|
||||
export interface DockerNodeHostConfig {
|
||||
/** Docker context name (for named Docker context selection) */
|
||||
contextName?: string;
|
||||
/** Explicit Docker host URL (e.g., "tcp://192.168.1.100:2376") */
|
||||
dockerHost?: string;
|
||||
/** Path to TLS CA cert */
|
||||
tlsCaCert?: string;
|
||||
/** Path to TLS client cert */
|
||||
tlsCert?: string;
|
||||
/** Path to TLS client key */
|
||||
tlsKey?: string;
|
||||
/** Whether to verify TLS (default: true) */
|
||||
tlsVerify?: boolean;
|
||||
}
|
||||
|
||||
export interface DockerNodePersistenceConfig {
|
||||
/** Named Docker volume for Fusion data */
|
||||
volumeName?: string;
|
||||
/** Whether to retain the volume when the node is deleted (default: false) */
|
||||
retainOnDelete?: boolean;
|
||||
}
|
||||
|
||||
export function validateDockerNodeConfig(config: unknown): {
|
||||
valid: boolean;
|
||||
config?: DockerNodeConfig;
|
||||
errors?: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||
return { valid: false, errors: ["config must be an object"] };
|
||||
}
|
||||
|
||||
const candidate = config as Record<string, unknown>;
|
||||
|
||||
if (typeof candidate.image !== "string" || !candidate.image.trim()) {
|
||||
errors.push("image must be a non-empty string");
|
||||
}
|
||||
|
||||
if (!Array.isArray(candidate.volumeMounts)) {
|
||||
errors.push("volumeMounts must be an array");
|
||||
} else {
|
||||
candidate.volumeMounts.forEach((mount, index) => {
|
||||
if (!mount || typeof mount !== "object" || Array.isArray(mount)) {
|
||||
errors.push(`volumeMounts[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
const mountCandidate = mount as Record<string, unknown>;
|
||||
if (typeof mountCandidate.hostPath !== "string") {
|
||||
errors.push(`volumeMounts[${index}].hostPath must be a string`);
|
||||
}
|
||||
if (typeof mountCandidate.containerPath !== "string") {
|
||||
errors.push(`volumeMounts[${index}].containerPath must be a string`);
|
||||
}
|
||||
if (mountCandidate.mode !== undefined && mountCandidate.mode !== "rw" && mountCandidate.mode !== "ro") {
|
||||
errors.push(`volumeMounts[${index}].mode must be "rw" or "ro"`);
|
||||
}
|
||||
if (mountCandidate.type !== undefined && mountCandidate.type !== "volume" && mountCandidate.type !== "bind") {
|
||||
errors.push(`volumeMounts[${index}].type must be "volume" or "bind"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!candidate.environment || typeof candidate.environment !== "object" || Array.isArray(candidate.environment)) {
|
||||
errors.push("environment must be an object");
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(candidate.environment)) {
|
||||
if (typeof key !== "string" || typeof value !== "string") {
|
||||
errors.push(`environment.${key} must be a string value`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof candidate.configVersion !== "number" || !Number.isFinite(candidate.configVersion) || candidate.configVersion < 1) {
|
||||
errors.push("configVersion must be a number >= 1");
|
||||
}
|
||||
|
||||
const validateOptionalObject = (
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
validators: Array<[string, (value: unknown) => boolean, string]>,
|
||||
) => {
|
||||
if (value === undefined) return;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
errors.push(`${fieldName} must be an object`);
|
||||
return;
|
||||
}
|
||||
const typed = value as Record<string, unknown>;
|
||||
for (const [prop, test, message] of validators) {
|
||||
if (typed[prop] !== undefined && !test(typed[prop])) {
|
||||
errors.push(`${fieldName}.${prop} ${message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
validateOptionalObject("resources", candidate.resources, [
|
||||
["memoryBytes", (value) => typeof value === "number" && Number.isFinite(value), "must be a number"],
|
||||
["cpuCount", (value) => typeof value === "number" && Number.isFinite(value), "must be a number"],
|
||||
["pidsLimit", (value) => typeof value === "number" && Number.isFinite(value), "must be a number"],
|
||||
]);
|
||||
|
||||
validateOptionalObject("host", candidate.host, [
|
||||
["contextName", (value) => typeof value === "string", "must be a string"],
|
||||
["dockerHost", (value) => typeof value === "string", "must be a string"],
|
||||
["tlsCaCert", (value) => typeof value === "string", "must be a string"],
|
||||
["tlsCert", (value) => typeof value === "string", "must be a string"],
|
||||
["tlsKey", (value) => typeof value === "string", "must be a string"],
|
||||
["tlsVerify", (value) => typeof value === "boolean", "must be a boolean"],
|
||||
]);
|
||||
|
||||
validateOptionalObject("persistence", candidate.persistence, [
|
||||
["volumeName", (value) => typeof value === "string", "must be a string"],
|
||||
["retainOnDelete", (value) => typeof value === "boolean", "must be a boolean"],
|
||||
]);
|
||||
|
||||
if (candidate.extraClis !== undefined) {
|
||||
if (!Array.isArray(candidate.extraClis) || candidate.extraClis.some((item) => typeof item !== "string")) {
|
||||
errors.push("extraClis must be an array of strings");
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate.containerName !== undefined && typeof candidate.containerName !== "string") {
|
||||
errors.push("containerName must be a string");
|
||||
}
|
||||
|
||||
if (candidate.lastUpdated !== undefined && typeof candidate.lastUpdated !== "string") {
|
||||
errors.push("lastUpdated must be a string");
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
return { valid: true, config: candidate as unknown as DockerNodeConfig };
|
||||
}
|
||||
|
||||
export function sanitizeDockerNodeConfigForResponse(config: DockerNodeConfig): DockerNodeConfig {
|
||||
const clone = structuredClone(config);
|
||||
const sensitivePattern = /API_KEY|SECRET|TOKEN|PASSWORD/i;
|
||||
|
||||
for (const [key, value] of Object.entries(clone.environment)) {
|
||||
if (sensitivePattern.test(key) && typeof value === "string") {
|
||||
clone.environment[key] = "***";
|
||||
}
|
||||
}
|
||||
|
||||
if (clone.host?.tlsKey) {
|
||||
clone.host.tlsKey = "***";
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
/** Version information tracked per node for plugin synchronization */
|
||||
export interface NodeVersionInfo {
|
||||
/** Core Fusion application version (semver string, e.g., "0.1.0") */
|
||||
|
||||
@@ -70,6 +70,7 @@ import type {
|
||||
TaskPriority,
|
||||
TaskSourceIssue,
|
||||
ManagedDockerNodeInput,
|
||||
DockerNodeConfig,
|
||||
DockerHostConfig,
|
||||
DockerResourceSizing,
|
||||
DockerVolumeMount,
|
||||
@@ -5306,6 +5307,8 @@ export interface ProjectCreateInput {
|
||||
cloneUrl?: string;
|
||||
}
|
||||
|
||||
export type DockerNodeConfigInfo = DockerNodeConfig;
|
||||
|
||||
/** Node information returned by node endpoints */
|
||||
export interface NodeInfo {
|
||||
id: NodeConfig["id"];
|
||||
@@ -5318,6 +5321,7 @@ export interface NodeInfo {
|
||||
maxConcurrent: NodeConfig["maxConcurrent"];
|
||||
createdAt: NodeConfig["createdAt"];
|
||||
updatedAt: NodeConfig["updatedAt"];
|
||||
dockerConfig?: DockerNodeConfigInfo;
|
||||
}
|
||||
|
||||
/** Managed Docker node information returned by docker node endpoints */
|
||||
@@ -5396,10 +5400,11 @@ export interface NodeCreateInput {
|
||||
url?: string;
|
||||
apiKey?: string;
|
||||
maxConcurrent?: number;
|
||||
dockerConfig?: DockerNodeConfigInfo;
|
||||
}
|
||||
|
||||
/** Input for updating an existing node */
|
||||
export type NodeUpdateInput = Partial<Pick<NodeCreateInput, "name" | "type" | "url" | "apiKey" | "maxConcurrent">> & {
|
||||
export type NodeUpdateInput = Partial<Pick<NodeCreateInput, "name" | "type" | "url" | "apiKey" | "maxConcurrent" | "dockerConfig">> & {
|
||||
status?: NodeStatus;
|
||||
capabilities?: string[];
|
||||
};
|
||||
@@ -5612,6 +5617,38 @@ export function updateNode(id: string, updates: NodeUpdateInput): Promise<NodeIn
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch sanitized docker config for a node */
|
||||
export function fetchDockerNodeConfig(nodeId: string): Promise<DockerNodeConfigInfo | null> {
|
||||
return api<DockerNodeConfigInfo | null>(`/nodes/${encodeURIComponent(nodeId)}/docker-config`);
|
||||
}
|
||||
|
||||
/** Replace full docker config for a node */
|
||||
export function replaceDockerNodeConfig(nodeId: string, config: DockerNodeConfig): Promise<DockerNodeConfigInfo> {
|
||||
return api<DockerNodeConfigInfo>(`/nodes/${encodeURIComponent(nodeId)}/docker-config`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
/** Patch docker config for a node */
|
||||
export function updateDockerNodeConfig(nodeId: string, config: Partial<DockerNodeConfig>): Promise<DockerNodeConfigInfo> {
|
||||
return api<DockerNodeConfigInfo>(`/nodes/${encodeURIComponent(nodeId)}/docker-config`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch docker config diff status for a node */
|
||||
export function fetchDockerConfigDiff(nodeId: string): Promise<{
|
||||
persistedVersion: number;
|
||||
deployedVersion: number | null;
|
||||
needsRecreate: boolean;
|
||||
}> {
|
||||
return api<{ persistedVersion: number; deployedVersion: number | null; needsRecreate: boolean }>(
|
||||
`/nodes/${encodeURIComponent(nodeId)}/docker-config/diff`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Unregister a node */
|
||||
export function unregisterNode(id: string): Promise<void> {
|
||||
return api<void>(`/nodes/${encodeURIComponent(id)}`, {
|
||||
|
||||
@@ -90,6 +90,65 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.node-detail-modal__docker-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__docker-toggle {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.node-detail-modal__docker-toggle-icon--expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.node-detail-modal__docker-config-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__docker-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__docker-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__docker-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-detail-modal__checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.node-detail-modal__docker-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
.node-detail-modal__docker-recreate {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.node-detail-modal__docker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -317,6 +376,15 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.node-detail-modal__docker-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.node-detail-modal__docker-toggle,
|
||||
.node-detail-modal__docker-config-content .btn {
|
||||
min-height: calc(var(--space-lg) * 2.25);
|
||||
}
|
||||
|
||||
.docker-management__env-list div {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
Pencil,
|
||||
Play,
|
||||
@@ -12,7 +15,7 @@ import {
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ContainerStatusInfo, ManagedDockerNodeInfo, NodeInfo, NodeUpdateInput, ProjectInfo } from "../api";
|
||||
import type { ContainerStatusInfo, DockerNodeConfig, ManagedDockerNodeInfo, NodeInfo, NodeUpdateInput, ProjectInfo } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { getProjectsForNode } from "../utils/nodeProjectAssignment";
|
||||
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
|
||||
@@ -41,6 +44,8 @@ interface NodeDetailModalProps {
|
||||
containerStatus?: ContainerStatusInfo;
|
||||
onFetchContainerStatus?: (managedId: string) => Promise<ContainerStatusInfo>;
|
||||
onFetchLogs?: (managedId: string) => Promise<string>;
|
||||
onUpdateDockerConfig?: (nodeId: string, config: Partial<DockerNodeConfig>) => Promise<DockerNodeConfig>;
|
||||
onFetchDockerConfigDiff?: (nodeId: string) => Promise<{ persistedVersion: number; deployedVersion: number | null; needsRecreate: boolean }>;
|
||||
}
|
||||
|
||||
const SENSITIVE_ENV_KEY_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD)/i;
|
||||
@@ -127,6 +132,8 @@ export function NodeDetailModal({
|
||||
containerStatus,
|
||||
onFetchContainerStatus,
|
||||
onFetchLogs,
|
||||
onUpdateDockerConfig,
|
||||
onFetchDockerConfigDiff,
|
||||
}: NodeDetailModalProps) {
|
||||
const isMountedRef = useRef(true);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
@@ -149,6 +156,11 @@ export function NodeDetailModal({
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const [logs, setLogs] = useState("");
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [dockerConfigExpanded, setDockerConfigExpanded] = useState(false);
|
||||
const [dockerConfigDraft, setDockerConfigDraft] = useState<DockerNodeConfig | null>(node?.dockerConfig ?? null);
|
||||
const [dockerEnvReveal, setDockerEnvReveal] = useState<Record<string, boolean>>({});
|
||||
const [dockerConfigSaving, setDockerConfigSaving] = useState(false);
|
||||
const [dockerConfigNeedsRecreate, setDockerConfigNeedsRecreate] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
@@ -174,6 +186,9 @@ export function NodeDetailModal({
|
||||
setApiKey(node.apiKey ?? "");
|
||||
setMaxConcurrent(node.maxConcurrent);
|
||||
setEditMode(false);
|
||||
setDockerConfigDraft(node.dockerConfig ?? null);
|
||||
setDockerConfigExpanded(false);
|
||||
setDockerEnvReveal({});
|
||||
}, [isOpen, node]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -359,6 +374,45 @@ export function NodeDetailModal({
|
||||
}
|
||||
}, [addToast, apiKey, isSaving, maxConcurrent, name, node, onUpdate, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!node?.dockerConfig || !onFetchDockerConfigDiff || !isOpen) return;
|
||||
void onFetchDockerConfigDiff(node.id)
|
||||
.then((diff) => {
|
||||
if (!isMountedRef.current) return;
|
||||
setDockerConfigNeedsRecreate(diff.needsRecreate);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isMountedRef.current) return;
|
||||
setDockerConfigNeedsRecreate(false);
|
||||
});
|
||||
}, [isOpen, node, onFetchDockerConfigDiff]);
|
||||
|
||||
const handleDockerConfigSave = useCallback(async () => {
|
||||
if (!node || !dockerConfigDraft || !onUpdateDockerConfig || dockerConfigSaving) return;
|
||||
setDockerConfigSaving(true);
|
||||
try {
|
||||
const result = await onUpdateDockerConfig(node.id, {
|
||||
image: dockerConfigDraft.image,
|
||||
volumeMounts: dockerConfigDraft.volumeMounts,
|
||||
environment: dockerConfigDraft.environment,
|
||||
resources: dockerConfigDraft.resources,
|
||||
host: dockerConfigDraft.host,
|
||||
extraClis: dockerConfigDraft.extraClis,
|
||||
persistence: dockerConfigDraft.persistence,
|
||||
containerName: dockerConfigDraft.containerName,
|
||||
});
|
||||
if (!isMountedRef.current) return;
|
||||
setDockerConfigDraft(result);
|
||||
addToast("Docker config saved", "success");
|
||||
} catch (error) {
|
||||
if (!isMountedRef.current) return;
|
||||
const message = error instanceof Error ? error.message : "Failed to save Docker config";
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
if (isMountedRef.current) setDockerConfigSaving(false);
|
||||
}
|
||||
}, [addToast, dockerConfigDraft, dockerConfigSaving, node, onUpdateDockerConfig]);
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
if (!node) return;
|
||||
setName(node.name);
|
||||
@@ -518,6 +572,156 @@ export function NodeDetailModal({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{dockerConfigDraft && (
|
||||
<section className="node-detail-modal__section node-detail-modal__docker-config">
|
||||
<button
|
||||
className="btn btn-sm node-detail-modal__docker-toggle"
|
||||
onClick={() => setDockerConfigExpanded((prev) => !prev)}
|
||||
aria-expanded={dockerConfigExpanded}
|
||||
>
|
||||
<ChevronDown size={14} className={dockerConfigExpanded ? "node-detail-modal__docker-toggle-icon--expanded" : ""} />
|
||||
Docker Configuration
|
||||
</button>
|
||||
|
||||
{dockerConfigExpanded && (
|
||||
<div className="node-detail-modal__docker-config-content">
|
||||
<div className="node-detail-modal__grid">
|
||||
<label className="node-detail-modal__field node-detail-modal__field--full">
|
||||
<span>Image</span>
|
||||
<input className="input" value={dockerConfigDraft.image} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, image: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>Volume Mounts</summary>
|
||||
<div className="node-detail-modal__docker-list">
|
||||
{dockerConfigDraft.volumeMounts.map((mount, index) => (
|
||||
<div key={`${mount.hostPath}-${mount.containerPath}-${index}`} className="node-detail-modal__docker-row">
|
||||
<input className="input" value={mount.hostPath} placeholder="Host path" onChange={(event) => {
|
||||
const next = [...dockerConfigDraft.volumeMounts];
|
||||
next[index] = { ...next[index], hostPath: event.target.value };
|
||||
setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: next });
|
||||
}} />
|
||||
<input className="input" value={mount.containerPath} placeholder="Container path" onChange={(event) => {
|
||||
const next = [...dockerConfigDraft.volumeMounts];
|
||||
next[index] = { ...next[index], containerPath: event.target.value };
|
||||
setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: next });
|
||||
}} />
|
||||
<select className="input" value={mount.mode ?? "rw"} onChange={(event) => {
|
||||
const next = [...dockerConfigDraft.volumeMounts];
|
||||
next[index] = { ...next[index], mode: event.target.value as "rw" | "ro" };
|
||||
setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: next });
|
||||
}}>
|
||||
<option value="rw">rw</option>
|
||||
<option value="ro">ro</option>
|
||||
</select>
|
||||
<select className="input" value={mount.type ?? "volume"} onChange={(event) => {
|
||||
const next = [...dockerConfigDraft.volumeMounts];
|
||||
next[index] = { ...next[index], type: event.target.value as "volume" | "bind" };
|
||||
setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: next });
|
||||
}}>
|
||||
<option value="volume">volume</option>
|
||||
<option value="bind">bind</option>
|
||||
</select>
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: dockerConfigDraft.volumeMounts.filter((_, i) => i !== index) })}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: [...dockerConfigDraft.volumeMounts, { hostPath: "", containerPath: "", mode: "rw", type: "volume" }] })}>Add Mount</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Environment Variables</summary>
|
||||
<div className="node-detail-modal__docker-list">
|
||||
{Object.entries(dockerConfigDraft.environment).map(([key, value]) => {
|
||||
const masked = SENSITIVE_ENV_KEY_PATTERN.test(key) && !dockerEnvReveal[key];
|
||||
return (
|
||||
<div key={key} className="node-detail-modal__docker-row">
|
||||
<input className="input" value={key} onChange={(event) => {
|
||||
const next = { ...dockerConfigDraft.environment };
|
||||
delete next[key];
|
||||
next[event.target.value] = value;
|
||||
setDockerConfigDraft({ ...dockerConfigDraft, environment: next });
|
||||
}} />
|
||||
<input className="input" value={masked ? "***" : value} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, environment: { ...dockerConfigDraft.environment, [key]: event.target.value } })} />
|
||||
<button className="btn btn-sm" onClick={() => setDockerEnvReveal((prev) => ({ ...prev, [key]: !prev[key] }))}>
|
||||
{dockerEnvReveal[key] ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => {
|
||||
const next = { ...dockerConfigDraft.environment };
|
||||
delete next[key];
|
||||
setDockerConfigDraft({ ...dockerConfigDraft, environment: next });
|
||||
}}>Remove</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button className="btn btn-sm" onClick={() => {
|
||||
const nextKey = `NEW_VAR_${Object.keys(dockerConfigDraft.environment).length + 1}`;
|
||||
setDockerConfigDraft({ ...dockerConfigDraft, environment: { ...dockerConfigDraft.environment, [nextKey]: "" } });
|
||||
}}>Add Variable</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Resources</summary>
|
||||
<div className="node-detail-modal__docker-stack">
|
||||
<input className="input" type="number" placeholder="Memory bytes (2 GB = 2147483648)" value={dockerConfigDraft.resources?.memoryBytes ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, resources: { ...dockerConfigDraft.resources, memoryBytes: event.target.value ? Number(event.target.value) : undefined } })} />
|
||||
<input className="input" type="number" placeholder="CPU count" value={dockerConfigDraft.resources?.cpuCount ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, resources: { ...dockerConfigDraft.resources, cpuCount: event.target.value ? Number(event.target.value) : undefined } })} />
|
||||
<input className="input" type="number" placeholder="PIDs limit" value={dockerConfigDraft.resources?.pidsLimit ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, resources: { ...dockerConfigDraft.resources, pidsLimit: event.target.value ? Number(event.target.value) : undefined } })} />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Host Config</summary>
|
||||
<div className="node-detail-modal__docker-stack">
|
||||
<input className="input" placeholder="Context name" value={dockerConfigDraft.host?.contextName ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, contextName: event.target.value } })} />
|
||||
<input className="input" placeholder="Docker host URL" value={dockerConfigDraft.host?.dockerHost ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, dockerHost: event.target.value } })} />
|
||||
<input className="input" placeholder="TLS CA cert path" value={dockerConfigDraft.host?.tlsCaCert ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, tlsCaCert: event.target.value } })} />
|
||||
<input className="input" placeholder="TLS cert path" value={dockerConfigDraft.host?.tlsCert ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, tlsCert: event.target.value } })} />
|
||||
<input className="input" placeholder="TLS key path" value={dockerConfigDraft.host?.tlsKey ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, tlsKey: event.target.value } })} />
|
||||
<label className="node-detail-modal__checkbox"><input type="checkbox" checked={dockerConfigDraft.host?.tlsVerify ?? true} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, tlsVerify: event.target.checked } })} />TLS verify</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Extra CLIs</summary>
|
||||
<div className="node-detail-modal__docker-list">
|
||||
{(dockerConfigDraft.extraClis ?? []).map((cli, index) => (
|
||||
<div key={`${cli}-${index}`} className="node-detail-modal__docker-row">
|
||||
<input className="input" value={cli} onChange={(event) => {
|
||||
const next = [...(dockerConfigDraft.extraClis ?? [])];
|
||||
next[index] = event.target.value;
|
||||
setDockerConfigDraft({ ...dockerConfigDraft, extraClis: next });
|
||||
}} />
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, extraClis: (dockerConfigDraft.extraClis ?? []).filter((_, i) => i !== index) })}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, extraClis: [...(dockerConfigDraft.extraClis ?? []), ""] })}>Add CLI</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Persistence</summary>
|
||||
<div className="node-detail-modal__docker-stack">
|
||||
<input className="input" placeholder="Volume name" value={dockerConfigDraft.persistence?.volumeName ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, persistence: { ...dockerConfigDraft.persistence, volumeName: event.target.value } })} />
|
||||
<label className="node-detail-modal__checkbox"><input type="checkbox" checked={dockerConfigDraft.persistence?.retainOnDelete ?? false} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, persistence: { ...dockerConfigDraft.persistence, retainOnDelete: event.target.checked } })} />Retain on delete</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="node-detail-modal__docker-meta">
|
||||
<span>Config v{dockerConfigDraft.configVersion} • Updated {formatRelativeTime(dockerConfigDraft.lastUpdated ?? node.updatedAt)}</span>
|
||||
{dockerConfigNeedsRecreate && <span className="node-detail-modal__docker-recreate">Needs Recreate</span>}
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary btn-sm" onClick={() => void handleDockerConfigSave()} disabled={dockerConfigSaving}>
|
||||
<Save size={14} />
|
||||
{dockerConfigSaving ? "Saving..." : "Save Docker Config"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{managedDockerNode && (
|
||||
<section className="node-detail-modal__section docker-management">
|
||||
<h4>Docker Management</h4>
|
||||
|
||||
@@ -20,7 +20,18 @@ interface NodesViewProps {
|
||||
}
|
||||
|
||||
export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
const { nodes, loading, error, refresh, register, update, unregister, healthCheck } = useNodes();
|
||||
const {
|
||||
nodes,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
register,
|
||||
update,
|
||||
unregister,
|
||||
healthCheck,
|
||||
patchDockerConfig,
|
||||
fetchDockerDiff,
|
||||
} = useNodes();
|
||||
const { projects } = useProjects();
|
||||
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync();
|
||||
const {
|
||||
@@ -264,6 +275,8 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
managedDockerNode={selectedNode ? dockerNodeMap.get(selectedNode.id) : undefined}
|
||||
onFetchContainerStatus={getContainerStatus}
|
||||
onFetchLogs={getLogs}
|
||||
onUpdateDockerConfig={patchDockerConfig}
|
||||
onFetchDockerConfigDiff={fetchDockerDiff}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { NodeDetailModal } from "../NodeDetailModal";
|
||||
import type { ManagedDockerNodeInfo, NodeInfo, ProjectInfo } from "../../api";
|
||||
import type { DockerNodeConfig, ManagedDockerNodeInfo, NodeInfo, ProjectInfo } from "../../api";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
Activity: () => <span>activity</span>,
|
||||
Download: () => <span>download</span>,
|
||||
Eye: () => <span>eye</span>,
|
||||
EyeOff: () => <span>eye-off</span>,
|
||||
FileText: () => <span>file-text</span>,
|
||||
Pencil: () => <span>pencil</span>,
|
||||
Play: () => <span>play</span>,
|
||||
@@ -63,6 +65,14 @@ const baseProps = {
|
||||
addToast: vi.fn(),
|
||||
};
|
||||
|
||||
const dockerConfig: DockerNodeConfig = {
|
||||
image: "runfusion/fusion:latest",
|
||||
volumeMounts: [{ hostPath: "fusion-data", containerPath: "/app/.fusion", mode: "rw", type: "volume" }],
|
||||
environment: { FUSION_TOKEN: "secret" },
|
||||
configVersion: 1,
|
||||
lastUpdated: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("NodeDetailModal docker section", () => {
|
||||
it("does not render docker section without managedDockerNode", () => {
|
||||
render(<NodeDetailModal {...baseProps} />);
|
||||
@@ -119,6 +129,25 @@ describe("NodeDetailModal docker section", () => {
|
||||
expect(screen.getByText("Exit code: 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders docker config editor and saves", async () => {
|
||||
const onUpdateDockerConfig = vi.fn().mockResolvedValue({ ...dockerConfig, configVersion: 2 });
|
||||
const onFetchDockerConfigDiff = vi.fn().mockResolvedValue({ persistedVersion: 1, deployedVersion: null, needsRecreate: true });
|
||||
render(
|
||||
<NodeDetailModal
|
||||
{...baseProps}
|
||||
node={makeNode({ dockerConfig })}
|
||||
onUpdateDockerConfig={onUpdateDockerConfig}
|
||||
onFetchDockerConfigDiff={onFetchDockerConfigDiff}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /docker configuration/i }));
|
||||
expect(screen.getByText(/Config v1/)).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText("Needs Recreate")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("button", { name: /save docker config/i }));
|
||||
await waitFor(() => expect(onUpdateDockerConfig).toHaveBeenCalledWith("node-1", expect.objectContaining({ image: "runfusion/fusion:latest" })));
|
||||
});
|
||||
|
||||
it("masks sensitive env values and shows read-only mount", () => {
|
||||
render(<NodeDetailModal {...baseProps} managedDockerNode={makeDockerNode()} />);
|
||||
fireEvent.click(screen.getByText("Environment Variables"));
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { NodeCreateInput, NodeInfo, NodeUpdateInput } from "../api";
|
||||
import type { DockerNodeConfig, NodeCreateInput, NodeInfo, NodeUpdateInput } from "../api";
|
||||
import {
|
||||
fetchDockerConfigDiff,
|
||||
fetchDockerNodeConfig,
|
||||
fetchNodes,
|
||||
registerNode,
|
||||
updateDockerNodeConfig,
|
||||
updateNode,
|
||||
unregisterNode,
|
||||
checkNodeHealth,
|
||||
@@ -17,6 +20,9 @@ export interface UseNodesResult {
|
||||
update: (id: string, updates: NodeUpdateInput) => Promise<NodeInfo>;
|
||||
unregister: (id: string) => Promise<void>;
|
||||
healthCheck: (id: string) => Promise<void>;
|
||||
fetchDockerConfig: (nodeId: string) => Promise<DockerNodeConfig | null>;
|
||||
patchDockerConfig: (nodeId: string, config: Partial<DockerNodeConfig>) => Promise<DockerNodeConfig>;
|
||||
fetchDockerDiff: (nodeId: string) => Promise<{ persistedVersion: number; deployedVersion: number | null; needsRecreate: boolean }>;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 10000; // 10 seconds
|
||||
@@ -136,6 +142,26 @@ export function useNodes(): UseNodesResult {
|
||||
)));
|
||||
}, []);
|
||||
|
||||
const fetchDockerConfig = useCallback((nodeId: string) => fetchDockerNodeConfig(nodeId), []);
|
||||
|
||||
const patchDockerConfig = useCallback(async (nodeId: string, config: Partial<DockerNodeConfig>) => {
|
||||
const updatedConfig = await updateDockerNodeConfig(nodeId, config);
|
||||
setNodes((prev) => prev.map((node) => (
|
||||
node.id === nodeId
|
||||
? { ...node, dockerConfig: updatedConfig }
|
||||
: node
|
||||
)));
|
||||
return updatedConfig;
|
||||
}, []);
|
||||
|
||||
const fetchDockerDiff = useCallback(async (nodeId: string) => {
|
||||
const diff = await fetchDockerConfigDiff(nodeId);
|
||||
if ("persistedVersion" in diff) {
|
||||
return diff;
|
||||
}
|
||||
return { persistedVersion: 0, deployedVersion: null, needsRecreate: false };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
loading,
|
||||
@@ -145,5 +171,8 @@ export function useNodes(): UseNodesResult {
|
||||
update,
|
||||
unregister,
|
||||
healthCheck,
|
||||
fetchDockerConfig,
|
||||
patchDockerConfig,
|
||||
fetchDockerDiff,
|
||||
};
|
||||
}
|
||||
|
||||
193
packages/dashboard/src/__tests__/docker-node-routes.test.ts
Normal file
193
packages/dashboard/src/__tests__/docker-node-routes.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { request } from "../test-request.js";
|
||||
import { createServer } from "../server.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mockInit: vi.fn().mockResolvedValue(undefined),
|
||||
mockClose: vi.fn().mockResolvedValue(undefined),
|
||||
mockGetNode: vi.fn(),
|
||||
mockUpdateNode: vi.fn(),
|
||||
mockRegisterNode: vi.fn(),
|
||||
mockValidateDockerNodeConfig: vi.fn(),
|
||||
mockSanitizeDockerNodeConfigForResponse: vi.fn((config) => config),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
...actual,
|
||||
CentralCore: vi.fn().mockImplementation(() => ({
|
||||
init: mocks.mockInit,
|
||||
close: mocks.mockClose,
|
||||
getNode: mocks.mockGetNode,
|
||||
updateNode: mocks.mockUpdateNode,
|
||||
registerNode: mocks.mockRegisterNode,
|
||||
})),
|
||||
validateDockerNodeConfig: mocks.mockValidateDockerNodeConfig,
|
||||
sanitizeDockerNodeConfigForResponse: mocks.mockSanitizeDockerNodeConfigForResponse,
|
||||
};
|
||||
});
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
getRootDir() { return "/tmp/fn-3114"; }
|
||||
getFusionDir() { return "/tmp/fn-3114/.fusion"; }
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({
|
||||
run: vi.fn().mockReturnValue({ changes: 0 }),
|
||||
get: vi.fn(),
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
};
|
||||
}
|
||||
getMissionStore() { return { listMissions: vi.fn().mockResolvedValue([]) }; }
|
||||
async listTasks(): Promise<Task[]> { return []; }
|
||||
}
|
||||
|
||||
const app = createServer(new MockStore() as any);
|
||||
|
||||
const config = {
|
||||
image: "runfusion/fusion:latest",
|
||||
volumeMounts: [{ hostPath: "fusion-data", containerPath: "/app/.fusion", mode: "rw", type: "volume" }],
|
||||
environment: { API_KEY: "x", NORMAL: "y" },
|
||||
host: { tlsKey: "/secrets/key.pem" },
|
||||
configVersion: 1,
|
||||
};
|
||||
|
||||
describe("docker node config routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.mockValidateDockerNodeConfig.mockReturnValue({ valid: true, config });
|
||||
mocks.mockSanitizeDockerNodeConfigForResponse.mockImplementation((value) => ({
|
||||
...value,
|
||||
environment: { ...value.environment, API_KEY: "***" },
|
||||
host: value.host ? { ...value.host, tlsKey: "***" } : undefined,
|
||||
}));
|
||||
});
|
||||
|
||||
it("GET /api/nodes/:id/docker-config returns 404 for missing node", async () => {
|
||||
mocks.mockGetNode.mockResolvedValue(undefined);
|
||||
const res = await request(app, "GET", "/api/nodes/node-1/docker-config");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /api/nodes/:id/docker-config returns null when no config", async () => {
|
||||
mocks.mockGetNode.mockResolvedValue({ id: "node-1", dockerConfig: undefined });
|
||||
const res = await request(app, "GET", "/api/nodes/node-1/docker-config");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBeNull();
|
||||
});
|
||||
|
||||
it("GET returns sanitized config", async () => {
|
||||
mocks.mockGetNode.mockResolvedValue({ id: "node-1", dockerConfig: config });
|
||||
const res = await request(app, "GET", "/api/nodes/node-1/docker-config");
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any).environment.API_KEY).toBe("***");
|
||||
expect((res.body as any).host.tlsKey).toBe("***");
|
||||
});
|
||||
|
||||
it("PUT validates and returns 400 on invalid config", async () => {
|
||||
mocks.mockValidateDockerNodeConfig.mockReturnValue({ valid: false, errors: ["bad"] });
|
||||
const res = await request(app, "PUT", "/api/nodes/node-1/docker-config", JSON.stringify({ bad: true }), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("PUT returns 404 for missing node", async () => {
|
||||
mocks.mockGetNode.mockResolvedValue(undefined);
|
||||
const res = await request(app, "PUT", "/api/nodes/node-1/docker-config", JSON.stringify(config), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("PUT accepts raw config and returns sanitized", async () => {
|
||||
mocks.mockGetNode.mockResolvedValue({ id: "node-1", dockerConfig: config });
|
||||
mocks.mockUpdateNode.mockResolvedValue({ dockerConfig: { ...config, configVersion: 2 } });
|
||||
const res = await request(app, "PUT", "/api/nodes/node-1/docker-config", JSON.stringify(config), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mocks.mockUpdateNode).toHaveBeenCalledWith("node-1", { dockerConfig: config });
|
||||
expect((res.body as any).environment.API_KEY).toBe("***");
|
||||
});
|
||||
|
||||
it("PATCH merges partial updates with volume replacement and env null-removal", async () => {
|
||||
mocks.mockGetNode.mockResolvedValue({
|
||||
id: "node-1",
|
||||
dockerConfig: {
|
||||
...config,
|
||||
environment: { KEEP: "x", DROP: "y", EMPTY: "" },
|
||||
volumeMounts: [{ hostPath: "old", containerPath: "/old", mode: "rw", type: "bind" }],
|
||||
},
|
||||
});
|
||||
mocks.mockUpdateNode.mockResolvedValue({ dockerConfig: { ...config, configVersion: 2 } });
|
||||
|
||||
const patch = {
|
||||
volumeMounts: [{ hostPath: "new", containerPath: "/new", mode: "ro", type: "volume" }],
|
||||
environment: { DROP: null, ADD: "z", EMPTY: "" },
|
||||
};
|
||||
const res = await request(app, "PATCH", "/api/nodes/node-1/docker-config", JSON.stringify(patch), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mocks.mockValidateDockerNodeConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
volumeMounts: patch.volumeMounts,
|
||||
environment: { KEEP: "x", EMPTY: "", ADD: "z" },
|
||||
}));
|
||||
});
|
||||
|
||||
it("PATCH returns 404 for missing node", async () => {
|
||||
mocks.mockGetNode.mockResolvedValue(undefined);
|
||||
const res = await request(app, "PATCH", "/api/nodes/node-1/docker-config", JSON.stringify({ image: "x" }), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("PATCH returns 400 when node has no existing config", async () => {
|
||||
mocks.mockGetNode.mockResolvedValue({ id: "node-1", dockerConfig: undefined });
|
||||
const res = await request(app, "PATCH", "/api/nodes/node-1/docker-config", JSON.stringify({ image: "x" }), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("GET /diff returns 404 for missing node", async () => {
|
||||
mocks.mockGetNode.mockResolvedValue(undefined);
|
||||
const res = await request(app, "GET", "/api/nodes/node-1/docker-config/diff");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /diff returns null config for non-docker node", async () => {
|
||||
mocks.mockGetNode.mockResolvedValue({ id: "node-1", dockerConfig: undefined });
|
||||
const res = await request(app, "GET", "/api/nodes/node-1/docker-config/diff");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ config: null });
|
||||
});
|
||||
|
||||
it("GET /diff returns v1 diff payload", async () => {
|
||||
mocks.mockGetNode.mockResolvedValue({ id: "node-1", dockerConfig: { ...config, configVersion: 3 } });
|
||||
const res = await request(app, "GET", "/api/nodes/node-1/docker-config/diff");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ persistedVersion: 3, deployedVersion: null, needsRecreate: false });
|
||||
});
|
||||
|
||||
it("config version increments across PUT/PATCH responses", async () => {
|
||||
mocks.mockGetNode
|
||||
.mockResolvedValueOnce({ id: "node-1", dockerConfig: { ...config, configVersion: 1 } })
|
||||
.mockResolvedValueOnce({ id: "node-1", dockerConfig: { ...config, configVersion: 2 } });
|
||||
mocks.mockUpdateNode
|
||||
.mockResolvedValueOnce({ dockerConfig: { ...config, configVersion: 2 } })
|
||||
.mockResolvedValueOnce({ dockerConfig: { ...config, configVersion: 3 } });
|
||||
|
||||
const putRes = await request(app, "PUT", "/api/nodes/node-1/docker-config", JSON.stringify(config), { "Content-Type": "application/json" });
|
||||
const patchRes = await request(app, "PATCH", "/api/nodes/node-1/docker-config", JSON.stringify({ image: "runfusion/fusion:stable" }), { "Content-Type": "application/json" });
|
||||
|
||||
expect(putRes.status).toBe(200);
|
||||
expect(patchRes.status).toBe(200);
|
||||
expect((putRes.body as any).configVersion).toBe(2);
|
||||
expect((patchRes.body as any).configVersion).toBe(3);
|
||||
});
|
||||
|
||||
it("POST/PATCH /api/nodes pass through dockerConfig", async () => {
|
||||
mocks.mockRegisterNode.mockResolvedValue({ id: "node-1" });
|
||||
mocks.mockUpdateNode.mockResolvedValue({ id: "node-1" });
|
||||
await request(app, "POST", "/api/nodes", JSON.stringify({ name: "n", type: "remote", url: "http://x", dockerConfig: config }), { "Content-Type": "application/json" });
|
||||
await request(app, "PATCH", "/api/nodes/node-1", JSON.stringify({ dockerConfig: config }), { "Content-Type": "application/json" });
|
||||
expect(mocks.mockRegisterNode).toHaveBeenCalledWith(expect.objectContaining({ dockerConfig: config }));
|
||||
expect(mocks.mockUpdateNode).toHaveBeenCalledWith("node-1", expect.objectContaining({ dockerConfig: config }));
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
*/
|
||||
router.post("/nodes", async (req, res) => {
|
||||
try {
|
||||
const { name, type, url, apiKey, maxConcurrent, capabilities } = req.body;
|
||||
const { name, type, url, apiKey, maxConcurrent, capabilities, dockerConfig } = req.body;
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
throw badRequest("name is required and must be a non-empty string");
|
||||
@@ -75,6 +75,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
apiKey: typeof apiKey === "string" ? apiKey : undefined,
|
||||
maxConcurrent,
|
||||
capabilities,
|
||||
dockerConfig,
|
||||
});
|
||||
|
||||
await central.close();
|
||||
@@ -124,7 +125,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
*/
|
||||
router.patch("/nodes/:id", async (req, res) => {
|
||||
try {
|
||||
const { name, url, apiKey, maxConcurrent, status, capabilities } = req.body;
|
||||
const { name, url, apiKey, maxConcurrent, status, capabilities, dockerConfig } = req.body;
|
||||
|
||||
const updates: Partial<Omit<import("@fusion/core").NodeConfig, "id" | "createdAt">> = {};
|
||||
if (name !== undefined) updates.name = name;
|
||||
@@ -133,6 +134,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
if (maxConcurrent !== undefined) updates.maxConcurrent = maxConcurrent;
|
||||
if (status !== undefined) updates.status = status as import("@fusion/core").NodeStatus;
|
||||
if (capabilities !== undefined) updates.capabilities = capabilities;
|
||||
if (dockerConfig !== undefined) updates.dockerConfig = dockerConfig;
|
||||
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
@@ -155,6 +157,125 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/nodes/:id/docker-config
|
||||
* Return sanitized Docker config for a node.
|
||||
*/
|
||||
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();
|
||||
if (!node) throw notFound("Node not found");
|
||||
res.json(node.dockerConfig ? sanitizeDockerNodeConfigForResponse(node.dockerConfig) : null);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/nodes/:id/docker-config
|
||||
* Replace full Docker config for a node.
|
||||
*/
|
||||
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();
|
||||
res.json(sanitizeDockerNodeConfigForResponse(updated.dockerConfig!));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
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 patch = req.body as Record<string, unknown>;
|
||||
const mergedEnvironment: Record<string, string> = { ...existing.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>)) {
|
||||
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 validation = validateDockerNodeConfig(merged);
|
||||
if (!validation.valid || !validation.config) {
|
||||
await central.close();
|
||||
throw new ApiError(400, "Invalid Docker config", { errors: validation.errors ?? [] });
|
||||
}
|
||||
|
||||
const updated = await central.updateNode(req.params.id, { dockerConfig: validation.config });
|
||||
await central.close();
|
||||
res.json(sanitizeDockerNodeConfigForResponse(updated.dockerConfig!));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
if (!node) throw notFound("Node not found");
|
||||
if (!node.dockerConfig) {
|
||||
res.json({ config: null });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
persistedVersion: node.dockerConfig.configVersion,
|
||||
deployedVersion: null,
|
||||
needsRecreate: false,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/nodes/:id
|
||||
* Unregister a node.
|
||||
|
||||
Reference in New Issue
Block a user