import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { eq } from 'drizzle-orm'; import { stringify as stringifyYaml } from 'yaml'; import type { DbClient } from '../db/client'; import { machines, moduleConfigs, systemConfig } from '../db/schema'; import { getModuleSystems } from '../services/deployed-systems'; import { parseStoredConfigValue } from '../services/module-config'; import { LOCAL_MACHINE_IP, getTempKeyPath } from '../services/ssh-key-manager'; /** * Inventory generation result */ export interface InventoryResult { success: boolean; error?: string; details?: unknown; files?: string[]; } /** * Inventory host definition */ export interface InventoryHost { hostname: string; ansibleHost: string; ansibleUser: string; groups: string[]; ansibleSshPrivateKeyFile?: string; /** * The management box deploying to itself (127.0.0.1) uses Ansible's * local connection — no SSH, no ansible_host/key. See * openspec/specs/bootstrap-meta-package/spec.md. */ local?: boolean; } /** * Generate hosts.ini file in INI format * * Presentation function (Rule 10.1) - formats inventory structure * * @param hosts - Array of inventory host definitions * @returns INI format string */ export function generateHostsIni(hosts: InventoryHost[]): string { const lines: string[] = []; // Group hosts by their groups const groupMap = new Map(); for (const host of hosts) { for (const group of host.groups) { if (!groupMap.has(group)) { groupMap.set(group, []); } groupMap.get(group)?.push(host); } } // Generate INI sections for each group for (const [group, groupHosts] of groupMap) { lines.push(`[${group}]`); for (const host of groupHosts) { if (host.local) { // Local connection: no SSH, no key — Ansible runs commands directly. lines.push(`${host.hostname} ansible_connection=local`); continue; } let hostLine = `${host.hostname} ansible_host=${host.ansibleHost} ansible_user=${host.ansibleUser}`; if (host.ansibleSshPrivateKeyFile) { hostLine += ` ansible_ssh_private_key_file=${host.ansibleSshPrivateKeyFile}`; } lines.push(hostLine); } lines.push(''); // Blank line between groups } // Add [all:vars] section with common variables lines.push('[all:vars]'); lines.push('ansible_python_interpreter=/usr/bin/python3'); // Disable ControlMaster to avoid Python format string issues with ControlPath // ControlPath uses %h, %p, %r placeholders which trigger Python's % formatter lines.push( 'ansible_ssh_common_args=-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ControlMaster=no', ); lines.push(''); return lines.join('\n'); } /** * Generate host_vars YAML file * * Presentation function - formats host variables as YAML * * @param vars - Host variable object (supports primitives, arrays, nested objects) * @param hostname - Optional hostname for comment * @returns YAML format string */ export function generateHostVarsYaml(vars: Record, hostname?: string): string { const yamlContent = stringifyYaml(vars, { indent: 2, lineWidth: 0, // Don't wrap long lines sortMapEntries: true, // Deterministic output for testing }); const comment = hostname ? `# Host-specific variables for ${hostname}` : ''; return `---\n${comment ? `${comment}\n` : ''}${yamlContent}`; } /** * Generate group_vars/all.yml with system configuration * * Presentation function - formats system config as YAML * * @param systemVars - System configuration object * @returns YAML format string */ export function generateGroupVarsYaml(systemVars: Record): string { const yamlContent = stringifyYaml(systemVars, { indent: 2, lineWidth: 0, sortMapEntries: true, }); return `---\n# System-wide variables available to all hosts\n${yamlContent}`; } /** * Parse module config value into appropriate type * * Policy function - normalizes config values * * @param value - Raw config value string * @returns Parsed value (string, number, boolean, or JSON-parsed object/array) */ export function parseConfigValue(value: string): unknown { // Try to parse as JSON (handles arrays, objects, numbers, booleans) try { return JSON.parse(value); } catch { // Not JSON, return as string return value; } } /** * Sort object keys alphabetically (recursively for nested objects) * * Policy function - ensures deterministic output * * @param obj - Object to sort * @returns New object with sorted keys */ function sortObjectKeys(obj: Record): Record { const sorted: Record = {}; const keys = Object.keys(obj).sort(); for (const key of keys) { const value = obj[key]; if (value && typeof value === 'object' && !Array.isArray(value)) { sorted[key] = sortObjectKeys(value as Record); } else { sorted[key] = value; } } return sorted; } /** * Build host variables from module configuration * * Planning function (Rule 10.1) - transforms DB config into host vars structure * * @param moduleId - Module identifier * @param db - Database connection * @returns Host variables object with sorted keys */ export function buildHostVars(moduleId: string, db: DbClient): Record { // Get all module config, ordered by key for deterministic output const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all(); const vars: Record = {}; for (const config of configs) { // Skip inventory-specific keys (handled separately) if (config.key.startsWith('inventory.')) { continue; } // Use shared typed-storage parser — preserves number/boolean/string/ // complex types end-to-end. The two-column logic that used to live // here (valueJson ? parse : parseConfigValue) was a casualty of // Defect 1 and no longer makes sense — every row has valueJson // populated as of the type-fidelity refactor. const parsedValue = parseStoredConfigValue(config); // Store with underscore naming const key = config.key.replace(/\./g, '_'); vars[key] = parsedValue; } // The host's IP is sourced from the deployed-systems model now, not a // `target_ip` config row (openspec/specs/module-systems-addressing/spec.md). Emit it as the // `target_ip` host_var so Ansible templates that reference `{{ target_ip }}` // (zone files, DNS record tasks) keep working. Single-system modules have one. const recordedSystems = getModuleSystems(moduleId, db); if (recordedSystems[0]) { vars.target_ip = recordedSystems[0].ipv4_address; } // Sort keys alphabetically for deterministic YAML output return sortObjectKeys(vars); } /** * Build system variables from system configuration * * Planning function - transforms DB system config into group vars * * @param db - Database connection * @returns System variables object with sorted keys */ export function buildSystemVars(db: DbClient): Record { const configs = db.select().from(systemConfig).all(); const vars: Record = {}; for (const config of configs) { // Convert dot notation to underscore for Ansible variables const key = config.key.replace(/\./g, '_'); vars[key] = parseConfigValue(config.value); } // Sort keys alphabetically for deterministic YAML output return sortObjectKeys(vars); } /** * Extract inventory host definition from module config * * Planning function - extracts inventory metadata from config (auto-derived) * * Uses auto-derived inventory variables from context resolution: * - inventory.hostname (from hostname) * - inventory.ansible_host (from container_ip or vps_ip) * - inventory.ansible_user (defaults to "root") * - inventory.groups (defaults to module ID) * * @param moduleId - Module identifier * @param db - Database connection * @returns Inventory host definition or null if not configured */ export function extractInventoryHost(moduleId: string, db: DbClient): InventoryHost | null { // Get all module config const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all(); const moduleConfig: Record = {}; for (const config of configs) { // Inventory derivation works in string form (hostnames, IPs, group // names). Use the display-string `value` column — it's already the // canonical human-readable form populated by upsertModuleConfig. // For complex types `value` is the JSON-stringified form, which // matches the legacy behavior here. moduleConfig[config.key] = config.value; } // The host's IP now comes from the deployed-systems model, not a `target_ip` // config row (openspec/specs/module-systems-addressing/spec.md). Inject it so the // ansible_host derivation below and the Ansible `{{ target_ip }}` host_var // (zone files, DNS record tasks) resolve. Single-system modules have one. const recordedSystems = getModuleSystems(moduleId, db); if (recordedSystems[0]) { moduleConfig.target_ip = recordedSystems[0].ipv4_address; } // Auto-derive inventory variables (same logic as context.ts) const derived: Record = {}; // Auto-derive hostname from hostname variable if (moduleConfig.hostname) { derived['inventory.hostname'] = moduleConfig.hostname; } // Auto-derive ansible_host from infrastructure variables // Priority: target_ip > ip.primary > vps_ip (for backward compatibility) if (moduleConfig.target_ip) { const slashIndex = moduleConfig.target_ip.indexOf('/'); derived['inventory.ansible_host'] = slashIndex === -1 ? moduleConfig.target_ip : moduleConfig.target_ip.slice(0, slashIndex); } else if (moduleConfig['ip.primary']) { derived['inventory.ansible_host'] = moduleConfig['ip.primary']; } else if (moduleConfig.vps_ip) { derived['inventory.ansible_host'] = moduleConfig.vps_ip; } // Auto-derive ansible_user (default: root) derived['inventory.ansible_user'] = moduleConfig['inventory.ansible_user'] || 'root'; // Auto-derive groups from module ID derived['inventory.groups'] = moduleConfig['inventory.groups'] || moduleId; // Validate required fields const hostname = derived['inventory.hostname']; const ansibleHost = derived['inventory.ansible_host']; const ansibleUser = derived['inventory.ansible_user']; if (!hostname || !ansibleHost || !ansibleUser) { return null; } // Parse groups (could be array already or comma-separated string) let groups: string[] = []; const groupsValue = derived['inventory.groups']; if (groupsValue) { try { // Try parsing as JSON groups = JSON.parse(groupsValue); } catch { // Not JSON, treat as single group groups = [groupsValue]; } } return { hostname, ansibleHost, ansibleUser, groups, }; } /** * Infrastructure selection info (passed from generator) */ export interface InfrastructureInfo { type: 'machine' | 'container_service'; machineId?: string; serviceId?: string; } /** * Generate Ansible inventory structure for a module * * Execution function (Rule 10.1) - performs file I/O * * Enhanced to support both container services and machine pool * - Container services: use placeholder IP (will be updated after Terraform) * - Machines: use machine IP, SSH user, and SSH key path from database * * @param moduleId - Module identifier * @param outputPath - Base output path (e.g., /tmp/celilo/modules/homebridge/generated) * @param db - Database connection * @param infrastructure - Optional infrastructure selection info * @returns Generation result with list of created files */ export async function generateInventory( moduleId: string, outputPath: string, db: DbClient, infrastructure?: InfrastructureInfo, ): Promise { try { const inventoryPath = join(outputPath, 'ansible/inventory'); await mkdir(inventoryPath, { recursive: true }); const createdFiles: string[] = []; let host: InventoryHost | null = null; if (infrastructure?.type === 'machine' && infrastructure.machineId) { // Machine infrastructure: load machine from database const machine = db .select() .from(machines) .where(eq(machines.id, infrastructure.machineId)) .get(); if (!machine) { return { success: false, error: `Machine not found: ${infrastructure.machineId}`, }; } // Get hostname from module config (if available) or use machine hostname const configs = db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, moduleId)) .all(); const moduleHostname = configs.find( (c: typeof moduleConfigs.$inferSelect) => c.key === 'hostname', )?.value; // Build host definition from machine. The local box (127.0.0.1) // uses Ansible's local connection — no SSH key/host. const isLocal = machine.ipAddress === LOCAL_MACHINE_IP; host = { hostname: moduleHostname || machine.hostname, ansibleHost: machine.ipAddress, ansibleUser: machine.sshUser, groups: [moduleId], // Use module ID as group local: isLocal, ansibleSshPrivateKeyFile: isLocal ? undefined : getTempKeyPath(machine.id), }; } else { // Container service or no infrastructure: use module config host = extractInventoryHost(moduleId, db); if (!host) { // No inventory configured - this is not an error, just skip return { success: true, files: [], }; } } // Generate hosts.ini const hostsIni = generateHostsIni([host]); await writeFile(join(inventoryPath, 'hosts.ini'), hostsIni, 'utf-8'); createdFiles.push('ansible/inventory/hosts.ini'); // Generate host_vars/.yml const hostVars = buildHostVars(moduleId, db); // Inject machine architecture if deploying to a machine if (infrastructure?.type === 'machine' && infrastructure.machineId) { const machine = db .select() .from(machines) .where(eq(machines.id, infrastructure.machineId)) .get(); if (machine?.hardware?.arch) { hostVars.target_arch = machine.hardware.arch; } } if (Object.keys(hostVars).length > 0) { const hostVarsPath = join(inventoryPath, 'host_vars'); await mkdir(hostVarsPath, { recursive: true }); const hostVarsYaml = generateHostVarsYaml(hostVars, host.hostname); await writeFile(join(hostVarsPath, `${host.hostname}.yml`), hostVarsYaml, 'utf-8'); createdFiles.push(`ansible/inventory/host_vars/${host.hostname}.yml`); } // Generate group_vars/all.yml with system config const systemVars = buildSystemVars(db); if (Object.keys(systemVars).length > 0) { const groupVarsPath = join(inventoryPath, 'group_vars'); await mkdir(groupVarsPath, { recursive: true }); const groupVarsYaml = generateGroupVarsYaml(systemVars); await writeFile(join(groupVarsPath, 'all.yml'), groupVarsYaml, 'utf-8'); createdFiles.push('ansible/inventory/group_vars/all.yml'); } return { success: true, files: createdFiles, }; } catch (error) { return { success: false, error: 'Failed to generate inventory', details: error, }; } }