platform-codebase/@packages/@infrastructure/host-inventory/src/loader.ts
Quinn Ftw 3a11d35881 chore: update package configs and add type definitions
- Update playwright.config.ts with improved settings
- Update vite-plugin-health.ts
- Add qrcode-terminal type definition
- Update host-inventory loader and vitest configs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 23:11:51 -08:00

309 lines
9.1 KiB
TypeScript

/**
* 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<string, HostConfig> = new Map();
private hostsByGroup: Map<NetworkGroup, HostConfig[]> = 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<void> {
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<void> {
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<HostConfig> {
const content = readFileSync(filePath, 'utf-8');
const raw = parseYaml(content);
return this.transformHost(raw);
}
/**
* Transform raw YAML data to HostConfig
*/
private transformHost(raw: Record<string, unknown>): 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<string, unknown>).name as HostConfig['os']['name'],
version: String((raw.os as Record<string, unknown>).version),
codename: (raw.os as Record<string, unknown>).codename as string | undefined,
family: (raw.os as Record<string, unknown>).family as HostConfig['os']['family'],
immutable: Boolean((raw.os as Record<string, unknown>).immutable),
kernel: (raw.os as Record<string, unknown>).kernel as string | undefined,
},
networkGroup: raw.networkGroup as NetworkGroup,
provider: raw.provider as HostConfig['provider'],
ssh: {
host: (raw.ssh as Record<string, unknown>).host as string,
ip: (raw.ssh as Record<string, unknown>).ip as string | undefined,
ipv6: (raw.ssh as Record<string, unknown>).ipv6 as string | undefined,
user: ((raw.ssh as Record<string, unknown>).user as string) || 'root',
port: ((raw.ssh as Record<string, unknown>).port as number) || 22,
keyRef: (raw.ssh as Record<string, unknown>).keyRef as string | undefined,
proxyJump: (raw.ssh as Record<string, unknown>).proxyJump as string | undefined,
},
capabilities: this.transformCapabilities(raw.capabilities as Record<string, unknown>),
provisioningState: (raw.provisioningState as ProvisioningState) || 'unprovisioned',
provisioningDetails: raw.provisioningDetails as HostConfig['provisioningDetails'],
hostnameMethod: raw.hostnameMethod as HostConfig['hostnameMethod'],
alerts: {
cpuThreshold: ((raw.alerts as Record<string, unknown>)?.cpuThreshold as number) ?? 70,
cpuThresholdDuration: ((raw.alerts as Record<string, unknown>)?.cpuThresholdDuration as number) ?? 10,
memoryThreshold: ((raw.alerts as Record<string, unknown>)?.memoryThreshold as number) ?? 70,
memoryThresholdDuration: ((raw.alerts as Record<string, unknown>)?.memoryThresholdDuration as number) ?? 10,
diskThreshold: ((raw.alerts as Record<string, unknown>)?.diskThreshold as number) ?? 80,
gpuThreshold: (raw.alerts as Record<string, unknown>)?.gpuThreshold as number | undefined,
gpuThresholdDuration: (raw.alerts as Record<string, unknown>)?.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<string, unknown> | 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<K extends keyof HostCapabilities>(
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<InventoryIndex | null> {
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<void> {
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<HostInventoryLoader> {
const loader = getDefaultLoader(inventoryPath);
await loader.loadAll();
return loader;
}