feat(packages): add host-inventory and registry-integration
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
02ab9cc22b
commit
5cbecba6d7
12 changed files with 1073 additions and 0 deletions
40
@packages/@infrastructure/host-inventory/package.json
Normal file
40
@packages/@infrastructure/host-inventory/package.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
7
@packages/@infrastructure/host-inventory/src/index.ts
Normal file
7
@packages/@infrastructure/host-inventory/src/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* @lilith/host-inventory
|
||||
* Host inventory management for infrastructure
|
||||
*/
|
||||
|
||||
export * from './types.js';
|
||||
export * from './loader.js';
|
||||
309
@packages/@infrastructure/host-inventory/src/loader.ts
Normal file
309
@packages/@infrastructure/host-inventory/src/loader.ts
Normal file
|
|
@ -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<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;
|
||||
}
|
||||
268
@packages/@infrastructure/host-inventory/src/types.ts
Normal file
268
@packages/@infrastructure/host-inventory/src/types.ts
Normal file
|
|
@ -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<string, {
|
||||
description: string;
|
||||
location: string;
|
||||
provider?: string;
|
||||
hosts: string[];
|
||||
}>;
|
||||
hosts: Record<string, {
|
||||
fqdn: string;
|
||||
type: string;
|
||||
role: string;
|
||||
os: string;
|
||||
}>;
|
||||
}
|
||||
21
@packages/@infrastructure/host-inventory/tsconfig.json
Normal file
21
@packages/@infrastructure/host-inventory/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
45
@packages/@infrastructure/registry-integration/package.json
Normal file
45
@packages/@infrastructure/registry-integration/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
23
@packages/@infrastructure/registry-integration/src/index.ts
Normal file
23
@packages/@infrastructure/registry-integration/src/index.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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');
|
||||
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
// 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<ServiceInfo[]> {
|
||||
return this.client.discover(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific service by name, preferring local instances
|
||||
*/
|
||||
async findService(name: string): Promise<ServiceConfig | null> {
|
||||
return this.client.getPreferredInstance(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all instances of a service across hosts
|
||||
*/
|
||||
async findServiceInstances(name: string): Promise<ServiceConfig[]> {
|
||||
return this.client.findServiceInstances(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to status change events
|
||||
*/
|
||||
async subscribe(
|
||||
callback: (event: StatusChangeEvent) => void,
|
||||
): Promise<void> {
|
||||
return this.client.subscribe(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for dependencies to become healthy
|
||||
*/
|
||||
async waitForDependencies(
|
||||
dependencies: string[],
|
||||
timeout?: number,
|
||||
): Promise<void> {
|
||||
return this.client.waitForDependencies(dependencies, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the registry is available
|
||||
*/
|
||||
async isRegistryAvailable(): Promise<boolean> {
|
||||
return this.client.isRegistryAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active hosts in the registry
|
||||
*/
|
||||
async getAllHosts(): Promise<string[]> {
|
||||
return this.client.getAllHosts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger re-registration (useful after recovery)
|
||||
*/
|
||||
async reregister(): Promise<void> {
|
||||
return this.client.reregister();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async configuration factory for RegistryModule
|
||||
*/
|
||||
export interface RegistryModuleAsyncConfig {
|
||||
/**
|
||||
* Factory function that returns the config
|
||||
*/
|
||||
useFactory: (
|
||||
...args: unknown[]
|
||||
) => RegistryModuleConfig | Promise<RegistryModuleConfig>;
|
||||
|
||||
/**
|
||||
* Dependencies to inject into the factory
|
||||
*/
|
||||
inject?: unknown[];
|
||||
|
||||
/**
|
||||
* Modules to import for the factory dependencies
|
||||
*/
|
||||
imports?: unknown[];
|
||||
}
|
||||
21
@packages/@infrastructure/registry-integration/tsconfig.json
Normal file
21
@packages/@infrastructure/registry-integration/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue