/** * Deployment Planning Service * * Determines what deployment steps are needed based on current state */ import { join } from 'node:path'; import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { moduleConfigs, moduleInfrastructure } from '../db/schema'; import type { ModuleManifest } from '../manifest/schema'; export interface DeploymentPlan { needsTerraform: boolean; needsSSHWait: boolean; ansiblePlaybook: string; targetHost: { hostname: string; ip: string; user: string; }; infrastructure?: { type: 'machine' | 'container_service'; machineId?: string; serviceId?: string; }; } /** * Determine what deployment steps are needed * Planning function - analyzes state and returns plan * * - Machine infrastructure: skip Terraform (machine already exists) * - Container service: run Terraform (need to provision container) * * @param moduleId - Module identifier * @param generatedPath - Path to generated artifacts * @param manifest - Module manifest * @param db - Database connection * @returns Deployment plan */ export async function planDeployment( moduleId: string, generatedPath: string, _manifest: ModuleManifest, db: DbClient, ): Promise { const infrastructure = await db .select() .from(moduleInfrastructure) .where(eq(moduleInfrastructure.moduleId, moduleId)) .get(); if (!infrastructure) { throw new Error( `No infrastructure selected for module ${moduleId}. Run 'celilo module generate ${moduleId}' first to select infrastructure (container service or machine) and generate templates.`, ); } let needsTerraform: boolean; let needsSSHWait: boolean; if (infrastructure.infrastructureType === 'machine') { // Machine infrastructure: skip Terraform (machine already exists) // No SSH wait needed (machine already running) needsTerraform = false; needsSSHWait = false; } else if (infrastructure.infrastructureType === 'container_service') { // Container service: run Terraform to provision container needsTerraform = true; needsSSHWait = true; } else { throw new Error(`Unknown infrastructure type: ${infrastructure.infrastructureType}`); } // Extract target host information from module config const targetHost = await extractTargetHost(moduleId, db); return { needsTerraform, needsSSHWait, ansiblePlaybook: join(generatedPath, 'ansible', 'playbook.yml'), targetHost, infrastructure: infrastructure ? { type: infrastructure.infrastructureType as 'machine' | 'container_service', machineId: infrastructure.machineId || undefined, serviceId: infrastructure.serviceId || undefined, } : undefined, }; } /** * Extract target host information from module configuration * Execution function - queries database * * Must be called AFTER infrastructure variable resolution * to pick up infrastructure-derived variables like ip.primary * * @param moduleId - Module identifier * @param db - Database connection * @returns Target host information */ export async function extractTargetHost( moduleId: string, db: DbClient, ): Promise<{ hostname: string; ip: string; user: string }> { // Get module configuration const configs = await db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, moduleId)) .all(); // Build config map. valueJson is JSON-ENCODED — a string value is stored as // `"10.0.20.13/24"` (with quotes), so using it raw and then splitting on '/' // kept the leading quote (`"10.0.20.13`), producing the malformed SSH target // `root@"10.0.20.13`. Parse it (the pattern used everywhere else); fall back // to the plain `value` column. const configMap = new Map(); for (const config of configs) { let value: string | undefined; if (config.valueJson != null) { try { const parsed: unknown = JSON.parse(config.valueJson); value = typeof parsed === 'string' ? parsed : String(parsed); } catch { value = config.value ?? undefined; } } else { value = config.value ?? undefined; } if (value) { configMap.set(config.key, value); } } // Extract hostname const hostname = configMap.get('hostname') || moduleId; // Extract IP - support infrastructure-derived variables // Priority: target_ip > ip.primary > vps_ip (backward compatibility) let ip = ''; const targetIp = configMap.get('target_ip'); const ipPrimary = configMap.get('ip.primary'); const vpsIp = configMap.get('vps_ip'); if (targetIp) { // Target IP format can be "10.0.10.10/24" - extract just IP ip = targetIp.split('/')[0]; } else if (ipPrimary) { // Infrastructure-derived IP (already plain format) ip = ipPrimary; } else if (vpsIp) { ip = vpsIp; } // Extract ansible user (defaults to root) const user = configMap.get('ansible_user') || 'root'; return { hostname, ip, user, }; }