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:
Fusion
2026-05-04 19:00:00 -07:00
committed by gsxdsm
parent 15293802ed
commit 94aab8a650
14 changed files with 1170 additions and 18 deletions

View File

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

View 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");
});
});

View File

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

View File

@@ -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")

View File

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

View File

@@ -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") */