import type { DbClient } from '@/db/client'; import { containerServices, machines, moduleConfigs, moduleInfrastructure } from '@/db/schema'; import { type ProxmoxProviderConfig, extractMachineProperties, extractProxmoxProperties, extractTerraformProperties, } from '@/infrastructure/property-extractor'; import type { ModuleManifest } from '@/manifest/schema'; import { upsertModuleConfig } from '@/services/module-config'; import type { Machine } from '@/types/infrastructure'; import { and, eq } from 'drizzle-orm'; /** * Result of resolving infrastructure variables */ export interface InfrastructureVariableResolution { /** Variables that were resolved (name → value) */ resolved: Record; /** Variables that were skipped (user override or optional missing) */ skipped: string[]; } /** * Resolve infrastructure variables for a module and store in moduleConfigs. * Called during deploy, after Terraform runs (if applicable). * * Resolution priority: * 1. User-configured value (moduleConfigs) - ALWAYS wins * 2. Infrastructure-derived value (from machine, IPAM, or Terraform output) * 3. Required check: throw if required variable has no value * 4. Optional: skip if no value available * * @param moduleId - Module identifier * @param manifest - Module manifest * @param terraformOutputs - Optional Terraform outputs (for Digital Ocean) * @param db - Database connection * @returns Resolution result with resolved and skipped variables */ export async function resolveInfrastructureVariables( moduleId: string, manifest: ModuleManifest, terraformOutputs: Record | null, db: DbClient, ): Promise { // Find infrastructure variables in manifest const infraVars = manifest.variables?.owns?.filter((v) => v.source === 'infrastructure') ?? []; if (infraVars.length === 0) { return { resolved: {}, skipped: [] }; // No infrastructure variables to resolve } // Query infrastructure selection const infraSelection = await db .select() .from(moduleInfrastructure) .where(eq(moduleInfrastructure.moduleId, moduleId)) .get(); if (!infraSelection) { throw new Error( `No infrastructure selected for module ${moduleId}. ` + `Run 'celilo module generate ${moduleId}' first.`, ); } // Extract properties based on infrastructure type let properties: Record; if (infraSelection.infrastructureType === 'machine') { if (!infraSelection.machineId) { throw new Error('Machine infrastructure selected but machineId is null'); } // Load machine from database const machineRow = await db .select() .from(machines) .where(eq(machines.id, infraSelection.machineId)) .get(); if (!machineRow) { throw new Error(`Machine not found: ${infraSelection.machineId}`); } // Cast to Machine type (interfaces zone field is stored as string in DB) const machine: Machine = { ...machineRow, zone: machineRow.zone as Machine['zone'], hardware: machineRow.hardware || { cpu_cores: 0, memory_mb: 0, disk_gb: 0 }, role: (machineRow.role as Machine['role']) || 'host', interfaces: (machineRow.interfaces || []) as Machine['interfaces'], earmarkedModule: machineRow.earmarkedModule ?? undefined, createdAt: new Date(machineRow.createdAt), updatedAt: new Date(machineRow.updatedAt), }; properties = extractMachineProperties(machine); } else if (infraSelection.infrastructureType === 'container_service') { if (!infraSelection.serviceId) { throw new Error('Container service infrastructure selected but serviceId is null'); } // Load container service to get provider config const service = await db .select() .from(containerServices) .where(eq(containerServices.id, infraSelection.serviceId)) .get(); if (!service) { throw new Error(`Container service not found: ${infraSelection.serviceId}`); } // Container service - check provider type if (service.providerName === 'digitalocean') { // Digital Ocean - use Terraform outputs if (!terraformOutputs) { throw new Error( 'Terraform outputs not found for Digital Ocean service. ' + 'Deploy may have failed or outputs not yet available.', ); } const hostname = (await getModuleConfig(moduleId, 'hostname', db)) || moduleId; properties = extractTerraformProperties(terraformOutputs, hostname); } else if (service.providerName === 'proxmox') { // Proxmox - use IPAM allocation + service provider config const vmid = await getModuleConfig(moduleId, 'vmid', db); const targetIp = await getModuleConfig(moduleId, 'target_ip', db); const hostname = (await getModuleConfig(moduleId, 'hostname', db)) || moduleId; if (!vmid || !targetIp) { throw new Error( `IPAM allocation not found for module ${moduleId}. ` + `Run 'celilo module generate ${moduleId}' first.`, ); } // Extract Proxmox provider config from service const providerConfig = service.providerConfig as unknown as ProxmoxProviderConfig; properties = extractProxmoxProperties( Number.parseInt(vmid, 10), targetIp, hostname, providerConfig, ); } else { throw new Error( `Unsupported container service provider: ${service.providerName}. Supported providers: proxmox, digitalocean`, ); } } else { throw new Error(`Unknown infrastructure type: ${infraSelection.infrastructureType}`); } // Resolve each infrastructure variable const resolved: Record = {}; const skipped: string[] = []; // When Terraform just ran, outputs are authoritative — always overwrite stale config. // When using machines (no Terraform), respect user overrides in module_configs. const terraformJustRan = terraformOutputs !== null; for (const variable of infraVars) { // Check if user manually set this variable const userValue = await getModuleConfig(moduleId, variable.name, db); // For machine-based infrastructure, user overrides win (stable infrastructure) // For Terraform-based infrastructure, fresh outputs win (may have been recreated) if (userValue !== null && !terraformJustRan) { resolved[variable.name] = userValue; skipped.push(variable.name); // Track as skipped (user override) continue; } // Variable name IS the property name (no derive_from) const value = properties[variable.name]; if (!value) { if (variable.required) { throw new Error( `Required infrastructure property '${variable.name}' not available ` + `for module '${moduleId}'. Available properties: ${Object.keys(properties).join(', ')}`, ); } skipped.push(variable.name); // Track as skipped (optional, no value) continue; } // Store in moduleConfigs (always via the typed-storage helper) upsertModuleConfig(db, moduleId, variable.name, value); resolved[variable.name] = value; } return { resolved, skipped }; } /** * Get module configuration value from database. * Returns null if not found. * * @param moduleId - Module identifier * @param key - Configuration key * @param db - Database connection * @returns Configuration value or null */ async function getModuleConfig( moduleId: string, key: string, db: DbClient, ): Promise { const result = await db .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, key))) .get(); return result?.value ?? null; }