/** * Deployment Pre-flight Check * * Fast validation that catches deployment-blocking issues BEFORE * spinning up infrastructure. Designed to run in ~100ms, not ~120s. * * Checks: * 1. Module exists and has a valid manifest * 2. All required capabilities have installed providers * 3. All required variables have values (from config, derivation, * or capability chain) * 4. Capability derivation chains resolve to actual values (not * unresolved templates like $self:primary_domain) * 5. Infrastructure is available for the module's zone (machine or * container service exists) * * Does NOT: * - Run template generation * - Run Ansible or Terraform * - Start any containers * - Modify any state */ import { compareConsumerToProvider } from '@celilo/capabilities'; import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { getDb } from '../db/client'; import { capabilities, moduleConfigs, modules, secrets } from '../db/schema'; import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema'; import { isPrivilegedCapability } from '../manifest/validate'; import { buildResolutionContext } from '../variables/context'; import { E2E_CONFLICT_FIX, runningE2eContainers } from './e2e-guard'; import { describeCapabilityProblem, findBrokenCapabilityDerivations } from './fleet-checks'; export interface PreflightResult { success: boolean; moduleId: string; errors: PreflightError[]; warnings: PreflightWarning[]; } export interface PreflightError { category: | 'missing-config' | 'missing-capability' | 'capability-version-mismatch' | 'unresolved-template' | 'no-infrastructure' | 'missing-secret' | 'e2e-conflict'; message: string; variable?: string; suggestion?: string; } export interface PreflightWarning { category: string; message: string; } /** * Run a fast pre-flight check for a module deployment. * * Returns structured errors that describe exactly what's wrong and * how to fix it, without actually attempting the deployment. */ export async function runPreflight( moduleId: string, db: DbClient = getDb(), ): Promise { const errors: PreflightError[] = []; const warnings: PreflightWarning[] = []; // 1. Module exists? const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) { return { success: false, moduleId, errors: [ { category: 'missing-config', message: `Module '${moduleId}' not found`, suggestion: 'Run: celilo module import ', }, ], warnings: [], }; } const manifest = module.manifestData as ModuleManifest; // 1b. Environment: live deploys and the e2e simulator are mutually // exclusive. The deploy refuses this same condition; surfacing it // here means `--preflight` answers "can I deploy right now?" honestly // instead of a false green that dies seconds into the real deploy. const e2eContainers = runningE2eContainers(); if (e2eContainers.length > 0) { errors.push({ category: 'e2e-conflict', message: `e2e test containers are running (${e2eContainers.length}) — live and e2e environments are mutually exclusive`, suggestion: E2E_CONFLICT_FIX, }); } // 2. Required capabilities have providers, and at least one // provider's version is compatible. Multi-provider scenarios // (e.g., zone-scoped dns_registrar with separate internal + // external providers) pass when ANY installed provider matches // the consumer's required major — that's what the runtime // zone-aware lookup actually picks. The audit catches the same // drift after deploy; this surface refuses up front so the // operator gets an actionable error instead of a silent // template-generation against an ABI the provider can't fulfil. if (manifest.requires?.capabilities) { for (const cap of manifest.requires.capabilities) { // Framework-granted privileges (e.g. cross_module_read) aren't // provider-backed — they're gated by the allow-list at import time, // not satisfied by deploying another module. Skip them here. if (isPrivilegedCapability(cap.name)) { continue; } const providers = db .select() .from(capabilities) .where(eq(capabilities.capabilityName, cap.name)) .all(); if (providers.length === 0) { errors.push({ category: 'missing-capability', message: `Required capability '${cap.name}' has no provider installed`, suggestion: `Deploy a module that provides '${cap.name}' first`, }); continue; } let exampleMismatch: { provider: (typeof providers)[number]; reason: string; } | null = null; let anyCompatible = false; for (const p of providers) { const result = compareConsumerToProvider(cap.version, p.version); if (result.compatible) { anyCompatible = true; break; } if (!exampleMismatch) { exampleMismatch = { provider: p, reason: result.reason }; } } if (anyCompatible || !exampleMismatch) continue; const { provider: example, reason } = exampleMismatch; errors.push({ category: 'capability-version-mismatch', message: `Module '${moduleId}' requires ${cap.name}@${cap.version} but ` + `provider '${example.moduleId}' offers ${cap.name}@${example.version}`, suggestion: reason === 'caller_minor_too_old' ? `Upgrade provider '${example.moduleId}' to a version that provides ${cap.name}@${cap.version} or newer` : `The interface major version differs. Update '${moduleId}' to require ${cap.name}@${example.version} (matching the provider's major), or rebuild '${example.moduleId}' against ${cap.name}@${cap.version}.`, }); } } // 3. Required variables have values? const configRows = db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, moduleId)) .all(); const configMap = new Map( configRows.map((c) => [c.key, c.valueJson ? JSON.parse(c.valueJson) : c.value]), ); const secretRows = db.select().from(secrets).where(eq(secrets.moduleId, moduleId)).all(); const secretNames = new Set(secretRows.map((s) => s.name)); if (manifest.variables?.owns) { for (const variable of manifest.variables.owns) { if (!variable.required) continue; // Infrastructure/terraform variables are auto-derived during deploy; // hook-owned variables are discovered by the module's own hooks at // runtime (hook-owned-state D2). None of the three is operator input. if ( variable.source === 'infrastructure' || variable.source === 'terraform' || variable.source === 'hook' ) { continue; } const hasValue = configMap.has(variable.name); const hasDeriveFrom = !!variable.derive_from; const hasDefault = variable.default !== undefined; if (!hasValue && !hasDeriveFrom && !hasDefault) { errors.push({ category: 'missing-config', message: `Required variable '${variable.name}' is not configured`, variable: variable.name, suggestion: `celilo module config set ${moduleId} ${variable.name} `, }); } } } // Check secrets if (manifest.secrets?.declares) { for (const secret of manifest.secrets.declares) { if (!secret.required) continue; const hasSecret = secretNames.has(secret.name); const hasGenerate = !!secret.generate; if (!hasSecret && !hasGenerate) { errors.push({ category: 'missing-secret', message: `Required secret '${secret.name}' is not set`, variable: secret.name, suggestion: `celilo module secret set ${moduleId} ${secret.name} `, }); } } } // 4. Check for unresolved template strings in configured values // This catches the bug where capability derivation chains produce // raw template strings like "$self:primary_domain" instead of // actual values. try { const context = await buildResolutionContext(moduleId, db); for (const [key, value] of Object.entries(context.selfConfig)) { if ( typeof value === 'string' && (value.includes('$self:') || value.includes('$system:') || value.includes('$capability:') || value.includes('$secret:')) ) { errors.push({ category: 'unresolved-template', message: `Variable '${key}' has unresolved template: ${value}`, variable: key, suggestion: `Set it explicitly: celilo module config set ${moduleId} ${key} `, }); } } // 4b. Capability-derived variables resolve to a concrete value. The // scan above only catches values that ARE present-but-templated; a // required `source: capability` var whose chain is broken upstream is // silently DROPPED during derivation (the hasUnresolved guard), so it's // absent from selfConfig entirely and a template `$self:` later dies // with a cryptic "not found". Assert it here against the RESOLVED // capabilities map so the operator gets the named missing link up front // instead of at generate time (ISS-0095 / ISS-0115). for (const problem of findBrokenCapabilityDerivations( moduleId, manifest, context.capabilities, )) { errors.push({ category: problem.reason === 'no-provider' ? 'missing-capability' : 'unresolved-template', message: describeCapabilityProblem(problem), variable: problem.variable, suggestion: problem.reason === 'no-provider' ? `Deploy a module that provides '${problem.capability}', then redeploy '${moduleId}'` : `Redeploy '${problem.capability}'s provider so it populates ${problem.capability}.${problem.path}, then redeploy '${moduleId}'`, }); } } catch (error) { // Resolution context may fail — that's informative too warnings.push({ category: 'resolution', message: `Variable resolution warning: ${error instanceof Error ? error.message : String(error)}`, }); } // 5. Infrastructure availability (for modules that need it) const systemSpec = getSingularSystemSpec(manifest); if (systemSpec) { const zone = systemSpec.zone; if (zone) { // Simplified check: is there a machine or service for the module's zone? // The full infrastructure selector (selectInfrastructure) needs a Module // object, which is more than we want for a fast pre-flight. Instead, // check if the deploy-validation flow would fail at the infrastructure // selection step by looking for machines/services in the zone directly. try { const { listMachines } = await import('./machine-pool'); const machines = await listMachines(); const { listContainerServices } = await import('./container-service'); const services = await listContainerServices(); const hasMachine = machines.some((m) => m.zone === zone || m.earmarkedModule === moduleId); const hasService = services.some((s) => { const zones = s.zones || []; return zones.includes(zone); }); if (!hasMachine && !hasService) { errors.push({ category: 'no-infrastructure', message: `No infrastructure available for zone '${zone}'`, suggestion: `Add a machine: celilo machine add --zone ${zone}\nOr add a container service: celilo service add proxmox`, }); } } catch { // Machine/service queries may fail — non-fatal for preflight } } } return { success: errors.length === 0, moduleId, errors, warnings, }; } /** * Format a preflight result as a human-readable string. */ export function formatPreflightResult(result: PreflightResult): string { if (result.success) { const warnCount = result.warnings.length; return warnCount > 0 ? `Pre-flight check passed with ${warnCount} warning(s)` : 'Pre-flight check passed'; } const lines: string[] = [`Pre-flight check failed for '${result.moduleId}':`, '']; for (const error of result.errors) { lines.push(` ${errorIcon(error.category)} ${error.message}`); if (error.suggestion) { lines.push(` Fix: ${error.suggestion}`); } } if (result.warnings.length > 0) { lines.push(''); for (const warning of result.warnings) { lines.push(` ⚠ ${warning.message}`); } } return lines.join('\n'); } function errorIcon(category: PreflightError['category']): string { switch (category) { case 'missing-config': return '✗'; case 'missing-capability': return '◯'; case 'capability-version-mismatch': return '⚠'; case 'unresolved-template': return '⟲'; case 'no-infrastructure': return '▢'; case 'missing-secret': return '🔑'; case 'e2e-conflict': return '🐳'; } }