- 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>
309 lines
9.1 KiB
TypeScript
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;
|
|
}
|