/** * Proxmox State Recovery Service * * Handles state drift scenarios where Terraform state and module_configs are out of sync. * This typically occurs when containers are deleted outside Terraform and then recreated. */ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { and, eq } from 'drizzle-orm'; import { log } from '../cli/prompts'; import type { DbClient } from '../db/client'; import { moduleConfigs } from '../db/schema'; import { upsertModuleConfig } from './module-config'; /** * Terraform state file structure (subset we care about) */ interface TerraformState { resources?: Array<{ type: string; name: string; instances?: Array<{ attributes?: { vmid?: number; // proxmox_lxc carries the IP here ("10.0.10.12/24" or "dhcp"). network?: Array<{ ip?: string; }>; // proxmox_vm_qemu carries the cloud-init IP here ("ip=10.0.10.12/24,gw=…"). ipconfig0?: string; }; }>; }>; } /** * Extract the CIDR from a qemu cloud-init `ipconfig0` string * ("ip=10.0.10.12/24,gw=10.0.10.1" → "10.0.10.12/24"). Returns undefined for a * DHCP config or anything without an explicit `ip=`. */ function extractIpFromIpconfig(ipconfig0: string | undefined): string | undefined { if (!ipconfig0) return undefined; const match = ipconfig0.match(/(?:^|,)ip=([^,]+)/); const value = match?.[1]; return value && value !== 'dhcp' ? value : undefined; } /** * Ensure module_configs has vmid and target_ip from Terraform state * This recovers from state drift scenarios where container was deleted/recreated * * @param moduleId - Module identifier * @param terraformDir - Terraform working directory * @param db - Database connection */ export async function ensureProxmoxConfigFromState( moduleId: string, terraformDir: string, db: DbClient, ): Promise { // Check if vmid and target_ip already exist const existingVmid = await db .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, 'vmid'))) .get(); const existingContainerIp = await db .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, 'target_ip'))) .get(); // If both exist, no recovery needed if (existingVmid && existingContainerIp) { return; } // Read Terraform state file const statePath = join(terraformDir, 'terraform.tfstate'); let stateContent: string; try { stateContent = await readFile(statePath, 'utf-8'); } catch (error) { throw new Error( `Failed to read Terraform state for recovery: ${error instanceof Error ? error.message : 'Unknown error'}`, ); } let state: TerraformState; try { state = JSON.parse(stateContent) as TerraformState; } catch (error) { throw new Error( `Failed to parse Terraform state: ${error instanceof Error ? error.message : 'Unknown error'}`, ); } // Find the provisioned guest: an LXC (proxmox_lxc) or a VM (proxmox_vm_qemu). const guest = state.resources?.find( (r) => r.type === 'proxmox_lxc' || r.type === 'proxmox_vm_qemu', ); if (!guest || !guest.instances || guest.instances.length === 0) { throw new Error('No proxmox_lxc or proxmox_vm_qemu resource found in Terraform state'); } const attributes = guest.instances[0].attributes; if (!attributes) { throw new Error(`No attributes found in ${guest.type} resource`); } const vmid = attributes.vmid; // LXC exposes the IP at network[0].ip; a qemu VM exposes it via the cloud-init // ipconfig0 ("ip=,gw="). Use whichever the guest type provides. const containerIp = attributes.network?.[0]?.ip ?? extractIpFromIpconfig(attributes.ipconfig0); if (!vmid) { throw new Error('vmid not found in Terraform state'); } if (!containerIp) { throw new Error('container IP not found in Terraform state'); } // Store in module_configs (recovery) log.warn(' Recovering vmid and target_ip from Terraform state...'); if (!existingVmid) { upsertModuleConfig(db, moduleId, 'vmid', vmid); } if (!existingContainerIp) { upsertModuleConfig(db, moduleId, 'target_ip', containerIp); } log.success(` Recovered: vmid=${vmid}, target_ip=${containerIp}`); }