diff --git a/src/lib/data/networkSchema.ts b/src/lib/data/networkSchema.ts new file mode 100644 index 0000000..46275df --- /dev/null +++ b/src/lib/data/networkSchema.ts @@ -0,0 +1,399 @@ +import type { Machine, NetworkData, NetworkDevice, Port, VM } from '../types'; + +export interface ValidationIssue { + path: string; + message: string; +} + +export interface ValidationResult { + valid: boolean; + errors: ValidationIssue[]; + 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 } : {}) + }; +} + +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; +} diff --git a/src/lib/server/networkPersistence.ts b/src/lib/server/networkPersistence.ts new file mode 100644 index 0000000..51bbbce --- /dev/null +++ b/src/lib/server/networkPersistence.ts @@ -0,0 +1,131 @@ +import { constants } from 'node:fs'; +import { + access, + copyFile, + mkdir, + open, + readFile, + readdir, + rename, + stat, + unlink +} from 'node:fs/promises'; +import path from 'node:path'; + +import type { NetworkData } from '../types'; + +export interface NetworkFileMetadata { + source: string; + 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(); +} + +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))); +} + +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 + }; +} + +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; +} diff --git a/src/routes/api/network-data/+server.ts b/src/routes/api/network-data/+server.ts new file mode 100644 index 0000000..8dc6fad --- /dev/null +++ b/src/routes/api/network-data/+server.ts @@ -0,0 +1,91 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +import { validateNetworkData } from '$lib/data/networkSchema'; +import { + checkWritableState, + isWriteEnabled, + readNetworkFile, + writeNetworkFile +} from '$lib/server/networkPersistence'; + +function serializeValidationErrors(errors: Array<{ path: string; message: string }>) { + return errors.map((issue) => ({ + path: issue.path, + message: issue.message + })); +} + +export const GET: RequestHandler = async () => { + try { + const [{ data, source, updatedAt }, writable] = await Promise.all([ + readNetworkFile(), + checkWritableState() + ]); + const validation = validateNetworkData(data); + if (!validation.valid || !validation.data) { + return json( + { + error: 'Stored network data is invalid', + details: serializeValidationErrors(validation.errors) + }, + { status: 500 } + ); + } + + return json({ + data: validation.data, + writable, + source, + updatedAt + }); + } catch (error) { + const message = + error instanceof Error && error.message ? error.message : 'Failed to read network data file'; + return json({ error: message }, { status: 500 }); + } +}; + +export const PUT: RequestHandler = async ({ request }) => { + if (!isWriteEnabled()) { + return json( + { + error: 'Write API disabled. Unset NETWORK_READ_ONLY to enable persistence.' + }, + { status: 403 } + ); + } + + let payload: unknown; + try { + payload = await request.json(); + } catch { + return json({ error: 'Request body must be valid JSON' }, { status: 400 }); + } + + const validation = validateNetworkData(payload); + if (!validation.valid || !validation.data) { + return json( + { + error: 'Network data validation failed', + details: serializeValidationErrors(validation.errors) + }, + { status: 400 } + ); + } + + try { + const metadata = await writeNetworkFile(validation.data); + const writable = await checkWritableState(); + return json({ + data: validation.data, + writable, + source: metadata.source, + updatedAt: metadata.updatedAt + }); + } catch (error) { + const message = + error instanceof Error && error.message ? error.message : 'Failed to persist network data'; + return json({ error: message }, { status: 500 }); + } +};