/** * Deployment Validation & Auto-Preparation Service * * Validates module readiness and auto-prepares (generate/build) if needed */ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { compareConsumerToProvider } from '@celilo/capabilities'; import { and, eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { capabilities, moduleConfigs, modules } from '../db/schema'; import type { ModuleManifest } from '../manifest/schema'; import { isPrivilegedCapability } from '../manifest/validate'; import { generateTemplates } from '../templates/generator'; import { findMissingSecrets } from './config-interview'; export interface ValidationResult { success: boolean; error?: string; warnings?: string[]; autoGenerated?: boolean; missingVariables?: Array<{ name: string; source: 'user' | 'secret' | 'capability' | 'system'; description?: string; derive_from?: string; type?: string; options?: Array<{ value: string; label: string; hint?: string }>; per_selection?: { key_pattern: string; prompt: string; type?: string; derive_from?: string }; generate?: { method: string; length: number; encoding: string }; /** For `type: string-map` only — labels shown in the add-loop prompt. */ key_label?: string; value_label?: string; key_pattern?: string; key_pattern_message?: string; value_pattern?: string; value_pattern_message?: string; }>; } /** * Declared build artifacts that are missing on disk. * * Modules are built when their package is created: `module package` / * `module publish` runs `manifest.build.command` and bakes the declared * artifacts into the .netapp (cross-arch binaries and all). By deploy time — * from the registry in production, or via `celilo package` in e2e — those * artifacts are already on disk, so deploy only VERIFIES them. An empty result * means the module is built; a non-empty result is a hard deploy error, because * the management server does NOT build from source (ISS-0131: a from-source * rebuild on the 3.7 GB management box deadlocked at `bun install`, then * OOM-crashed nx workers, despite the .netapp shipping the binaries). */ export function findMissingBuildArtifacts(manifest: ModuleManifest, sourcePath: string): string[] { const declared = manifest.build?.artifacts ?? []; return declared.filter((rel) => !existsSync(join(sourcePath, rel))); } /** * Validate module is ready for deployment and auto-prepare if needed * Policy + Execution function - checks prerequisites and runs generate if needed * * @param moduleId - Module identifier * @param db - Database connection * @returns Validation result with auto-prepare flags */ export async function validateAndPrepareDeployment( moduleId: string, db: DbClient, ): Promise { let autoGenerated = false; // Check module exists const module = await db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) { return { success: false, error: `Module '${moduleId}' not found. Run: celilo module import `, }; } const manifest = module.manifestData as ModuleManifest; // Check capability dependencies first — no point interviewing or generating // if required provider modules aren't deployed yet. if (manifest.requires?.capabilities) { const missingCapabilities = await findMissingCapabilities(manifest.requires.capabilities, db); if (missingCapabilities.length > 0) { const capList = missingCapabilities.join(', '); return { success: false, error: `Missing required capabilities: ${capList}\nDeploy provider modules first.\n\nSuggested order:\n${getSuggestedDeploymentOrder(missingCapabilities, moduleId)}`, }; } // Provider exists — verify the provider's claimed capability version is // compatible with what this module declares it requires. Catches the // class of break where a capability had a major bump (e.g. dns_registrar // 4.0.0 → 5.0.0) and the consumer was rebuilt against the new major but // the provider on this system still ships the old one (or vice-versa). const versionMismatches = await findCapabilityVersionMismatches( manifest.requires.capabilities, db, ); if (versionMismatches.length > 0) { return { success: false, error: formatVersionMismatchError(moduleId, versionMismatches), }; } } // Check for missing required variables BEFORE generating — if any are missing // we return them for the caller to interview, then the caller re-invokes deploy // after the user has answered. This prevents generation from failing mid-way // on an unresolved $self: variable. const missingVariables = await findMissingRequiredVariables(moduleId, manifest, db); if (missingVariables.length > 0) { return { success: true, autoGenerated: false, missingVariables, }; } // All variables present — safe to generate templates now. const generatedPath = getGeneratedPath(module.sourcePath, moduleId); const generateResult = await generateTemplates({ moduleId, modulePath: module.sourcePath, outputPath: generatedPath, db, skipVariableValidation: true, }); if (!generateResult.success) { return { success: false, error: `Generation failed: ${generateResult.error || 'Unknown error'}`, }; } autoGenerated = true; // Verify build artifacts are present — the management server does NOT build // modules from source (see findMissingBuildArtifacts / ISS-0131). const missingArtifacts = findMissingBuildArtifacts(manifest, module.sourcePath); if (missingArtifacts.length > 0) { return { success: false, error: `Module '${moduleId}' is missing build artifacts that must ship in its package:\n${missingArtifacts.map((m) => ` - ${m}`).join('\n')}\n\nThe management server does not build modules from source. Build where the module is authored, then republish and update:\n celilo module publish (or: celilo module build ${moduleId})\n celilo module update`, }; } return { success: true, autoGenerated, }; } /** * Find capabilities that are required but not provided by deployed modules * Policy function - checks database for capability providers * * @param required - Required capabilities from manifest * @param db - Database connection * @returns Array of missing capability names */ async function findMissingCapabilities( required: Array<{ name: string; version: string }>, db: DbClient, ): Promise { const missing: string[] = []; for (const cap of required) { // 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 isProvided = await isCapabilityProvided(cap.name, db); if (!isProvided) { missing.push(cap.name); } } return missing; } interface CapabilityVersionMismatch { capabilityName: string; requiredVersion: string; providedVersion: string; providerModuleId: string; reason: string; } /** * Find capabilities whose installed providers can't satisfy what the * consumer module's manifest declares it requires. * * Multi-provider semantics: a capability can have several installed * providers (e.g., zone-scoped firewalls, or `dns_registrar` split * across an internal-zone provider like knot-unbound-internal and an * external-zone provider like namecheap). The deploy passes when at * LEAST ONE provider is version-compatible with the consumer — that * matches the runtime behaviour of `findCapabilityProvider`, which * picks the right provider per zone. The mismatch error names the * "best" (closest-version) incompatible provider so the operator * sees an actionable suggestion. * * Uses `compareConsumerToProvider` from `@celilo/capabilities`, the * same helper that powers the system audit's capability_abi check — * so deploy and audit verdicts stay in lockstep. */ async function findCapabilityVersionMismatches( required: Array<{ name: string; version: string }>, db: DbClient, ): Promise { const mismatches: CapabilityVersionMismatch[] = []; for (const cap of required) { const providers = await db .select() .from(capabilities) .where(eq(capabilities.capabilityName, cap.name)) .all(); if (providers.length === 0) continue; // Missing-provider case is handled separately. // If any provider is compatible, the consumer is fine — runtime's // zone-aware lookup will pick that one for the relevant zone. // Otherwise, capture the first provider's mismatch as the canonical // example for the error message. let exampleMismatch: { provider: { version: string; moduleId: string }; 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; mismatches.push({ capabilityName: cap.name, requiredVersion: cap.version, providedVersion: exampleMismatch.provider.version, providerModuleId: exampleMismatch.provider.moduleId, reason: exampleMismatch.reason, }); } return mismatches; } /** * Format a version-mismatch error message for the deploy refusal. * * The message names the consumer, the requirement, the actual provider * version, and an actionable next step — so an operator can resolve the * break without reading the source of `compareConsumerToProvider`. */ function formatVersionMismatchError( consumerModuleId: string, mismatches: CapabilityVersionMismatch[], ): string { const lines: string[] = [`Capability version mismatch: cannot deploy '${consumerModuleId}'.`, '']; for (const m of mismatches) { lines.push( ` ${m.capabilityName}: requires ${m.requiredVersion}, but ` + `'${m.providerModuleId}' provides ${m.providedVersion}`, ); if (m.reason === 'caller_minor_too_old') { lines.push( ` Fix: upgrade '${m.providerModuleId}' to a version that provides ` + `${m.capabilityName}@${m.requiredVersion} or newer.`, ); } else { lines.push( ` Fix: the major version differs. Either update '${consumerModuleId}' ` + `to require ${m.capabilityName}@${m.providedVersion} (matching the ` + `installed provider's major), or rebuild '${m.providerModuleId}' ` + `against ${m.capabilityName}@${m.requiredVersion}.`, ); } } return lines.join('\n'); } /** * Check if a capability is provided by any deployed module * Execution function - queries database * * @param capabilityName - Capability name * @param db - Database connection * @returns True if capability is provided */ async function isCapabilityProvided(capabilityName: string, db: DbClient): Promise { const cap = await db .select() .from(capabilities) .where(eq(capabilities.capabilityName, capabilityName)) .get(); if (!cap) return false; // Also verify the provider module is actually deployed (VERIFIED or DEPLOYED), // not just imported/configured. A capability registered by an undeployed // module cannot serve hook requests at runtime. const provider = await db .select({ state: modules.state }) .from(modules) .where(eq(modules.id, cap.moduleId)) .get(); return provider?.state === 'VERIFIED' || provider?.state === 'INSTALLED'; } /** * Get generated path for module * Policy function - computes path * * @param sourcePath - Module source path * @param moduleId - Module identifier * @returns Generated artifacts path */ function getGeneratedPath(sourcePath: string, _moduleId: string): string { // Generated artifacts are in /modules//generated // Module source path is /modules/ return `${sourcePath}/generated`; } /** * Format suggested deployment order for missing capabilities * Presentation function - formats error message * * @param missingCapabilities - Missing capability names * @param consumerModuleId - Consumer module ID * @returns Formatted deployment order suggestion */ function getSuggestedDeploymentOrder( missingCapabilities: string[], consumerModuleId: string, ): string { const lines: string[] = []; for (let i = 0; i < missingCapabilities.length; i++) { // Convert capability name to module ID convention // e.g., dns_registrar -> dns-registrar const moduleId = missingCapabilities[i].replace(/_/g, '-'); lines.push(` ${i + 1}. celilo module deploy ${moduleId}`); } lines.push(` ${missingCapabilities.length + 1}. celilo module deploy ${consumerModuleId}`); return lines.join('\n'); } /** * Find required variables that are not configured * Policy function - checks database for variable values * * @param moduleId - Module identifier * @param manifest - Module manifest * @param db - Database connection * @returns Array of missing required variables */ export async function findMissingRequiredVariables( moduleId: string, manifest: ModuleManifest, db: DbClient, ): Promise< Array<{ name: string; source: 'user' | 'secret' | 'capability' | 'system'; description?: string; derive_from?: string; type?: string; options?: Array<{ value: string; label: string; hint?: string }>; per_selection?: { key_pattern: string; prompt: string; type?: string; derive_from?: string }; generate?: { method: string; length: number; encoding: string }; key_label?: string; value_label?: string; key_pattern?: string; key_pattern_message?: string; value_pattern?: string; value_pattern_message?: string; }> > { const missing: Array<{ name: string; source: 'user' | 'secret' | 'capability' | 'system'; description?: string; derive_from?: string; type?: string; options?: Array<{ value: string; label: string; hint?: string }>; per_selection?: { key_pattern: string; prompt: string; type?: string; derive_from?: string }; generate?: { method: string; length: number; encoding: string }; key_label?: string; value_label?: string; key_pattern?: string; key_pattern_message?: string; value_pattern?: string; value_pattern_message?: string; }> = []; // Check declared variables (user config, capability, system, infrastructure) if (manifest.variables?.owns) { for (const variable of manifest.variables.owns) { if (!variable.required) continue; // Skip optional variables // Infrastructure-derived variables will be resolved during deploy if (variable.source === 'infrastructure') { continue; // These are auto-populated, don't need validation here } // Terraform variables are resolved during deployment if (variable.source === 'terraform') { continue; // These are auto-populated from Terraform outputs } // Hook-owned variables are discovered by the module's own hooks at // runtime (hook-owned-state D2). An unwritten one is not a missing // configuration value — prompting for it or failing validation would // ask the operator to guess what a hook has not learned yet. if (variable.source === 'hook') { continue; } // Check if variable is configured let isConfigured = false; if ( variable.source === 'user' || variable.source === 'capability' || variable.source === 'system' ) { // Check moduleConfigs table const config = await db .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, variable.name))) .get(); isConfigured = !!config && (!!config.value || !!config.valueJson); } if (!isConfigured) { missing.push({ name: variable.name, source: variable.source, description: variable.description, derive_from: variable.derive_from, type: variable.type, options: variable.options, per_selection: variable.per_selection, }); } } } // Delegate the secret-discovery half to the canonical implementation // in config-interview.ts. Previously the secrets section was inlined // here and diverged from validateModuleSecrets — most recently // dropping `type` / `key_label` / `value_label` on the floor, which // routed string-map secrets through the wrong responder UX. const missingSecrets = await findMissingSecrets(moduleId, manifest, db); for (const s of missingSecrets) { missing.push({ name: s.name, source: s.source, description: s.description, type: s.type, generate: s.generate, key_label: s.key_label, value_label: s.value_label, key_pattern: s.key_pattern, key_pattern_message: s.key_pattern_message, value_pattern: s.value_pattern, value_pattern_message: s.value_pattern_message, }); } return missing; }