import { readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { parseVariables } from '../variables/parser'; import type { VariableReference } from '../variables/types'; import type { ModuleManifest } from './schema'; /** * Template validation error */ export interface TemplateValidationError { file: string; variable: string; error: string; } /** * Template validation result */ export interface TemplateValidationResult { success: boolean; errors: TemplateValidationError[]; /** * `.` paths the templates reference. The capability-access * check at import consumes these, so a secret referenced only from a template * is refused at import rather than later at generation. */ capabilityReferences: string[]; } /** * Get nested value from object using dot notation * Returns undefined if path doesn't exist * * @param obj - Object to traverse * @param path - Dot-separated path (e.g., 'requires.system.cpu') * @returns Value if found, undefined otherwise */ function getNestedValue(obj: Record, path: string): unknown { const parts = path.split('.'); let current: unknown = obj; for (const part of parts) { if (current === null || current === undefined) { return undefined; } if (typeof current !== 'object') { return undefined; } current = (current as Record)[part]; } return current; } /** * Check if a path exists in the manifest * * @param manifest - Module manifest * @param path - Dot-separated path (e.g., 'requires.system.cpu') * @returns True if path exists in manifest */ function pathExistsInManifest(manifest: ModuleManifest, path: string): boolean { // Convert manifest to a plain object we can traverse const manifestObj = manifest as unknown as Record; const value = getNestedValue(manifestObj, path); return value !== undefined; } /** * Auto-allocated variables that are injected during module generation * These don't need to be declared in the manifest * * IPAM auto-allocation and zone-based derivation */ const AUTO_ALLOCATED_VARIABLES = new Set([ 'vmid', // Auto-allocated by IPAM (container-based modules) 'target_ip', // Auto-allocated by IPAM or injected from machine infrastructure 'vlan', // Auto-derived from zone configuration 'gateway', // Auto-derived from zone configuration 'target_node', // Can be auto-derived from system config 'lxc_nameserver', // Composed at generate time from dns_internal + dns.primary (openspec/specs/lxc-dns-at-birth/spec.md) // Instance sizing (ISS-0150): the instance Terraform reads $self:{cores,memory, // disk,storage}, which are injected during resolution from the module_systems // table (falling back to requires.system.*) — see variables/context.ts. Like // vmid/target_ip they are populated at generate time, not declared in the // manifest, so they are auto-allocated rather than import-time validation errors. 'cores', // requires.system.cpu / module_systems.cpu 'memory', // requires.system.memory / module_systems.memory 'disk', // requires.system.disk / module_systems.disk 'storage', // storage pool name (requires.system.storage) ]); /** * Check if a variable is declared in the manifest or auto-allocated * * @param manifest - Module manifest * @param variableName - Variable name to check * @returns True if variable is declared or auto-allocated */ function isVariableDeclared(manifest: ModuleManifest, variableName: string): boolean { // Check if it's an auto-allocated variable if (AUTO_ALLOCATED_VARIABLES.has(variableName)) { return true; } // Check in variables.owns const declared = manifest.variables?.owns?.some((v) => v.name === variableName); if (declared) return true; // Check in variables.imports const imported = manifest.variables?.imports?.some((v) => v.name === variableName); if (imported) return true; return false; } /** * Validate a $self: variable reference * * Policy function - validates variable path against manifest structure * * @param variable - Variable reference to validate * @param manifest - Module manifest * @returns Error message if invalid, null if valid */ function validateSelfVariable( variable: VariableReference, manifest: ModuleManifest, ): string | null { const path = variable.path; // Check if it's a direct variable reference (e.g., $self:hostname) if (!path.includes('.')) { if (isVariableDeclared(manifest, path)) { return null; // Valid - variable is declared } // Check if it exists as a top-level manifest field if (pathExistsInManifest(manifest, path)) { return null; // Valid - exists in manifest } return `Self variable '${path}' not found in module configuration`; } // Check if it's a nested path (e.g., $self:requires.system.cpu) if (pathExistsInManifest(manifest, path)) { return null; // Valid } return `Self variable '${path}' not found in module configuration`; } /** * Validate a $capability: variable reference * * Policy function - validates capability references against manifest requirements * * @param variable - Variable reference to validate * @param manifest - Module manifest * @returns Error message if invalid, null if valid */ function validateCapabilityVariable( variable: VariableReference, manifest: ModuleManifest, ): string | null { const [capabilityName, ...pathParts] = variable.path.split('.'); if (!capabilityName) { return 'Capability variable must specify capability name (e.g., dns_registrar.primary_domain)'; } if (pathParts.length === 0) { return `Capability variable must specify data path (e.g., ${capabilityName}.nameserver)`; } // Check if module requires this capability const requiresCapability = manifest.requires?.capabilities?.some( (cap) => cap.name === capabilityName, ); if (!requiresCapability) { return `Module references capability '${capabilityName}' but does not require it in manifest`; } return null; // Valid - capability is required } /** * Validate variable references in a template * * Policy function - validates template variables against manifest * * @param content - Template content * @param manifest - Module manifest * @param relativePath - Relative file path (for error reporting) * @returns Array of validation errors */ function validateTemplateContent( content: string, manifest: ModuleManifest, relativePath: string, ): TemplateValidationError[] { const errors: TemplateValidationError[] = []; const variables = parseVariables(content); for (const variable of variables) { let error: string | null = null; switch (variable.type) { case 'self': error = validateSelfVariable(variable, manifest); break; case 'capability': error = validateCapabilityVariable(variable, manifest); break; case 'system': case 'system_secret': case 'secret': // These are validated at runtime, not import time // System config and secrets might not exist yet during import break; } if (error) { errors.push({ file: relativePath, variable: variable.raw, error, }); } } return errors; } /** * Find all template files in a directory recursively * * @param dirPath - Directory to search * @param basePath - Base path for relative path calculation * @returns Array of template file paths (relative to basePath) */ async function findTemplateFiles(dirPath: string, basePath: string): Promise { const templateFiles: string[] = []; async function walkDir(currentPath: string) { const entries = await readdir(currentPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(currentPath, entry.name); if (entry.isDirectory()) { await walkDir(fullPath); } else if (entry.isFile() && entry.name.endsWith('.tpl')) { // Store relative path from basePath const relativePath = fullPath.substring(basePath.length + 1); templateFiles.push(relativePath); } } } await walkDir(dirPath); return templateFiles; } /** * Validate all template files in a module directory * * Policy function - validates template variable references against manifest * * @param modulePath - Path to module directory * @param manifest - Validated module manifest * @returns Validation result with errors if any */ export async function validateModuleTemplates( modulePath: string, manifest: ModuleManifest, ): Promise { const allErrors: TemplateValidationError[] = []; try { // Find all .tpl files const templateFiles = await findTemplateFiles(modulePath, modulePath); // Validate each template file. Every one is parsed here anyway, so the // capability references fall out of work already being done — no second // walk of the module tree. const capabilityReferences = new Set(); for (const relativePath of templateFiles) { const fullPath = join(modulePath, relativePath); const content = await readFile(fullPath, 'utf-8'); for (const variable of parseVariables(content)) { if (variable.type === 'capability') capabilityReferences.add(variable.path); } const errors = validateTemplateContent(content, manifest, relativePath); allErrors.push(...errors); } return { success: allErrors.length === 0, errors: allErrors, capabilityReferences: [...capabilityReferences], }; } catch (error) { return { success: false, errors: [ { file: '', variable: '', error: `Failed to validate templates: ${error instanceof Error ? error.message : 'Unknown error'}`, }, ], // Unreadable templates cannot yield references, and import returns on // !success before the access check runs, so this can never reach it as a // silent pass. capabilityReferences: [], }; } } /** * Format template validation errors into a readable error message * * @param errors - Array of validation errors * @returns Formatted error message */ export function formatTemplateValidationErrors(errors: TemplateValidationError[]): string { const lines: string[] = ['Failed to validate template variables:']; // Group errors by file const errorsByFile = new Map(); for (const error of errors) { const fileErrors = errorsByFile.get(error.file) || []; fileErrors.push(error); errorsByFile.set(error.file, fileErrors); } // Format errors by file for (const [file, fileErrors] of errorsByFile) { lines.push(` ${file}:`); for (const error of fileErrors) { lines.push(` ${error.variable}: ${error.error}`); } } return lines.join('\n'); }