From 5cbecba6d78adc1087ef277128bcd84439331260 Mon Sep 17 00:00:00 2001 From: Quinn Ftw Date: Fri, 26 Dec 2025 00:37:33 -0800 Subject: [PATCH] feat(packages): add host-inventory and registry-integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - host-inventory: Fleet management with YAML config loader - registry-integration: NestJS module for service registry 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../host-inventory/package.json | 40 +++ .../host-inventory/src/index.ts | 7 + .../host-inventory/src/loader.ts | 309 ++++++++++++++++++ .../host-inventory/src/types.ts | 268 +++++++++++++++ .../host-inventory/tsconfig.json | 21 ++ .../registry-integration/package.json | 45 +++ .../registry-integration/src/index.ts | 23 ++ .../src/registry.constants.ts | 9 + .../src/registry.module.ts | 83 +++++ .../src/registry.service.ts | 184 +++++++++++ .../src/registry.types.ts | 63 ++++ .../registry-integration/tsconfig.json | 21 ++ 12 files changed, 1073 insertions(+) create mode 100644 @packages/@infrastructure/host-inventory/package.json create mode 100644 @packages/@infrastructure/host-inventory/src/index.ts create mode 100644 @packages/@infrastructure/host-inventory/src/loader.ts create mode 100644 @packages/@infrastructure/host-inventory/src/types.ts create mode 100644 @packages/@infrastructure/host-inventory/tsconfig.json create mode 100644 @packages/@infrastructure/registry-integration/package.json create mode 100644 @packages/@infrastructure/registry-integration/src/index.ts create mode 100644 @packages/@infrastructure/registry-integration/src/registry.constants.ts create mode 100644 @packages/@infrastructure/registry-integration/src/registry.module.ts create mode 100644 @packages/@infrastructure/registry-integration/src/registry.service.ts create mode 100644 @packages/@infrastructure/registry-integration/src/registry.types.ts create mode 100644 @packages/@infrastructure/registry-integration/tsconfig.json diff --git a/@packages/@infrastructure/host-inventory/package.json b/@packages/@infrastructure/host-inventory/package.json new file mode 100644 index 000000000..9cbb3a42a --- /dev/null +++ b/@packages/@infrastructure/host-inventory/package.json @@ -0,0 +1,40 @@ +{ + "name": "@lilith/host-inventory", + "version": "1.0.0", + "description": "Host inventory loader for infrastructure management", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "clean": "rimraf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "yaml": "^2.3.4", + "ajv": "^8.12.0" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "rimraf": "^5.0.5", + "typescript": "^5.3.0" + }, + "files": [ + "dist", + "src" + ], + "keywords": [ + "infrastructure", + "host-inventory", + "yaml", + "lilith" + ] +} diff --git a/@packages/@infrastructure/host-inventory/src/index.ts b/@packages/@infrastructure/host-inventory/src/index.ts new file mode 100644 index 000000000..e7f02ee70 --- /dev/null +++ b/@packages/@infrastructure/host-inventory/src/index.ts @@ -0,0 +1,7 @@ +/** + * @lilith/host-inventory + * Host inventory management for infrastructure + */ + +export * from './types.js'; +export * from './loader.js'; diff --git a/@packages/@infrastructure/host-inventory/src/loader.ts b/@packages/@infrastructure/host-inventory/src/loader.ts new file mode 100644 index 000000000..ebfd456a2 --- /dev/null +++ b/@packages/@infrastructure/host-inventory/src/loader.ts @@ -0,0 +1,309 @@ +/** + * Host Inventory Loader + * Loads and manages host configuration from YAML files + */ + +import { readFileSync, readdirSync, statSync, 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; +} diff --git a/@packages/@infrastructure/host-inventory/src/types.ts b/@packages/@infrastructure/host-inventory/src/types.ts new file mode 100644 index 000000000..024b8f7b5 --- /dev/null +++ b/@packages/@infrastructure/host-inventory/src/types.ts @@ -0,0 +1,268 @@ +/** + * Host Inventory Types + * Defines the structure for infrastructure host configuration + */ + +/** + * Supported operating system types + */ +export type OSName = + | 'debian' + | 'ubuntu' + | 'fedora-atomic' + | 'fedora' + | 'rhel' + | 'centos' + | 'rocky' + | 'alma'; + +/** + * OS family determines package management and configuration approach + */ +export type OSFamily = 'debian' | 'rhel' | 'atomic'; + +/** + * Operating system configuration + */ +export interface HostOS { + /** Distribution name */ + name: OSName; + /** Version string (e.g., "12", "24.04", "EL10") */ + version: string; + /** Codename (e.g., "bookworm", "noble", "bluefin") */ + codename?: string; + /** OS family for package management */ + family: OSFamily; + /** Whether /etc is immutable (Fedora Atomic, etc.) */ + immutable: boolean; + /** Kernel version if known */ + kernel?: string; +} + +/** + * SSH connection configuration + */ +export interface HostSSH { + /** SSH hostname or IP address */ + host: string; + /** Static IPv4 address */ + ip?: string; + /** IPv6 address if available */ + ipv6?: string; + /** SSH username */ + user: string; + /** SSH port (default: 22) */ + port: number; + /** Reference to vault key (e.g., 'vault://ssh-keys/id_ed25519_1984') */ + keyRef?: string; + /** ProxyJump host for bastion access */ + proxyJump?: string; +} + +/** + * Host capabilities and features + */ +export interface HostCapabilities { + /** Has GPU(s) available */ + gpu: boolean; + /** GPU model description */ + gpuModel?: string; + /** Has database server running */ + database: boolean; + /** Database type if applicable */ + databaseType?: 'postgresql' | 'mysql' | 'mariadb' | 'sqlite'; + /** Has significant storage capacity */ + storage: boolean; + /** Storage capacity in GB */ + storageCapacityGB?: number; + /** Acts as VPN gateway */ + vpnGateway: boolean; + /** Acts as DNS server */ + dnsServer: boolean; + /** List of services running on host */ + services: string[]; +} + +/** + * Provisioning state of the host + */ +export type ProvisioningState = + | 'unprovisioned' // Fresh OS install only + | 'minimal' // SSH access, basic packages + | 'partial' // Some services installed + | 'full' // All expected services running + | 'degraded'; // Previously full, now has issues + +/** + * Provisioning details + */ +export interface ProvisioningDetails { + /** Last provisioning timestamp */ + lastProvisioned?: Date; + /** Last discovery timestamp */ + lastDiscovered?: Date; + /** Installed packages */ + packages?: string[]; + /** Currently running services */ + servicesRunning?: string[]; + /** Expected but missing services */ + servicesMissing?: string[]; +} + +/** + * Method for configuring hostname on the host + */ +export type HostnameMethod = + | 'hostnamectl-only' // Fedora Atomic - immutable /etc + | 'etc-hostname' // Traditional Debian/Ubuntu + | 'cloud-init'; // Cloud-init managed VPS + +/** + * Alert threshold configuration + */ +export interface AlertThresholds { + /** CPU usage threshold percentage */ + cpuThreshold: number; + /** Minutes before CPU alert triggers */ + cpuThresholdDuration: number; + /** Memory usage threshold percentage */ + memoryThreshold: number; + /** Minutes before memory alert triggers */ + memoryThresholdDuration: number; + /** Disk usage threshold percentage */ + diskThreshold: number; + /** GPU usage threshold percentage (if applicable) */ + gpuThreshold?: number; + /** Minutes before GPU alert triggers */ + gpuThresholdDuration?: number; +} + +/** + * VPN configuration + */ +export interface VPNConfig { + /** VPN enabled on this host */ + enabled: boolean; + /** VPN IP address (e.g., 10.9.0.1) */ + ip?: string; + /** VPN subnet (e.g., 10.9.0.0/24) */ + subnet?: string; + /** This host is a VPN gateway */ + isGateway: boolean; + /** SOCKS5 proxy URL for routing */ + proxyUrl?: string; +} + +/** + * Agent configuration for monitoring + */ +export interface AgentConfig { + /** Agent enabled on this host */ + enabled: boolean; + /** Status dashboard server URL */ + serverUrl?: string; + /** Metrics collection interval in milliseconds */ + collectInterval: number; + /** mTLS configuration */ + mtls?: { + enabled: boolean; + certRef?: string; + keyRef?: string; + caRef?: string; + }; +} + +/** + * Provider information + */ +export interface ProviderInfo { + /** Provider name */ + name: 'homelab' | '1984-hosting' | 'swisslayer' | 'digitalocean' | 'hetzner' | 'vultr'; + /** Geographic region */ + region?: string; + /** Data center location */ + dataCenter?: string; +} + +/** + * Network group types + */ +export type NetworkGroup = 'voyager' | 'dss/1984' | 'dss/swisslayer'; + +/** + * Complete host configuration + */ +export interface HostConfig { + /** Unique identifier (e.g., "apricot", "platform-vps-0") */ + id: string; + /** Short hostname (e.g., "apricot", "0") */ + hostname: string; + /** Fully qualified domain name */ + fqdn: string; + /** Human-readable display name */ + displayName: string; + /** Operating system configuration */ + os: HostOS; + /** Network group for categorization */ + networkGroup: NetworkGroup; + /** Provider information */ + provider?: ProviderInfo; + /** SSH connection configuration */ + ssh: HostSSH; + /** Host capabilities */ + capabilities: HostCapabilities; + /** Current provisioning state */ + provisioningState: ProvisioningState; + /** Provisioning details */ + provisioningDetails?: ProvisioningDetails; + /** Method for configuring hostname */ + hostnameMethod: HostnameMethod; + /** Alert thresholds */ + alerts: AlertThresholds; + /** VPN configuration */ + vpn?: VPNConfig; + /** Monitoring agent configuration */ + agent?: AgentConfig; + /** Arbitrary tags for filtering */ + tags: string[]; + /** Freeform notes */ + notes?: string; +} + +/** + * Host discovery result from probe script + */ +export interface DiscoveryResult { + os: HostOS; + capabilities: HostCapabilities; + network: { + hostname: string; + fqdn: string; + primaryIp: string; + vpnIp?: string; + }; + hostnameMethod: HostnameMethod; + discoveredAt: string; +} + +/** + * Inventory index structure + */ +export interface InventoryIndex { + schema: { + version: string; + path: string; + }; + networkGroups: Record; + hosts: Record; +} diff --git a/@packages/@infrastructure/host-inventory/tsconfig.json b/@packages/@infrastructure/host-inventory/tsconfig.json new file mode 100644 index 000000000..e754ba0c1 --- /dev/null +++ b/@packages/@infrastructure/host-inventory/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/@packages/@infrastructure/registry-integration/package.json b/@packages/@infrastructure/registry-integration/package.json new file mode 100644 index 000000000..17a17e364 --- /dev/null +++ b/@packages/@infrastructure/registry-integration/package.json @@ -0,0 +1,45 @@ +{ + "name": "@lilith/registry-integration", + "version": "1.0.0", + "private": true, + "description": "Service registry integration module for NestJS applications", + "author": { + "name": "QuinnFTW", + "email": "TransQuinnFTW@pm.me", + "url": "https://github.com/transquinnftw" + }, + "repository": { + "type": "git", + "url": "https://github.com/transquinnftw/lilith-platform.git" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "import": "./dist/index.js" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "build": "tsc", + "prepublish": "pnpm build", + "test": "vitest run --passWithNoTests", + "lint": "eslint . --ext ts" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@service-registry/client": "workspace:*", + "@service-registry/types": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tslib": "^2.6.0", + "typescript": "^5.0.0", + "vitest": "^2.0.0" + }, + "peerDependencies": { + "@nestjs/core": "^10.0.0" + } +} diff --git a/@packages/@infrastructure/registry-integration/src/index.ts b/@packages/@infrastructure/registry-integration/src/index.ts new file mode 100644 index 000000000..52bf766a8 --- /dev/null +++ b/@packages/@infrastructure/registry-integration/src/index.ts @@ -0,0 +1,23 @@ +// Module +export { RegistryModule } from './registry.module'; + +// Service +export { RegistryService } from './registry.service'; + +// Types +export type { + RegistryModuleConfig, + RegistryModuleAsyncConfig, +} from './registry.types'; + +// Constants (for advanced use cases) +export { REGISTRY_CONFIG, REGISTRY_CLIENT } from './registry.constants'; + +// Re-export commonly used types from @service-registry/types +export type { + ServiceConfig, + ServiceInfo, + ServiceStatus, + StatusChangeEvent, + ServiceDiscoveryRequest, +} from '@service-registry/types'; diff --git a/@packages/@infrastructure/registry-integration/src/registry.constants.ts b/@packages/@infrastructure/registry-integration/src/registry.constants.ts new file mode 100644 index 000000000..21091d5e9 --- /dev/null +++ b/@packages/@infrastructure/registry-integration/src/registry.constants.ts @@ -0,0 +1,9 @@ +/** + * Injection token for registry module configuration + */ +export const REGISTRY_CONFIG = Symbol('REGISTRY_CONFIG'); + +/** + * Injection token for the registry client instance + */ +export const REGISTRY_CLIENT = Symbol('REGISTRY_CLIENT'); diff --git a/@packages/@infrastructure/registry-integration/src/registry.module.ts b/@packages/@infrastructure/registry-integration/src/registry.module.ts new file mode 100644 index 000000000..c628a2a46 --- /dev/null +++ b/@packages/@infrastructure/registry-integration/src/registry.module.ts @@ -0,0 +1,83 @@ +import { Module, DynamicModule, Global, type InjectionToken, type OptionalFactoryDependency } from '@nestjs/common'; +import { RegistryService } from './registry.service'; +import { REGISTRY_CONFIG } from './registry.constants'; +import type { + RegistryModuleConfig, + RegistryModuleAsyncConfig, +} from './registry.types'; + +/** + * NestJS module for service registry integration. + * + * Provides automatic service registration, discovery, and health monitoring. + * + * @example + * ```typescript + * @Module({ + * imports: [ + * RegistryModule.forRoot({ + * name: 'my-service', + * type: 'api', + * healthEndpoint: '/health', + * metadata: { + * version: '1.0.0', + * capabilities: ['feature-a', 'feature-b'], + * }, + * }), + * ], + * }) + * export class AppModule {} + * ``` + */ +@Global() +@Module({}) +export class RegistryModule { + /** + * Register with static configuration + */ + static forRoot(config: RegistryModuleConfig): DynamicModule { + return { + module: RegistryModule, + providers: [ + { + provide: REGISTRY_CONFIG, + useValue: config, + }, + RegistryService, + ], + exports: [RegistryService], + }; + } + + /** + * Register with async configuration (factory pattern) + * + * @example + * ```typescript + * RegistryModule.forRootAsync({ + * imports: [ConfigModule], + * inject: [ConfigService], + * useFactory: (configService: ConfigService) => ({ + * name: configService.get('SERVICE_NAME'), + * type: 'api', + * port: configService.get('PORT'), + * }), + * }) + * ``` + */ + static forRootAsync(options: RegistryModuleAsyncConfig): DynamicModule { + return { + module: RegistryModule, + imports: options.imports as any[] || [], + providers: [ + { + provide: REGISTRY_CONFIG, + useFactory: options.useFactory, + inject: (options.inject || []) as (InjectionToken | OptionalFactoryDependency)[], + }, + RegistryService, + ], + exports: [RegistryService], + }; + } +} diff --git a/@packages/@infrastructure/registry-integration/src/registry.service.ts b/@packages/@infrastructure/registry-integration/src/registry.service.ts new file mode 100644 index 000000000..a6ef85e82 --- /dev/null +++ b/@packages/@infrastructure/registry-integration/src/registry.service.ts @@ -0,0 +1,184 @@ +import { + Injectable, + Inject, + OnModuleInit, + OnModuleDestroy, + Logger, +} from '@nestjs/common'; +import { RegistryClient } from '@service-registry/client'; +import type { + ServiceConfig, + ServiceInfo, + ServiceDiscoveryRequest, + StatusChangeEvent, +} from '@service-registry/types'; +import { REGISTRY_CONFIG } from './registry.constants'; +import type { RegistryModuleConfig } from './registry.types'; + +/** + * Service that wraps the RegistryClient for NestJS dependency injection. + * Handles automatic registration on module init and deregistration on destroy. + */ +@Injectable() +export class RegistryService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(RegistryService.name); + private client: RegistryClient; + private allocatedPort?: number; + private isRegistered = false; + + constructor( + @Inject(REGISTRY_CONFIG) private readonly config: RegistryModuleConfig, + ) { + // Use SERVICE_REGISTRY_URL env or default to services.nasty.sh + const url = process.env.SERVICE_REGISTRY_URL || 'https://services.nasty.sh'; + this.logger.log(`Initializing registry client for ${url}`); + this.client = new RegistryClient(url); + } + + async onModuleInit(): Promise { + // Skip registration if disabled via env + if (process.env.REGISTRY_DISABLED === 'true') { + this.logger.warn('Service registry integration is disabled'); + return; + } + + try { + await this.registerService(); + } catch (error) { + // Log error but don't fail startup - registry may be temporarily unavailable + this.logger.error( + `Failed to register with service registry: ${(error as Error).message}`, + ); + this.logger.warn('Service will continue without registry registration'); + } + } + + async onModuleDestroy(): Promise { + if (this.isRegistered) { + try { + await this.client.deregister(); + this.logger.log(`Deregistered ${this.config.name} from service registry`); + } catch (error) { + this.logger.error( + `Failed to deregister: ${(error as Error).message}`, + ); + } + } + } + + private async registerService(): Promise { + // Request port if dynamic allocation is enabled (no port provided) + if (!this.config.port && this.config.type) { + this.logger.log(`Requesting port allocation for ${this.config.name}`); + this.allocatedPort = await this.client.requestPort({ + name: this.config.name, + type: this.config.type, + primary: this.config.primary, + }); + this.logger.log(`Allocated port ${this.allocatedPort} for ${this.config.name}`); + } + + const port = this.config.port || this.allocatedPort; + if (!port) { + throw new Error('No port configured and port allocation failed'); + } + + const serviceConfig: ServiceConfig = { + name: this.config.name, + type: this.config.type, + port, + healthEndpoint: this.config.healthEndpoint || '/health', + dependencies: this.config.dependencies, + metadata: this.config.metadata, + }; + + await this.client.register(serviceConfig); + this.isRegistered = true; + this.logger.log(`Registered ${this.config.name} with service registry`); + + // Enable health monitoring for auto-recovery + this.client.enableHealthMonitoring(30000); + } + + /** + * Get the port allocated by the registry (if dynamic allocation was used) + */ + getAllocatedPort(): number | undefined { + return this.allocatedPort; + } + + /** + * Get the effective port (configured or allocated) + */ + getPort(): number | undefined { + return this.config.port || this.allocatedPort; + } + + /** + * Check if the service is registered with the registry + */ + isServiceRegistered(): boolean { + return this.isRegistered; + } + + /** + * Discover services with hierarchy-aware search + */ + async discover(request: ServiceDiscoveryRequest): Promise { + return this.client.discover(request); + } + + /** + * Find a specific service by name, preferring local instances + */ + async findService(name: string): Promise { + return this.client.getPreferredInstance(name); + } + + /** + * Find all instances of a service across hosts + */ + async findServiceInstances(name: string): Promise { + return this.client.findServiceInstances(name); + } + + /** + * Subscribe to status change events + */ + async subscribe( + callback: (event: StatusChangeEvent) => void, + ): Promise { + return this.client.subscribe(callback); + } + + /** + * Wait for dependencies to become healthy + */ + async waitForDependencies( + dependencies: string[], + timeout?: number, + ): Promise { + return this.client.waitForDependencies(dependencies, timeout); + } + + /** + * Check if the registry is available + */ + async isRegistryAvailable(): Promise { + return this.client.isRegistryAvailable(); + } + + /** + * Get all active hosts in the registry + */ + async getAllHosts(): Promise { + return this.client.getAllHosts(); + } + + /** + * Manually trigger re-registration (useful after recovery) + */ + async reregister(): Promise { + return this.client.reregister(); + } +} diff --git a/@packages/@infrastructure/registry-integration/src/registry.types.ts b/@packages/@infrastructure/registry-integration/src/registry.types.ts new file mode 100644 index 000000000..25ccdcfd2 --- /dev/null +++ b/@packages/@infrastructure/registry-integration/src/registry.types.ts @@ -0,0 +1,63 @@ +/** + * Configuration for the RegistryModule + */ +export interface RegistryModuleConfig { + /** + * Service name to register with the registry + */ + name: string; + + /** + * Service type for port allocation grouping + * 'ui' | 'api' | 'infra' | 'ws' | 'web' + */ + type?: string; + + /** + * Fixed port to use (if not using dynamic allocation) + */ + port?: number; + + /** + * Whether this is the primary web service (exclusive port slot) + */ + primary?: boolean; + + /** + * Health check endpoint path + * @default '/health' + */ + healthEndpoint?: string; + + /** + * Service dependencies that must be healthy before startup + */ + dependencies?: string[]; + + /** + * Additional metadata to register with the service + */ + metadata?: Record; +} + +/** + * Async configuration factory for RegistryModule + */ +export interface RegistryModuleAsyncConfig { + /** + * Factory function that returns the config + */ + useFactory: ( + ...args: unknown[] + ) => RegistryModuleConfig | Promise; + + /** + * Dependencies to inject into the factory + */ + inject?: unknown[]; + + /** + * Modules to import for the factory dependencies + */ + imports?: unknown[]; +} diff --git a/@packages/@infrastructure/registry-integration/tsconfig.json b/@packages/@infrastructure/registry-integration/tsconfig.json new file mode 100644 index 000000000..b57f6e7e2 --- /dev/null +++ b/@packages/@infrastructure/registry-integration/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}