diff --git a/server.mjs b/server.mjs index 1e394e5..0691edf 100644 --- a/server.mjs +++ b/server.mjs @@ -1,18 +1,14 @@ -import { constants } from 'node:fs'; -import { - access, - copyFile, - mkdir, - open, - readFile, - readdir, - rename, - stat, - unlink -} from 'node:fs/promises'; +import { readFile, stat } from 'node:fs/promises'; import { createServer } from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { validateNetworkData } from './src/lib/shared/networkSchemaCore.mjs'; +import { + checkWritableState, + isWriteEnabled, + readNetworkFile, + writeNetworkFile +} from './src/lib/shared/networkPersistenceCore.mjs'; const currentFilePath = fileURLToPath(import.meta.url); const currentDirPath = path.dirname(currentFilePath); @@ -21,18 +17,6 @@ const buildDir = path.resolve(currentDirPath, 'build'); const HOST = process.env.HOST ?? '0.0.0.0'; const PORT = Number(process.env.PORT ?? 3000); -const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true'; -const NETWORK_DATA_FILE = path.resolve( - process.env.NETWORK_DATA_FILE ?? path.join(currentDirPath, 'data/network.json') -); -const NETWORK_BACKUP_DIR = path.resolve( - process.env.NETWORK_BACKUP_DIR ?? path.join(path.dirname(NETWORK_DATA_FILE), '.backups') -); - -function isWriteEnabled() { - return !NETWORK_READ_ONLY; -} - const contentTypeByExtension = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', @@ -64,343 +48,6 @@ function badRequest(response, message, details) { }); } -function normalizePort(port) { - if (!port || typeof port !== 'object') { - return null; - } - if (typeof port.portName !== 'string' || port.portName.trim() === '') { - return null; - } - const normalized = { - portName: port.portName.trim() - }; - if (typeof port.speedGbps === 'number' && !Number.isNaN(port.speedGbps)) { - normalized.speedGbps = port.speedGbps; - } - if (port.connectedTo && typeof port.connectedTo === 'object') { - if ( - typeof port.connectedTo.device === 'string' && - port.connectedTo.device.trim() !== '' && - typeof port.connectedTo.port === 'string' && - port.connectedTo.port.trim() !== '' - ) { - normalized.connectedTo = { - device: port.connectedTo.device.trim(), - port: port.connectedTo.port.trim() - }; - } - } - return normalized; -} - -function normalizeVm(vm) { - if (!vm || typeof vm !== 'object') { - return null; - } - if ( - typeof vm.name !== 'string' || - vm.name.trim() === '' || - typeof vm.role !== 'string' || - vm.role.trim() === '' || - typeof vm.ipAddress !== 'string' || - vm.ipAddress.trim() === '' - ) { - return null; - } - return { - name: vm.name.trim(), - role: vm.role.trim(), - ipAddress: vm.ipAddress.trim(), - ...(typeof vm.iconKey === 'string' && vm.iconKey.trim() ? { iconKey: vm.iconKey.trim() } : {}) - }; -} - -function validateNetworkData(value) { - const errors = []; - if (!value || typeof value !== 'object') { - return { - valid: false, - errors: [{ path: '$', message: 'must be an object with machines and devices arrays' }] - }; - } - if (!Array.isArray(value.machines)) { - errors.push({ path: '$.machines', message: 'must be an array' }); - } - if (!Array.isArray(value.devices)) { - errors.push({ path: '$.devices', message: 'must be an array' }); - } - - const machines = []; - for (const [machineIndex, sourceMachine] of (Array.isArray(value.machines) - ? value.machines - : [] - ).entries()) { - if (!sourceMachine || typeof sourceMachine !== 'object') { - errors.push({ path: `$.machines[${machineIndex}]`, message: 'must be an object' }); - continue; - } - - const requiredMachineFields = ['machineName', 'ipAddress', 'role', 'operatingSystem']; - let invalidField = false; - for (const fieldName of requiredMachineFields) { - if (typeof sourceMachine[fieldName] !== 'string' || sourceMachine[fieldName].trim() === '') { - errors.push({ path: `$.machines[${machineIndex}].${fieldName}`, message: 'is required' }); - invalidField = true; - } - } - const { hardware } = sourceMachine; - if (!hardware || typeof hardware !== 'object') { - errors.push({ path: `$.machines[${machineIndex}].hardware`, message: 'must be an object' }); - invalidField = true; - } - const { software } = sourceMachine; - if (!software || typeof software !== 'object' || !Array.isArray(software.vms)) { - errors.push({ - path: `$.machines[${machineIndex}].software.vms`, - message: 'must be an array' - }); - invalidField = true; - } - if (invalidField) { - continue; - } - - const vmNames = new Set(); - const vms = []; - for (const [vmIndex, sourceVm] of software.vms.entries()) { - const vm = normalizeVm(sourceVm); - if (!vm) { - errors.push({ - path: `$.machines[${machineIndex}].software.vms[${vmIndex}]`, - message: 'invalid vm' - }); - continue; - } - const vmKey = vm.name.toLowerCase(); - if (vmNames.has(vmKey)) { - errors.push({ - path: `$.machines[${machineIndex}].software.vms[${vmIndex}].name`, - message: `duplicate vm name "${vm.name}"` - }); - } - vmNames.add(vmKey); - vms.push(vm); - } - - const ports = []; - const portNames = new Set(); - for (const [portIndex, sourcePort] of (Array.isArray(sourceMachine.ports) - ? sourceMachine.ports - : [] - ).entries()) { - const port = normalizePort(sourcePort); - if (!port) { - errors.push({ - path: `$.machines[${machineIndex}].ports[${portIndex}]`, - message: 'invalid port' - }); - continue; - } - const portKey = port.portName.toLowerCase(); - if (portNames.has(portKey)) { - errors.push({ - path: `$.machines[${machineIndex}].ports[${portIndex}].portName`, - message: `duplicate port name "${port.portName}"` - }); - } - portNames.add(portKey); - ports.push(port); - } - - machines.push({ - machineName: sourceMachine.machineName.trim(), - ipAddress: sourceMachine.ipAddress.trim(), - role: sourceMachine.role.trim(), - operatingSystem: sourceMachine.operatingSystem.trim(), - ...(typeof sourceMachine.iconKey === 'string' && sourceMachine.iconKey.trim() - ? { iconKey: sourceMachine.iconKey.trim() } - : {}), - software: { - vms - }, - hardware: { - cpu: String(hardware.cpu ?? ''), - ram: String(hardware.ram ?? ''), - networkPorts: Number(hardware.networkPorts ?? 0), - ...(typeof hardware.networkPortSpeedGbps === 'number' - ? { networkPortSpeedGbps: hardware.networkPortSpeedGbps } - : {}), - ...(typeof hardware.gpu === 'string' && hardware.gpu.trim() - ? { gpu: hardware.gpu.trim() } - : {}) - }, - ports - }); - } - - const devices = []; - for (const [deviceIndex, sourceDevice] of (Array.isArray(value.devices) - ? value.devices - : [] - ).entries()) { - if (!sourceDevice || typeof sourceDevice !== 'object') { - errors.push({ path: `$.devices[${deviceIndex}]`, message: 'must be an object' }); - continue; - } - const requiredDeviceFields = ['name', 'ipAddress', 'type']; - let invalidField = false; - for (const fieldName of requiredDeviceFields) { - if (typeof sourceDevice[fieldName] !== 'string' || sourceDevice[fieldName].trim() === '') { - errors.push({ path: `$.devices[${deviceIndex}].${fieldName}`, message: 'is required' }); - invalidField = true; - } - } - if (invalidField) { - continue; - } - - const ports = []; - const portNames = new Set(); - for (const [portIndex, sourcePort] of (Array.isArray(sourceDevice.ports) - ? sourceDevice.ports - : [] - ).entries()) { - const port = normalizePort(sourcePort); - if (!port) { - errors.push({ - path: `$.devices[${deviceIndex}].ports[${portIndex}]`, - message: 'invalid port' - }); - continue; - } - const portKey = port.portName.toLowerCase(); - if (portNames.has(portKey)) { - errors.push({ - path: `$.devices[${deviceIndex}].ports[${portIndex}].portName`, - message: `duplicate port name "${port.portName}"` - }); - } - portNames.add(portKey); - ports.push(port); - } - - devices.push({ - name: sourceDevice.name.trim(), - ipAddress: sourceDevice.ipAddress.trim(), - type: sourceDevice.type.trim(), - ...(typeof sourceDevice.iconKey === 'string' && sourceDevice.iconKey.trim() - ? { iconKey: sourceDevice.iconKey.trim() } - : {}), - ...(typeof sourceDevice.notes === 'string' ? { notes: sourceDevice.notes } : {}), - ports - }); - } - - const machineNames = new Set(); - for (const [index, machine] of machines.entries()) { - const key = machine.machineName.toLowerCase(); - if (machineNames.has(key)) { - errors.push({ - path: `$.machines[${index}].machineName`, - message: `duplicate machine name "${machine.machineName}"` - }); - } - machineNames.add(key); - } - - const deviceNames = new Set(); - for (const [index, device] of devices.entries()) { - const key = device.name.toLowerCase(); - if (deviceNames.has(key)) { - errors.push({ - path: `$.devices[${index}].name`, - message: `duplicate device name "${device.name}"` - }); - } - deviceNames.add(key); - } - - for (const [index, machine] of machines.entries()) { - if (deviceNames.has(machine.machineName.toLowerCase())) { - errors.push({ - path: `$.machines[${index}].machineName`, - message: `name "${machine.machineName}" collides with a device name` - }); - } - } - - return { - valid: errors.length === 0, - errors, - data: { - machines, - devices - } - }; -} - -async function createBackupIfPresent(filePath) { - try { - await access(filePath, constants.F_OK); - } catch { - return; - } - await mkdir(NETWORK_BACKUP_DIR, { recursive: true }); - const baseName = path.basename(filePath); - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const backupPath = path.join(NETWORK_BACKUP_DIR, `${baseName}.${timestamp}.bak`); - await copyFile(filePath, backupPath); -} - -async function trimBackups(filePath, keep = 5) { - let files = []; - try { - files = await readdir(NETWORK_BACKUP_DIR); - } catch { - return; - } - const baseName = path.basename(filePath); - const matches = await Promise.all( - files - .filter((entry) => entry.startsWith(`${baseName}.`) && entry.endsWith('.bak')) - .map(async (entry) => { - const entryPath = path.join(NETWORK_BACKUP_DIR, entry); - const info = await stat(entryPath); - return { path: entryPath, mtime: info.mtimeMs }; - }) - ); - matches.sort((a, b) => b.mtime - a.mtime); - await Promise.all(matches.slice(keep).map((entry) => unlink(entry.path).catch(() => undefined))); -} - -async function writeNetworkFile(payload) { - const directory = path.dirname(NETWORK_DATA_FILE); - await mkdir(directory, { recursive: true }); - await createBackupIfPresent(NETWORK_DATA_FILE); - - const tempPath = `${NETWORK_DATA_FILE}.tmp-${Date.now()}-${process.pid}`; - const fileHandle = await open(tempPath, 'w'); - try { - await fileHandle.writeFile(`${JSON.stringify(payload, null, '\t')}\n`, 'utf8'); - await fileHandle.sync(); - } finally { - await fileHandle.close(); - } - await rename(tempPath, NETWORK_DATA_FILE); - await trimBackups(NETWORK_DATA_FILE, 5); -} - -async function readNetworkFile() { - const raw = await readFile(NETWORK_DATA_FILE, 'utf8'); - const parsed = JSON.parse(raw); - const fileStats = await stat(NETWORK_DATA_FILE); - return { - data: parsed, - source: NETWORK_DATA_FILE, - updatedAt: fileStats.mtime.toISOString() - }; -} - async function serveApi(request, response) { if (request.method === 'GET') { try { @@ -414,16 +61,7 @@ async function serveApi(request, response) { return; } - let canWrite = false; - if (isWriteEnabled()) { - try { - await mkdir(path.dirname(NETWORK_DATA_FILE), { recursive: true }); - await access(path.dirname(NETWORK_DATA_FILE), constants.W_OK); - canWrite = true; - } catch { - canWrite = false; - } - } + const canWrite = await checkWritableState(); sendJson(response, 200, { data: validation.data, @@ -467,13 +105,12 @@ async function serveApi(request, response) { } try { - await writeNetworkFile(validation.data); - const fileStats = await stat(NETWORK_DATA_FILE); + const metadata = await writeNetworkFile(validation.data); sendJson(response, 200, { data: validation.data, writable: true, - source: NETWORK_DATA_FILE, - updatedAt: fileStats.mtime.toISOString() + source: metadata.source, + updatedAt: metadata.updatedAt }); } catch (error) { sendJson(response, 500, { diff --git a/src/lib/data/networkSchema.ts b/src/lib/data/networkSchema.ts index 46275df..0febe29 100644 --- a/src/lib/data/networkSchema.ts +++ b/src/lib/data/networkSchema.ts @@ -1,4 +1,8 @@ -import type { Machine, NetworkData, NetworkDevice, Port, VM } from '../types'; +import { + normalizeNetworkData as normalizeNetworkDataCore, + validateNetworkData as validateNetworkDataCore +} from '../shared/networkSchemaCore.mjs'; +import type { NetworkData } from '../types'; export interface ValidationIssue { path: string; @@ -11,389 +15,10 @@ export interface ValidationResult { data?: NetworkData; } -function deepClone(value: T): T { - if (typeof structuredClone === 'function') { - return structuredClone(value); - } - return JSON.parse(JSON.stringify(value)) as T; -} - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function readString( - issues: ValidationIssue[], - path: string, - value: unknown, - options?: { allowEmpty?: boolean; optional?: boolean } -): string | undefined { - if (value === undefined || value === null) { - if (options?.optional) { - return undefined; - } - issues.push({ path, message: 'is required' }); - return undefined; - } - if (typeof value !== 'string') { - issues.push({ path, message: 'must be a string' }); - return undefined; - } - const trimmed = value.trim(); - if (!options?.allowEmpty && trimmed.length === 0) { - issues.push({ path, message: 'cannot be empty' }); - return undefined; - } - return trimmed; -} - -function readNumber( - issues: ValidationIssue[], - path: string, - value: unknown, - options?: { optional?: boolean; min?: number } -): number | undefined { - if (value === undefined || value === null) { - if (options?.optional) { - return undefined; - } - issues.push({ path, message: 'is required' }); - return undefined; - } - if (typeof value !== 'number' || Number.isNaN(value)) { - issues.push({ path, message: 'must be a number' }); - return undefined; - } - if (typeof options?.min === 'number' && value < options.min) { - issues.push({ path, message: `must be >= ${options.min}` }); - return undefined; - } - return value; -} - -function normalizePort( - issues: ValidationIssue[], - path: string, - value: unknown, - ownerLabel: string -): Port | null { - if (!isRecord(value)) { - issues.push({ path, message: `must be an object (${ownerLabel} port)` }); - return null; - } - - const portName = readString(issues, `${path}.portName`, value.portName); - const speedGbps = readNumber(issues, `${path}.speedGbps`, value.speedGbps, { - optional: true, - min: 0 - }); - - let connectedTo: Port['connectedTo']; - if (value.connectedTo !== undefined) { - if (!isRecord(value.connectedTo)) { - issues.push({ path: `${path}.connectedTo`, message: 'must be an object' }); - } else { - const device = readString(issues, `${path}.connectedTo.device`, value.connectedTo.device); - const port = readString(issues, `${path}.connectedTo.port`, value.connectedTo.port); - if (device && port) { - connectedTo = { device, port }; - } - } - } - - if (!portName) { - return null; - } - - return { - portName, - ...(typeof speedGbps === 'number' ? { speedGbps } : {}), - ...(connectedTo ? { connectedTo } : {}) - }; -} - -function validatePortUniqueness( - issues: ValidationIssue[], - ports: Port[], - path: string, - ownerName: string -) { - const seen = new Set(); - for (let index = 0; index < ports.length; index += 1) { - const key = ports[index].portName.toLowerCase(); - if (seen.has(key)) { - issues.push({ - path: `${path}[${index}].portName`, - message: `duplicate port name "${ports[index].portName}" for ${ownerName}` - }); - } - seen.add(key); - } -} - -function normalizeVm(issues: ValidationIssue[], path: string, value: unknown): VM | null { - if (!isRecord(value)) { - issues.push({ path, message: 'must be an object (vm)' }); - return null; - } - const name = readString(issues, `${path}.name`, value.name); - const role = readString(issues, `${path}.role`, value.role); - const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress); - const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true }); - - if (!name || !role || !ipAddress) { - return null; - } - - return { - name, - role, - ipAddress, - ...(iconKey ? { iconKey } : {}) - }; -} - -function normalizeMachine(issues: ValidationIssue[], path: string, value: unknown): Machine | null { - if (!isRecord(value)) { - issues.push({ path, message: 'must be an object (machine)' }); - return null; - } - - const machineName = readString(issues, `${path}.machineName`, value.machineName); - const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress); - const role = readString(issues, `${path}.role`, value.role); - const operatingSystem = readString(issues, `${path}.operatingSystem`, value.operatingSystem); - const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true }); - - const softwareRaw = value.software; - if (!isRecord(softwareRaw)) { - issues.push({ path: `${path}.software`, message: 'must be an object' }); - } - const vmRaw = isRecord(softwareRaw) ? softwareRaw.vms : undefined; - if (!Array.isArray(vmRaw)) { - issues.push({ path: `${path}.software.vms`, message: 'must be an array' }); - } - const vms: VM[] = []; - for (const [vmIndex, vmValue] of (Array.isArray(vmRaw) ? vmRaw : []).entries()) { - const vm = normalizeVm(issues, `${path}.software.vms[${vmIndex}]`, vmValue); - if (vm) { - vms.push(vm); - } - } - - const vmSeen = new Set(); - for (const [vmIndex, vm] of vms.entries()) { - const vmKey = vm.name.toLowerCase(); - if (vmSeen.has(vmKey)) { - issues.push({ - path: `${path}.software.vms[${vmIndex}].name`, - message: `duplicate VM name "${vm.name}" for machine "${machineName ?? 'unknown'}"` - }); - } - vmSeen.add(vmKey); - } - - const hardwareRaw = value.hardware; - if (!isRecord(hardwareRaw)) { - issues.push({ path: `${path}.hardware`, message: 'must be an object' }); - } - const hardware = { - cpu: readString(issues, `${path}.hardware.cpu`, isRecord(hardwareRaw) ? hardwareRaw.cpu : undefined), - ram: readString(issues, `${path}.hardware.ram`, isRecord(hardwareRaw) ? hardwareRaw.ram : undefined), - networkPorts: readNumber( - issues, - `${path}.hardware.networkPorts`, - isRecord(hardwareRaw) ? hardwareRaw.networkPorts : undefined, - { min: 0 } - ), - networkPortSpeedGbps: readNumber( - issues, - `${path}.hardware.networkPortSpeedGbps`, - isRecord(hardwareRaw) ? hardwareRaw.networkPortSpeedGbps : undefined, - { optional: true, min: 0 } - ), - gpu: readString(issues, `${path}.hardware.gpu`, isRecord(hardwareRaw) ? hardwareRaw.gpu : undefined, { - optional: true, - allowEmpty: true - }) - }; - - const portsRaw = value.ports; - if (portsRaw !== undefined && !Array.isArray(portsRaw)) { - issues.push({ path: `${path}.ports`, message: 'must be an array when provided' }); - } - const ports: Port[] = []; - for (const [portIndex, portValue] of (Array.isArray(portsRaw) ? portsRaw : []).entries()) { - const port = normalizePort(issues, `${path}.ports[${portIndex}]`, portValue, 'machine'); - if (port) { - ports.push(port); - } - } - validatePortUniqueness(issues, ports, `${path}.ports`, machineName ?? 'machine'); - - if ( - !machineName || - !ipAddress || - !role || - !operatingSystem || - !hardware.cpu || - !hardware.ram || - typeof hardware.networkPorts !== 'number' - ) { - return null; - } - - return { - machineName, - ipAddress, - role, - operatingSystem, - ...(iconKey ? { iconKey } : {}), - software: { vms }, - hardware: { - cpu: hardware.cpu, - ram: hardware.ram, - networkPorts: hardware.networkPorts, - ...(typeof hardware.networkPortSpeedGbps === 'number' - ? { networkPortSpeedGbps: hardware.networkPortSpeedGbps } - : {}), - ...(hardware.gpu ? { gpu: hardware.gpu } : {}) - }, - ports - }; -} - -function normalizeDevice( - issues: ValidationIssue[], - path: string, - value: unknown -): NetworkDevice | null { - if (!isRecord(value)) { - issues.push({ path, message: 'must be an object (device)' }); - return null; - } - - const name = readString(issues, `${path}.name`, value.name); - const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress); - const type = readString(issues, `${path}.type`, value.type); - const notes = readString(issues, `${path}.notes`, value.notes, { optional: true, allowEmpty: true }); - const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true }); - - const portsRaw = value.ports; - if (portsRaw !== undefined && !Array.isArray(portsRaw)) { - issues.push({ path: `${path}.ports`, message: 'must be an array when provided' }); - } - const ports: Port[] = []; - for (const [portIndex, portValue] of (Array.isArray(portsRaw) ? portsRaw : []).entries()) { - const port = normalizePort(issues, `${path}.ports[${portIndex}]`, portValue, 'device'); - if (port) { - ports.push(port); - } - } - validatePortUniqueness(issues, ports, `${path}.ports`, name ?? 'device'); - - if (!name || !ipAddress || !type) { - return null; - } - - return { - name, - ipAddress, - type, - ...(iconKey ? { iconKey } : {}), - ...(notes !== undefined ? { notes } : {}), - ports - }; -} - export function validateNetworkData(value: unknown): ValidationResult { - const issues: ValidationIssue[] = []; - if (!isRecord(value)) { - return { - valid: false, - errors: [{ path: '$', message: 'must be an object with machines/devices arrays' }] - }; - } - - const machinesRaw = value.machines; - if (!Array.isArray(machinesRaw)) { - issues.push({ path: '$.machines', message: 'must be an array' }); - } - - const devicesRaw = value.devices; - if (!Array.isArray(devicesRaw)) { - issues.push({ path: '$.devices', message: 'must be an array' }); - } - - const machines: Machine[] = []; - for (const [machineIndex, machineValue] of (Array.isArray(machinesRaw) ? machinesRaw : []).entries()) { - const machine = normalizeMachine(issues, `$.machines[${machineIndex}]`, machineValue); - if (machine) { - machines.push(machine); - } - } - - const devices: NetworkDevice[] = []; - for (const [deviceIndex, deviceValue] of (Array.isArray(devicesRaw) ? devicesRaw : []).entries()) { - const device = normalizeDevice(issues, `$.devices[${deviceIndex}]`, deviceValue); - if (device) { - devices.push(device); - } - } - - const seenMachineNames = new Set(); - for (const [index, machine] of machines.entries()) { - const key = machine.machineName.toLowerCase(); - if (seenMachineNames.has(key)) { - issues.push({ - path: `$.machines[${index}].machineName`, - message: `duplicate machine name "${machine.machineName}"` - }); - } - seenMachineNames.add(key); - } - - const seenDeviceNames = new Set(); - for (const [index, device] of devices.entries()) { - const key = device.name.toLowerCase(); - if (seenDeviceNames.has(key)) { - issues.push({ - path: `$.devices[${index}].name`, - message: `duplicate device name "${device.name}"` - }); - } - seenDeviceNames.add(key); - } - - for (const [index, machine] of machines.entries()) { - if (seenDeviceNames.has(machine.machineName.toLowerCase())) { - issues.push({ - path: `$.machines[${index}].machineName`, - message: `name "${machine.machineName}" collides with a device name` - }); - } - } - - const data: NetworkData = { - machines, - devices - }; - - return { - valid: issues.length === 0, - errors: issues, - ...(issues.length === 0 ? { data } : {}) - }; + return validateNetworkDataCore(value) as ValidationResult; } export function normalizeNetworkData(data: NetworkData): NetworkData { - const cloned = deepClone(data); - const validated = validateNetworkData(cloned); - if (!validated.valid || !validated.data) { - return { - machines: [], - devices: [] - }; - } - return validated.data; + return normalizeNetworkDataCore(data) as NetworkData; } diff --git a/src/lib/server/networkPersistence.ts b/src/lib/server/networkPersistence.ts index 51bbbce..455bae1 100644 --- a/src/lib/server/networkPersistence.ts +++ b/src/lib/server/networkPersistence.ts @@ -1,16 +1,9 @@ -import { constants } from 'node:fs'; import { - access, - copyFile, - mkdir, - open, - readFile, - readdir, - rename, - stat, - unlink -} from 'node:fs/promises'; -import path from 'node:path'; + checkWritableState as checkWritableStateCore, + isWriteEnabled as isWriteEnabledCore, + readNetworkFile as readNetworkFileCore, + writeNetworkFile as writeNetworkFileCore +} from '../shared/networkPersistenceCore.mjs'; import type { NetworkData } from '../types'; @@ -19,113 +12,18 @@ export interface NetworkFileMetadata { updatedAt: string; } -const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true'; -const DATA_FILE_DEFAULT = path.resolve(process.cwd(), 'data/network.json'); -const BACKUP_DIR_DEFAULT = path.resolve(process.cwd(), 'data/.backups'); - -function resolveDataFilePath(): string { - return path.resolve(process.env.NETWORK_DATA_FILE ?? DATA_FILE_DEFAULT); -} - -function resolveBackupDirectory(): string { - return path.resolve(process.env.NETWORK_BACKUP_DIR ?? BACKUP_DIR_DEFAULT); -} - export function isWriteEnabled(): boolean { - return !NETWORK_READ_ONLY; -} - -async function readUpdatedAt(dataFilePath: string): Promise { - const dataFileStats = await stat(dataFilePath); - return dataFileStats.mtime.toISOString(); + return isWriteEnabledCore(); } export async function readNetworkFile(): Promise<{ data: NetworkData } & NetworkFileMetadata> { - const source = resolveDataFilePath(); - const raw = await readFile(source, 'utf8'); - const parsed = JSON.parse(raw) as NetworkData; - const updatedAt = await readUpdatedAt(source); - return { - data: parsed, - source, - updatedAt - }; -} - -async function createBackupIfPresent(dataFilePath: string, backupDirectory: string) { - try { - await access(dataFilePath, constants.F_OK); - } catch { - return; - } - - await mkdir(backupDirectory, { recursive: true }); - const base = path.basename(dataFilePath); - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const backupFilePath = path.join(backupDirectory, `${base}.${timestamp}.bak`); - await copyFile(dataFilePath, backupFilePath); -} - -async function trimBackups(backupDirectory: string, baseName: string, keep = 5) { - let entries: Array<{ name: string; time: number }> = []; - try { - const files = await readdir(backupDirectory); - entries = await Promise.all( - files - .filter((file) => file.startsWith(`${baseName}.`) && file.endsWith('.bak')) - .map(async (file) => { - const filePath = path.join(backupDirectory, file); - const info = await stat(filePath); - return { name: filePath, time: info.mtimeMs }; - }) - ); - } catch { - return; - } - - entries.sort((a, b) => b.time - a.time); - await Promise.all(entries.slice(keep).map((entry) => unlink(entry.name).catch(() => undefined))); + return (await readNetworkFileCore()) as { data: NetworkData } & NetworkFileMetadata; } export async function writeNetworkFile(data: NetworkData): Promise { - const source = resolveDataFilePath(); - const backupDirectory = resolveBackupDirectory(); - const directory = path.dirname(source); - await mkdir(directory, { recursive: true }); - await createBackupIfPresent(source, backupDirectory); - - const temporaryPath = `${source}.tmp-${Date.now()}-${process.pid}`; - const jsonPayload = `${JSON.stringify(data, null, '\t')}\n`; - const fileHandle = await open(temporaryPath, 'w'); - try { - await fileHandle.writeFile(jsonPayload, 'utf8'); - await fileHandle.sync(); - } finally { - await fileHandle.close(); - } - await rename(temporaryPath, source); - - await trimBackups(backupDirectory, path.basename(source), 5); - const updatedAt = await readUpdatedAt(source); - - return { - source, - updatedAt - }; + return (await writeNetworkFileCore(data)) as NetworkFileMetadata; } export async function checkWritableState(): Promise { - if (!isWriteEnabled()) { - return false; - } - - const source = resolveDataFilePath(); - const directory = path.dirname(source); - try { - await mkdir(directory, { recursive: true }); - await access(directory, constants.W_OK); - } catch { - return false; - } - return true; + return checkWritableStateCore(); } diff --git a/src/lib/shared/networkPersistenceCore.mjs b/src/lib/shared/networkPersistenceCore.mjs new file mode 100644 index 0000000..7279f7a --- /dev/null +++ b/src/lib/shared/networkPersistenceCore.mjs @@ -0,0 +1,165 @@ +import { constants } from 'node:fs'; +import { + access, + copyFile, + mkdir, + open, + readFile, + readdir, + rename, + stat, + unlink +} from 'node:fs/promises'; +import path from 'node:path'; + +/** + * @typedef {{ machines: unknown[]; devices: unknown[] }} NetworkData + * @typedef {{ source: string; updatedAt: string }} NetworkFileMetadata + * @typedef {{ data: NetworkData } & NetworkFileMetadata} NetworkFileReadResult + */ + +const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true'; +const DATA_FILE_DEFAULT = path.resolve(process.cwd(), 'data/network.json'); +const BACKUP_DIR_DEFAULT = path.resolve(process.cwd(), 'data/.backups'); + +/** + * @returns {string} + */ +function resolveDataFilePath() { + return path.resolve(process.env.NETWORK_DATA_FILE ?? DATA_FILE_DEFAULT); +} + +/** + * @returns {string} + */ +function resolveBackupDirectory() { + return path.resolve(process.env.NETWORK_BACKUP_DIR ?? BACKUP_DIR_DEFAULT); +} + +/** + * @returns {boolean} + */ +export function isWriteEnabled() { + return !NETWORK_READ_ONLY; +} + +/** + * @param {string} dataFilePath + * @returns {Promise} + */ +async function readUpdatedAt(dataFilePath) { + const dataFileStats = await stat(dataFilePath); + return dataFileStats.mtime.toISOString(); +} + +/** + * @returns {Promise} + */ +export async function readNetworkFile() { + const source = resolveDataFilePath(); + const raw = await readFile(source, 'utf8'); + const parsed = JSON.parse(raw); + const updatedAt = await readUpdatedAt(source); + return { + data: parsed, + source, + updatedAt + }; +} + +/** + * @param {string} dataFilePath + * @param {string} backupDirectory + * @returns {Promise} + */ +export async function createBackupIfPresent(dataFilePath, backupDirectory) { + try { + await access(dataFilePath, constants.F_OK); + } catch { + return; + } + + await mkdir(backupDirectory, { recursive: true }); + const base = path.basename(dataFilePath); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupFilePath = path.join(backupDirectory, `${base}.${timestamp}.bak`); + await copyFile(dataFilePath, backupFilePath); +} + +/** + * @param {string} backupDirectory + * @param {string} baseName + * @param {number} [keep] + * @returns {Promise} + */ +export async function trimBackups(backupDirectory, baseName, keep = 5) { + /** @type {Array<{ name: string; time: number }>} */ + let entries = []; + try { + const files = await readdir(backupDirectory); + entries = await Promise.all( + files + .filter((file) => file.startsWith(`${baseName}.`) && file.endsWith('.bak')) + .map(async (file) => { + const filePath = path.join(backupDirectory, file); + const info = await stat(filePath); + return { name: filePath, time: info.mtimeMs }; + }) + ); + } catch { + return; + } + + entries.sort((a, b) => b.time - a.time); + await Promise.all(entries.slice(keep).map((entry) => unlink(entry.name).catch(() => undefined))); +} + +/** + * @param {NetworkData} data + * @returns {Promise} + */ +export async function writeNetworkFile(data) { + const source = resolveDataFilePath(); + const backupDirectory = resolveBackupDirectory(); + const directory = path.dirname(source); + await mkdir(directory, { recursive: true }); + await createBackupIfPresent(source, backupDirectory); + + const temporaryPath = `${source}.tmp-${Date.now()}-${process.pid}`; + const jsonPayload = `${JSON.stringify(data, null, '\t')}\n`; + const fileHandle = await open(temporaryPath, 'w'); + try { + await fileHandle.writeFile(jsonPayload, 'utf8'); + await fileHandle.sync(); + } finally { + await fileHandle.close(); + } + await rename(temporaryPath, source); + + await trimBackups(backupDirectory, path.basename(source), 5); + const updatedAt = await readUpdatedAt(source); + + return { + source, + updatedAt + }; +} + +/** + * @returns {Promise} + */ +export async function checkWritableState() { + if (!isWriteEnabled()) { + return false; + } + + const source = resolveDataFilePath(); + const directory = path.dirname(source); + try { + await mkdir(directory, { recursive: true }); + await access(directory, constants.W_OK); + } catch { + return false; + } + return true; +} diff --git a/src/lib/shared/networkSchemaCore.mjs b/src/lib/shared/networkSchemaCore.mjs new file mode 100644 index 0000000..add90d0 --- /dev/null +++ b/src/lib/shared/networkSchemaCore.mjs @@ -0,0 +1,440 @@ +/** + * @typedef {{ path: string; message: string }} ValidationIssue + * @typedef {{ device: string; port: string }} ConnectedTo + * @typedef {{ portName: string; speedGbps?: number; connectedTo?: ConnectedTo }} Port + * @typedef {{ name: string; role: string; ipAddress: string; iconKey?: string }} VM + * @typedef {{ cpu: string; ram: string; networkPorts: number; networkPortSpeedGbps?: number; gpu?: string }} Hardware + * @typedef {{ machineName: string; ipAddress: string; role: string; operatingSystem: string; iconKey?: string; software: { vms: VM[] }; hardware: Hardware; ports?: Port[] }} Machine + * @typedef {{ name: string; ipAddress: string; type: string; iconKey?: string; notes?: string; ports?: Port[] }} NetworkDevice + * @typedef {{ machines: Machine[]; devices: NetworkDevice[] }} NetworkData + * @typedef {{ valid: boolean; errors: ValidationIssue[]; data?: NetworkData }} ValidationResult + * @typedef {{ allowEmpty?: boolean; optional?: boolean }} ReadStringOptions + * @typedef {{ optional?: boolean; min?: number }} ReadNumberOptions + */ + +/** + * @template T + * @param {T} value + * @returns {T} + */ +function deepClone(value) { + if (typeof structuredClone === 'function') { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)); +} + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isRecord(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +/** + * @param {ValidationIssue[]} issues + * @param {string} path + * @param {unknown} value + * @param {ReadStringOptions} [options] + * @returns {string | undefined} + */ +function readString(issues, path, value, options) { + if (value === undefined || value === null) { + if (options?.optional) { + return undefined; + } + issues.push({ path, message: 'is required' }); + return undefined; + } + if (typeof value !== 'string') { + issues.push({ path, message: 'must be a string' }); + return undefined; + } + const trimmed = value.trim(); + if (!options?.allowEmpty && trimmed.length === 0) { + issues.push({ path, message: 'cannot be empty' }); + return undefined; + } + return trimmed; +} + +/** + * @param {ValidationIssue[]} issues + * @param {string} path + * @param {unknown} value + * @param {ReadNumberOptions} [options] + * @returns {number | undefined} + */ +function readNumber(issues, path, value, options) { + if (value === undefined || value === null) { + if (options?.optional) { + return undefined; + } + issues.push({ path, message: 'is required' }); + return undefined; + } + if (typeof value !== 'number' || Number.isNaN(value)) { + issues.push({ path, message: 'must be a number' }); + return undefined; + } + if (typeof options?.min === 'number' && value < options.min) { + issues.push({ path, message: `must be >= ${options.min}` }); + return undefined; + } + return value; +} + +/** + * @param {ValidationIssue[]} issues + * @param {string} path + * @param {unknown} value + * @param {string} ownerLabel + * @returns {Port | null} + */ +export function normalizePort(issues, path, value, ownerLabel) { + if (!isRecord(value)) { + issues.push({ path, message: `must be an object (${ownerLabel} port)` }); + return null; + } + + const portName = readString(issues, `${path}.portName`, value.portName); + const speedGbps = readNumber(issues, `${path}.speedGbps`, value.speedGbps, { + optional: true, + min: 0 + }); + + let connectedTo; + if (value.connectedTo !== undefined) { + if (!isRecord(value.connectedTo)) { + issues.push({ path: `${path}.connectedTo`, message: 'must be an object' }); + } else { + const device = readString(issues, `${path}.connectedTo.device`, value.connectedTo.device); + const port = readString(issues, `${path}.connectedTo.port`, value.connectedTo.port); + if (device && port) { + connectedTo = { device, port }; + } + } + } + + if (!portName) { + return null; + } + + return { + portName, + ...(typeof speedGbps === 'number' ? { speedGbps } : {}), + ...(connectedTo ? { connectedTo } : {}) + }; +} + +/** + * @param {ValidationIssue[]} issues + * @param {Port[]} ports + * @param {string} path + * @param {string} ownerName + * @returns {void} + */ +function validatePortUniqueness(issues, ports, path, ownerName) { + const seen = new Set(); + for (let index = 0; index < ports.length; index += 1) { + const key = ports[index].portName.toLowerCase(); + if (seen.has(key)) { + issues.push({ + path: `${path}[${index}].portName`, + message: `duplicate port name "${ports[index].portName}" for ${ownerName}` + }); + } + seen.add(key); + } +} + +/** + * @param {ValidationIssue[]} issues + * @param {string} path + * @param {unknown} value + * @returns {VM | null} + */ +export function normalizeVm(issues, path, value) { + if (!isRecord(value)) { + issues.push({ path, message: 'must be an object (vm)' }); + return null; + } + const name = readString(issues, `${path}.name`, value.name); + const role = readString(issues, `${path}.role`, value.role); + const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress); + const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true }); + + if (!name || !role || !ipAddress) { + return null; + } + + return { + name, + role, + ipAddress, + ...(iconKey ? { iconKey } : {}) + }; +} + +/** + * @param {ValidationIssue[]} issues + * @param {string} path + * @param {unknown} value + * @returns {Machine | null} + */ +function normalizeMachine(issues, path, value) { + if (!isRecord(value)) { + issues.push({ path, message: 'must be an object (machine)' }); + return null; + } + + const machineName = readString(issues, `${path}.machineName`, value.machineName); + const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress); + const role = readString(issues, `${path}.role`, value.role); + const operatingSystem = readString(issues, `${path}.operatingSystem`, value.operatingSystem); + const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true }); + + const softwareRaw = value.software; + if (!isRecord(softwareRaw)) { + issues.push({ path: `${path}.software`, message: 'must be an object' }); + } + const vmRaw = isRecord(softwareRaw) ? softwareRaw.vms : undefined; + if (!Array.isArray(vmRaw)) { + issues.push({ path: `${path}.software.vms`, message: 'must be an array' }); + } + const vms = []; + for (const [vmIndex, vmValue] of (Array.isArray(vmRaw) ? vmRaw : []).entries()) { + const vm = normalizeVm(issues, `${path}.software.vms[${vmIndex}]`, vmValue); + if (vm) { + vms.push(vm); + } + } + + const vmSeen = new Set(); + for (const [vmIndex, vm] of vms.entries()) { + const vmKey = vm.name.toLowerCase(); + if (vmSeen.has(vmKey)) { + issues.push({ + path: `${path}.software.vms[${vmIndex}].name`, + message: `duplicate VM name "${vm.name}" for machine "${machineName ?? 'unknown'}"` + }); + } + vmSeen.add(vmKey); + } + + const hardwareRaw = value.hardware; + if (!isRecord(hardwareRaw)) { + issues.push({ path: `${path}.hardware`, message: 'must be an object' }); + } + const hardware = { + cpu: readString(issues, `${path}.hardware.cpu`, isRecord(hardwareRaw) ? hardwareRaw.cpu : undefined), + ram: readString(issues, `${path}.hardware.ram`, isRecord(hardwareRaw) ? hardwareRaw.ram : undefined), + networkPorts: readNumber( + issues, + `${path}.hardware.networkPorts`, + isRecord(hardwareRaw) ? hardwareRaw.networkPorts : undefined, + { min: 0 } + ), + networkPortSpeedGbps: readNumber( + issues, + `${path}.hardware.networkPortSpeedGbps`, + isRecord(hardwareRaw) ? hardwareRaw.networkPortSpeedGbps : undefined, + { optional: true, min: 0 } + ), + gpu: readString(issues, `${path}.hardware.gpu`, isRecord(hardwareRaw) ? hardwareRaw.gpu : undefined, { + optional: true, + allowEmpty: true + }) + }; + + const portsRaw = value.ports; + if (portsRaw !== undefined && !Array.isArray(portsRaw)) { + issues.push({ path: `${path}.ports`, message: 'must be an array when provided' }); + } + const ports = []; + for (const [portIndex, portValue] of (Array.isArray(portsRaw) ? portsRaw : []).entries()) { + const port = normalizePort(issues, `${path}.ports[${portIndex}]`, portValue, 'machine'); + if (port) { + ports.push(port); + } + } + validatePortUniqueness(issues, ports, `${path}.ports`, machineName ?? 'machine'); + + if ( + !machineName || + !ipAddress || + !role || + !operatingSystem || + !hardware.cpu || + !hardware.ram || + typeof hardware.networkPorts !== 'number' + ) { + return null; + } + + return { + machineName, + ipAddress, + role, + operatingSystem, + ...(iconKey ? { iconKey } : {}), + software: { vms }, + hardware: { + cpu: hardware.cpu, + ram: hardware.ram, + networkPorts: hardware.networkPorts, + ...(typeof hardware.networkPortSpeedGbps === 'number' + ? { networkPortSpeedGbps: hardware.networkPortSpeedGbps } + : {}), + ...(hardware.gpu ? { gpu: hardware.gpu } : {}) + }, + ports + }; +} + +/** + * @param {ValidationIssue[]} issues + * @param {string} path + * @param {unknown} value + * @returns {NetworkDevice | null} + */ +function normalizeDevice(issues, path, value) { + if (!isRecord(value)) { + issues.push({ path, message: 'must be an object (device)' }); + return null; + } + + const name = readString(issues, `${path}.name`, value.name); + const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress); + const type = readString(issues, `${path}.type`, value.type); + const notes = readString(issues, `${path}.notes`, value.notes, { optional: true, allowEmpty: true }); + const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true }); + + const portsRaw = value.ports; + if (portsRaw !== undefined && !Array.isArray(portsRaw)) { + issues.push({ path: `${path}.ports`, message: 'must be an array when provided' }); + } + const ports = []; + for (const [portIndex, portValue] of (Array.isArray(portsRaw) ? portsRaw : []).entries()) { + const port = normalizePort(issues, `${path}.ports[${portIndex}]`, portValue, 'device'); + if (port) { + ports.push(port); + } + } + validatePortUniqueness(issues, ports, `${path}.ports`, name ?? 'device'); + + if (!name || !ipAddress || !type) { + return null; + } + + return { + name, + ipAddress, + type, + ...(iconKey ? { iconKey } : {}), + ...(notes !== undefined ? { notes } : {}), + ports + }; +} + +/** + * @param {unknown} value + * @returns {ValidationResult} + */ +export function validateNetworkData(value) { + /** @type {ValidationIssue[]} */ + const issues = []; + if (!isRecord(value)) { + return { + valid: false, + errors: [{ path: '$', message: 'must be an object with machines/devices arrays' }] + }; + } + + const machinesRaw = value.machines; + if (!Array.isArray(machinesRaw)) { + issues.push({ path: '$.machines', message: 'must be an array' }); + } + + const devicesRaw = value.devices; + if (!Array.isArray(devicesRaw)) { + issues.push({ path: '$.devices', message: 'must be an array' }); + } + + const machines = []; + for (const [machineIndex, machineValue] of (Array.isArray(machinesRaw) ? machinesRaw : []).entries()) { + const machine = normalizeMachine(issues, `$.machines[${machineIndex}]`, machineValue); + if (machine) { + machines.push(machine); + } + } + + const devices = []; + for (const [deviceIndex, deviceValue] of (Array.isArray(devicesRaw) ? devicesRaw : []).entries()) { + const device = normalizeDevice(issues, `$.devices[${deviceIndex}]`, deviceValue); + if (device) { + devices.push(device); + } + } + + const seenMachineNames = new Set(); + for (const [index, machine] of machines.entries()) { + const key = machine.machineName.toLowerCase(); + if (seenMachineNames.has(key)) { + issues.push({ + path: `$.machines[${index}].machineName`, + message: `duplicate machine name "${machine.machineName}"` + }); + } + seenMachineNames.add(key); + } + + const seenDeviceNames = new Set(); + for (const [index, device] of devices.entries()) { + const key = device.name.toLowerCase(); + if (seenDeviceNames.has(key)) { + issues.push({ + path: `$.devices[${index}].name`, + message: `duplicate device name "${device.name}"` + }); + } + seenDeviceNames.add(key); + } + + for (const [index, machine] of machines.entries()) { + if (seenDeviceNames.has(machine.machineName.toLowerCase())) { + issues.push({ + path: `$.machines[${index}].machineName`, + message: `name "${machine.machineName}" collides with a device name` + }); + } + } + + const data = { + machines, + devices + }; + + return { + valid: issues.length === 0, + errors: issues, + ...(issues.length === 0 ? { data } : {}) + }; +} + +/** + * @param {NetworkData} data + * @returns {NetworkData} + */ +export function normalizeNetworkData(data) { + const cloned = deepClone(data); + const validated = validateNetworkData(cloned); + if (!validated.valid || !validated.data) { + return { + machines: [], + devices: [] + }; + } + return validated.data; +}