/** * Host Inventory Loader * Loads and manages host configuration from YAML files */ import { readFileSync, readdirSync, existsSync } from 'fs'; import { join, resolve, dirname } from 'path'; import { parse as parseYaml } from 'yaml'; import type { HostConfig, NetworkGroup, ProvisioningState, InventoryIndex, HostCapabilities, } from './types.js'; /** * Default inventory path relative to workspace root */ const DEFAULT_INVENTORY_PATH = '../../../../../../infrastructure/hosts'; /** * Loads and manages host inventory from YAML files */ export class HostInventoryLoader { private hosts: Map = new Map(); private hostsByGroup: Map = new Map(); private inventoryPath: string; private loaded = false; constructor(inventoryPath?: string) { if (inventoryPath) { this.inventoryPath = inventoryPath; } else { // Resolve relative to this file's location this.inventoryPath = resolve(dirname(import.meta.url.replace('file://', '')), DEFAULT_INVENTORY_PATH); } } /** * Load all hosts from the inventory */ async loadAll(): Promise { if (this.loaded) { return; } this.hosts.clear(); this.hostsByGroup.clear(); await this.loadDirectory(this.inventoryPath); this.loaded = true; } /** * Recursively load YAML files from a directory */ private async loadDirectory(dirPath: string, groupPrefix = ''): Promise { if (!existsSync(dirPath)) { throw new Error(`Inventory path does not exist: ${dirPath}`); } const entries = readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dirPath, entry.name); if (entry.isDirectory() && entry.name !== 'schema' && entry.name !== 'provisioning') { const newPrefix = groupPrefix ? `${groupPrefix}/${entry.name}` : entry.name; await this.loadDirectory(fullPath, newPrefix); } else if (entry.name.endsWith('.yaml') && entry.name !== 'index.yaml') { const host = await this.loadHost(fullPath); this.hosts.set(host.id, host); const group = host.networkGroup; if (!this.hostsByGroup.has(group)) { this.hostsByGroup.set(group, []); } this.hostsByGroup.get(group)!.push(host); } } } /** * Load a single host from a YAML file */ private async loadHost(filePath: string): Promise { const content = readFileSync(filePath, 'utf-8'); const raw = parseYaml(content); return this.transformHost(raw); } /** * Transform raw YAML data to HostConfig */ private transformHost(raw: Record): HostConfig { // Apply defaults and type coercion const host: HostConfig = { id: raw.id as string, hostname: raw.hostname as string, fqdn: raw.fqdn as string, displayName: (raw.displayName as string) || (raw.hostname as string), os: { name: (raw.os as Record).name as HostConfig['os']['name'], version: String((raw.os as Record).version), codename: (raw.os as Record).codename as string | undefined, family: (raw.os as Record).family as HostConfig['os']['family'], immutable: Boolean((raw.os as Record).immutable), kernel: (raw.os as Record).kernel as string | undefined, }, networkGroup: raw.networkGroup as NetworkGroup, provider: raw.provider as HostConfig['provider'], ssh: { host: (raw.ssh as Record).host as string, ip: (raw.ssh as Record).ip as string | undefined, ipv6: (raw.ssh as Record).ipv6 as string | undefined, user: ((raw.ssh as Record).user as string) || 'root', port: ((raw.ssh as Record).port as number) || 22, keyRef: (raw.ssh as Record).keyRef as string | undefined, proxyJump: (raw.ssh as Record).proxyJump as string | undefined, }, capabilities: this.transformCapabilities(raw.capabilities as Record), provisioningState: (raw.provisioningState as ProvisioningState) || 'unprovisioned', provisioningDetails: raw.provisioningDetails as HostConfig['provisioningDetails'], hostnameMethod: raw.hostnameMethod as HostConfig['hostnameMethod'], alerts: { cpuThreshold: ((raw.alerts as Record)?.cpuThreshold as number) ?? 70, cpuThresholdDuration: ((raw.alerts as Record)?.cpuThresholdDuration as number) ?? 10, memoryThreshold: ((raw.alerts as Record)?.memoryThreshold as number) ?? 70, memoryThresholdDuration: ((raw.alerts as Record)?.memoryThresholdDuration as number) ?? 10, diskThreshold: ((raw.alerts as Record)?.diskThreshold as number) ?? 80, gpuThreshold: (raw.alerts as Record)?.gpuThreshold as number | undefined, gpuThresholdDuration: (raw.alerts as Record)?.gpuThresholdDuration as number | undefined, }, vpn: raw.vpn as HostConfig['vpn'], agent: raw.agent as HostConfig['agent'], tags: (raw.tags as string[]) || [], notes: raw.notes as string | undefined, }; return host; } /** * Transform capabilities with defaults */ private transformCapabilities(raw: Record | undefined): HostCapabilities { if (!raw) { return { gpu: false, database: false, storage: false, vpnGateway: false, dnsServer: false, services: [], }; } return { gpu: Boolean(raw.gpu), gpuModel: raw.gpuModel as string | undefined, database: Boolean(raw.database), databaseType: raw.databaseType as HostCapabilities['databaseType'], storage: Boolean(raw.storage), storageCapacityGB: raw.storageCapacityGB as number | undefined, vpnGateway: Boolean(raw.vpnGateway), dnsServer: Boolean(raw.dnsServer), services: (raw.services as string[]) || [], }; } /** * Get a host by ID */ getHost(id: string): HostConfig | undefined { return this.hosts.get(id); } /** * Get hosts by network group */ getHostsByGroup(group: NetworkGroup): HostConfig[] { return this.hostsByGroup.get(group) || []; } /** * Get all hosts */ getAllHosts(): HostConfig[] { return Array.from(this.hosts.values()); } /** * Get hosts by capability */ getHostsByCapability( capability: K, value: HostCapabilities[K] = true as HostCapabilities[K] ): HostConfig[] { return this.getAllHosts().filter(h => h.capabilities[capability] === value); } /** * Get hosts by provisioning state */ getHostsByProvisioningState(state: ProvisioningState): HostConfig[] { return this.getAllHosts().filter(h => h.provisioningState === state); } /** * Get hosts by tag */ getHostsByTag(tag: string): HostConfig[] { return this.getAllHosts().filter(h => h.tags.includes(tag)); } /** * Get hosts that need provisioning */ getHostsNeedingProvisioning(): HostConfig[] { return this.getAllHosts().filter( h => h.provisioningState === 'unprovisioned' || h.provisioningState === 'minimal' ); } /** * Get hosts with issues */ getDegradedHosts(): HostConfig[] { return this.getAllHosts().filter(h => h.provisioningState === 'degraded'); } /** * Get VPS hosts only */ getVPSHosts(): HostConfig[] { return this.getAllHosts().filter( h => h.networkGroup.startsWith('dss/') ); } /** * Get local/homelab hosts only */ getHomelabHosts(): HostConfig[] { return this.getHostsByGroup('voyager'); } /** * Load the inventory index */ async loadIndex(): Promise { const indexPath = join(this.inventoryPath, 'index.yaml'); if (!existsSync(indexPath)) { return null; } const content = readFileSync(indexPath, 'utf-8'); return parseYaml(content) as InventoryIndex; } /** * Reload the inventory (force refresh) */ async reload(): Promise { this.loaded = false; await this.loadAll(); } /** * Check if inventory is loaded */ isLoaded(): boolean { return this.loaded; } /** * Get inventory path */ getInventoryPath(): string { return this.inventoryPath; } } /** * Create a singleton loader instance */ let defaultLoader: HostInventoryLoader | null = null; /** * Get the default loader instance */ export function getDefaultLoader(inventoryPath?: string): HostInventoryLoader { if (!defaultLoader || inventoryPath) { defaultLoader = new HostInventoryLoader(inventoryPath); } return defaultLoader; } /** * Initialize and load the default inventory */ export async function initializeInventory(inventoryPath?: string): Promise { const loader = getDefaultLoader(inventoryPath); await loader.loadAll(); return loader; }