import { randomUUID } from 'node:crypto'; import { existsSync, statSync } from 'node:fs'; import { chmod, cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import { dirname, join, relative } from 'node:path'; import { and, eq } from 'drizzle-orm'; import { generateInventory } from '../ansible/inventory'; import { generateAnsibleSecrets } from '../ansible/secrets'; import { ProxmoxClient, type ProxmoxCredentials } from '../api-clients/proxmox'; import { log } from '../cli/prompts'; import { getModuleStoragePath } from '../config/paths'; import { type DbClient, getDb } from '../db/client'; import { capabilities, containerServices, machines, moduleConfigs, moduleInfrastructure, modules, } from '../db/schema'; import { getSingularSystemSpec } from '../manifest/schema'; import type { AnsibleCollection, ModuleManifest } from '../manifest/schema'; import { validateZoneRequirements } from '../manifest/validate'; import { getServiceCredentials } from '../services/container-service'; import { getModuleSystems } from '../services/deployed-systems'; import { describeCapabilityProblem, findBrokenCapabilityDerivations, } from '../services/fleet-checks'; import { selectInfrastructure } from '../services/infrastructure-selector'; import { deleteModuleConfig, getModuleConfigValue, upsertModuleConfig, } from '../services/module-config'; import type { InfrastructureSelection } from '../types/infrastructure'; import { convertSecretsToJinja } from '../variables/ansible-resolver'; import { buildResolutionContext } from '../variables/context'; import { resolveTemplate } from '../variables/resolver'; import type { GenerateOptions, GenerateResult, GeneratedFile, TemplateFile } from './types'; /** * Template file extensions (require Celilo variable resolution) */ const TEMPLATE_EXTENSIONS = ['.tpl', '.j2']; /** * Extensions to strip after processing * .tpl - Stripped (temporary extension for Celilo processing) * .j2 - Kept (Ansible Jinja2 templates need this extension) */ const STRIP_EXTENSIONS = ['.tpl']; /** * File extensions to copy as-is (no Celilo variable resolution) * - Ansible files (.yml, .yaml) * - Terraform files (.tf) - regular Terraform syntax, no Celilo variables */ const COPY_AS_IS_EXTENSIONS = ['.yml', '.yaml', '.tf']; /** * Template directories to process */ // Directories to scan for template files. Supports both root-level and // celilo/ subdirectory layouts (e.g., ansible/ or celilo/ansible/). const TEMPLATE_DIRS = ['terraform', 'ansible', 'celilo/terraform', 'celilo/ansible']; /** * Check if file is a template that needs variable resolution * * Policy function (Rule 10.1) - validation only * * @param filename - File name to check * @returns True if file is a template requiring resolution */ export function isTemplateFile(filename: string): boolean { return TEMPLATE_EXTENSIONS.some((ext) => filename.endsWith(ext)); } /** * Check if file should be copied as-is without Celilo variable resolution * Includes Ansible files (.yml, .yaml) and Terraform files (.tf) * * Policy function - validation only * * @param filename - File name to check * @returns True if file should be copied without Celilo resolution */ export function isCopyAsIsFile(filename: string): boolean { return COPY_AS_IS_EXTENSIONS.some((ext) => filename.endsWith(ext)); } /** * Check if file should be processed (either template or Ansible file) * * Policy function - validation only * * @param filename - File name to check * @returns True if file should be included in processing */ export function isProcessableFile(filename: string): boolean { return isTemplateFile(filename) || isCopyAsIsFile(filename); } /** * Get output filename by removing template extension * * Policy function - pure transformation * * Strips .tpl extension (temporary processing extension) * Keeps .j2 extension (Ansible Jinja2 templates need this) * * @param templateFilename - Template file name * @returns Output file name with appropriate extension */ export function getOutputFilename(templateFilename: string): string { let result = templateFilename; // Strip template extensions (.tpl) for (const ext of STRIP_EXTENSIONS) { if (result.endsWith(ext)) { result = result.slice(0, -ext.length); break; } } // Normalize path: strip any prefix before ansible/ or terraform/ // e.g., "celilo/ansible/playbook.yml" → "ansible/playbook.yml" // This allows modules to organize deployment files in subdirectories // (e.g., celilo/) while generating to the standard layout. const ansibleIdx = result.indexOf('ansible/'); if (ansibleIdx > 0) { result = result.slice(ansibleIdx); } const terraformIdx = result.indexOf('terraform/'); if (terraformIdx > 0) { result = result.slice(terraformIdx); } return result; } /** * Inject framework-owned DNS-at-birth into every Proxmox compute resource — * `proxmox_lxc` and `proxmox_vm_qemu`. * * A node's nameserver is infrastructure celilo owns — like vmid, target_ip, and * inventory — not something a module author hand-writes. Authors declare zero * DNS terraform; this stamps it onto each Proxmox resource block at generate * time. For every block it emits: * * - `nameserver = "$self:lxc_nameserver"` — only when a nameserver is * computable (`hasNameserver`). The attribute is `nameserver` for both an * LXC and a cloud-init VM. The first system, deployed before any * `dns_internal` provider exists, has no value: it inherits the node default * and the `dns-client-config` aspect repairs resolv.conf post-deploy. * - `lifecycle { ignore_changes = [...] }` — always. The list is the * resource's create-time / ForceNew attributes, so an unchanged redeploy is * a no-op and a real change an in-place UPDATE — never the destructive * REPLACE the terraform-safety guard (create-only, see terraform-safety.ts) * rejects. terraform = birth DNS, the `dns-client-config` aspect = ongoing * DNS. The per-type lists are in the callback. See openspec/specs/lxc-dns-at-birth/spec.md. * * Anchored on the resource's opening line — it never brace-matches the nested * disk/network/features blocks, so it's robust to attribute order/formatting. * Idempotent at file granularity: a `.tf` that already declares * `ignore_changes = [nameserver` (re-run, or an author who opted in) is returned * untouched. * * Policy function (Rule 10.1) - pure string transformation, no I/O. * * @param content - Raw terraform template content (pre variable-resolution) * @param hasNameserver - Whether `$self:lxc_nameserver` resolves to a value * @returns Content with DNS injected into each Proxmox compute resource */ export function injectProxmoxDns(content: string, hasNameserver: boolean): string { // Already injected (idempotent) or author opted into the lifecycle — done. // Match the `[nameserver` prefix (no closing bracket) so this stays true for // both the LXC and the VM variant — otherwise re-generate double-injects. if (content.includes('ignore_changes = [nameserver')) { return content; } // A template that still carries a `nameserver = …` attribute — a stale copied // module, or an author who set it by hand — already supplies the value. // Injecting a second `nameserver` is a terraform "Attribute redefined" error. // So skip the value line when one exists and just add the lifecycle guard. const alreadyHasNameserver = /^[ \t]*nameserver[ \t]*=/m.test(content); const openLineRe = /^([ \t]*)resource\s+"(proxmox_lxc|proxmox_vm_qemu)"\s+"[^"]+"\s*\{[ \t]*$/gm; return content.replace(openLineRe, (openLine, indent: string, resourceType: string) => { const inner = `${indent} `; const injected = [openLine]; if (hasNameserver && !alreadyHasNameserver) { injected.push(`${inner}nameserver = "$self:lxc_nameserver"`); } injected.push(`${inner}lifecycle {`); // The create-time / ForceNew attributes Proxmox assigns, per resource type. // Listing them makes an unchanged redeploy a no-op and a real change an // in-place UPDATE, never a destructive REPLACE. A non-existent attribute // here fails `terraform validate`, so each list is exactly the schema // attributes the resource actually has. // LXC (ISS-0055/0089): the create-time MAC + rootfs volume, plus // `ssh_public_keys` (ForceNew — a fleet-key rotation must not REPLACE the // container). `rootfs.size` is NOT ignored, so a disk grow still applies. // We do NOT ignore `target_node` — a node change is real (migration, // ISS-0090), never silently dropped. // VM (telmate 3.x proxmox_vm_qemu): `clone` (ForceNew clone source), // `sshkeys` + `nameserver` (ForceNew cloud-init), and the NIC `macaddr`. // Initial set from the provider schema — extend if a first real VM deploy // surfaces another spurious-REPLACE attribute, exactly as ISS-0055 did for // the LXC list. const ignore = resourceType === 'proxmox_vm_qemu' ? 'nameserver, network[0].macaddr, sshkeys, clone' : 'nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys'; injected.push(`${inner} ignore_changes = [${ignore}]`); injected.push(`${inner}}`); return injected.join('\n'); }); } /** * Omit the optional Proxmox network VLAN tag when a zone is explicitly * untagged. * * Celilo represents an untagged zone by leaving `network..vlan` unset. * Module templates historically rendered `tag = $self:vlan` unconditionally, * which makes variable resolution fail even though the Proxmox provider treats * an omitted `tag` attribute as the correct untagged configuration. Removing * only that exact framework variable line preserves literal tags and other * tag expressions authored by a module. * * Policy function (Rule 10.1) - pure string transformation, no I/O. * * @param content - Raw terraform template content (pre variable-resolution) * @param hasVlan - Whether `$self:vlan` resolves to a configured VLAN ID * @returns Content with the optional tag line omitted for an untagged zone */ export function omitUntaggedProxmoxVlan(content: string, hasVlan: boolean): string { if (hasVlan) { return content; } return content.replace(/^[ \t]*tag[ \t]*=[ \t]*\$self:vlan[ \t]*(?:#.*)?(?:\r?\n|$)/gm, ''); } /** * Extract the node a Proxmox guest is deployed on from a parsed terraform state * object — an LXC (proxmox_lxc) or a VM (proxmox_vm_qemu). Pure logic (Rule 10), * split from the file read for testability. Prefers the explicit `target_node` * attribute; falls back to parsing the resource id (`/lxc/` for an * LXC, `/qemu/` for a VM). Returns null when there's no such * resource (e.g. an empty/fresh state). */ export function targetNodeFromTfState(state: { resources?: Array<{ type?: string; instances?: Array<{ attributes?: { target_node?: string; id?: string } }>; }>; }): string | null { const guest = state.resources?.find( (r) => r.type === 'proxmox_lxc' || r.type === 'proxmox_vm_qemu', ); const attrs = guest?.instances?.[0]?.attributes; if (!attrs) { return null; } if (attrs.target_node) { return attrs.target_node; } // id format: "/lxc/" (LXC) or "/qemu/" (VM) return attrs.id?.split('/')[0] || null; } /** * The storage an existing container's root disk actually lives on, from * terraform state. Prefers the LXC `rootfs.storage` attribute, falls back to the * `:vm--disk-0` volume prefix, then to a VM's data `disk` block. * Returns null when there's no such resource (e.g. an empty/fresh state). */ export function storageFromTfState(state: { resources?: Array<{ type?: string; instances?: Array<{ attributes?: { rootfs?: | Array<{ storage?: string; volume?: string }> | { storage?: string; volume?: string }; disk?: Array<{ storage?: string; type?: string }>; }; }>; }>; }): string | null { const guest = state.resources?.find( (r) => r.type === 'proxmox_lxc' || r.type === 'proxmox_vm_qemu', ); const attrs = guest?.instances?.[0]?.attributes; if (!attrs) { return null; } // The provider models `rootfs` as a single-element block list; older state // shapes record it as a bare object. const rootfs = Array.isArray(attrs.rootfs) ? attrs.rootfs[0] : attrs.rootfs; if (rootfs?.storage) { return rootfs.storage; } // volume format: ":vm--disk-0" const volumeStorage = rootfs?.volume?.split(':')[0]; if (volumeStorage) { return volumeStorage; } // VMs: the data disk carries the storage that matters (the cloudinit disk // rides on the same storage — see the `disk` block ordering note in the // vm templates). const disks = attrs.disk ?? []; return (disks.find((d) => d.type === 'disk') ?? disks[0])?.storage || null; } /** Parse a module's terraform state, or null when absent/unreadable. */ async function readTfState(moduleId: string): Promise | null> { const statePath = join( getModuleStoragePath(), moduleId, 'generated', 'terraform', 'terraform.tfstate', ); if (!existsSync(statePath)) { return null; } try { return JSON.parse(await readFile(statePath, 'utf-8')); } catch { return null; } } /** * Read the node a module's container is currently deployed on, from its terraform * state file. This is celilo's authoritative record of placement (ISS-0090). * Returns null when no state exists yet (a first deploy) or it can't be read — * the caller then falls back to the service `default_target_node`. */ async function readDeployedTargetNode(moduleId: string): Promise { const state = await readTfState(moduleId); return state ? targetNodeFromTfState(state) : null; } /** * Read the storage a module's container is currently deployed on, from its * terraform state file. Returns null on a first deploy — the caller then falls * back to the service default storage. */ async function readDeployedStorage(moduleId: string): Promise { const state = await readTfState(moduleId); return state ? storageFromTfState(state) : null; } /** * The node a module's container ACTUALLY lives on, from Proxmox (ISS-0090). * Proxmox is the ultimate source of truth for current location — it sees a * hand-migration that celilo's terraform state wouldn't. Returns null when the * module has no deployed vmid yet, the service is unreachable, or the vmid isn't * in the cluster — the caller then falls back to terraform state / the default. * Never throws: a Proxmox outage must not block a deploy. */ async function readProxmoxNodeForModule( moduleId: string, serviceId: string, db: DbClient, ): Promise { const vmid = getModuleSystems(moduleId, db).find( (s) => s.infrastructure.type === 'container_service' && s.infrastructure.vmid != null, )?.infrastructure.vmid; if (vmid == null) return null; try { const creds = (await getServiceCredentials(serviceId)) as ProxmoxCredentials; const result = await new ProxmoxClient(creds).nodeForVmid(vmid); return result.success ? result.data : null; } catch { return null; } } /** * Decide which node to target for a deploy (ISS-0090). Pure (Rule 10): * Proxmox reality > recorded terraform state > service default. * `default_target_node` governs only a FIRST placement; a changed default must * never relocate a running container. A hand-migration (seen by Proxmox but not * tf-state) is adopted. Deliberate moves are an explicit migrate (ISS-0062). */ export function decideTargetNode(opts: { proxmoxNode: string | null; stateNode: string | null; defaultNode: string; }): { node: string; source: 'proxmox' | 'state' | 'default' } { if (opts.proxmoxNode) return { node: opts.proxmoxNode, source: 'proxmox' }; if (opts.stateNode) return { node: opts.stateNode, source: 'state' }; return { node: opts.defaultNode, source: 'default' }; } /** * Decide which storage to target for a deploy. Pure (Rule 10). The sibling of * `decideTargetNode`: the service's `storage` governs only a FIRST placement. * A container already living on `local-lvm` must keep living there when the * service default later changes to `datacenter` — emitting the new default * makes terraform plan a destroy-and-recreate ("forces replacement") of a * running container and lose its volume. Relocating storage is a deliberate * migration, never a side effect of a default drifting. * * ponytail: terraform state only, no Proxmox-reality tier like decideTargetNode * has. A hand `pct move-volume` is invisible until the next apply refreshes * state; add a Proxmox read if that ever bites. */ export function decideStorage(opts: { stateStorage: string | null; defaultStorage: string; }): { storage: string; source: 'state' | 'default' } { if (opts.stateStorage) return { storage: opts.stateStorage, source: 'state' }; return { storage: opts.defaultStorage, source: 'default' }; } /** * Discover template files in directory recursively * * Execution function (Rule 10.1) - performs file I/O * * Note: Despite the name, this discovers both template files (.tpl, .j2) * and Ansible files (.yml, .yaml) that should be processed * * @param baseDir - Base directory to search * @param currentDir - Current directory (for recursion) * @returns Array of relative paths to processable files */ export async function discoverTemplateFiles( baseDir: string, currentDir: string = baseDir, ): Promise { const templates: string[] = []; if (!existsSync(currentDir)) { return templates; } const entries = await readdir(currentDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(currentDir, entry.name); if (entry.isDirectory()) { // Skip inventory directory - it's generated programmatically if (entry.name === 'inventory') { continue; } // Recurse into subdirectories const subTemplates = await discoverTemplateFiles(baseDir, fullPath); templates.push(...subTemplates); } else if (entry.isFile() && isProcessableFile(entry.name)) { // Get relative path from base directory const relativePath = relative(baseDir, fullPath); templates.push(relativePath); } } return templates; } /** * Read template files * * Execution function - performs file I/O * * @param modulePath - Module base path * @param templatePaths - Relative paths to template files * @returns Template files with content */ export async function readTemplateFiles( modulePath: string, templatePaths: string[], ): Promise { const templates: TemplateFile[] = []; for (const templatePath of templatePaths) { const sourcePath = join(modulePath, templatePath); const content = await readFile(sourcePath, 'utf-8'); const outputFilename = getOutputFilename(templatePath); templates.push({ sourcePath, targetPath: outputFilename, content, }); } return templates; } /** * Write generated files to output directory * * Execution function - performs file I/O * * @param outputPath - Base output path * @param files - Generated files to write */ /** * Copy each `ansible/roles//files/` directory from the module source * to the generated output, verbatim (preserving binary content + mode bits). * * These hold Ansible role-local static assets — they may be binaries and * don't need template variable resolution, so the standard template * pipeline (which is utf-8) can't handle them. */ export async function copyAnsibleRoleFilesDirs( modulePath: string, outputPath: string, ): Promise { const rolesDir = join(modulePath, 'ansible', 'roles'); if (!existsSync(rolesDir)) return; const roles = await readdir(rolesDir, { withFileTypes: true }); for (const role of roles) { if (!role.isDirectory()) continue; const srcFilesDir = join(rolesDir, role.name, 'files'); if (!existsSync(srcFilesDir)) continue; const destFilesDir = join(outputPath, 'ansible', 'roles', role.name, 'files'); await mkdir(dirname(destFilesDir), { recursive: true }); // `force: true` is LOAD-BEARING on bun, and its absence was celilo#925. // // Node defaults `force` to true, so this looked correct and is correct // under Node. Bun 1.3.3 does not, on this path specifically — measured, // copying "NEW" over an existing "OLD": // // recursive only -> NEW // recursive + force -> NEW // recursive + preserveTimestamps -> OLD <- what this was // recursive + force + preserveTimestamps -> NEW // // celilo runs on bun. So a module's built binary landed in `generated/` // exactly once, at first generate, and no later version ever replaced it — // silently, because `cp` reports no error, so the caller's try/catch has // nothing to catch. Ansible then copies that first binary forever and // reports `ok`, unchanged, while the module's version field advances. // // The sibling call in `storage-set-path.ts:153` already passes `force`. await cp(srcFilesDir, destFilesDir, { recursive: true, force: true, preserveTimestamps: true, }); } } export async function writeGeneratedFiles( outputPath: string, files: GeneratedFile[], ): Promise { for (const file of files) { const fullPath = join(outputPath, file.path); const dir = dirname(fullPath); // Ensure directory exists await mkdir(dir, { recursive: true }); // Write file await writeFile(fullPath, file.content, 'utf-8'); // Set restrictive permissions on sensitive files (owner read/write only) if (file.path.includes('terraform.tfvars') || file.path.includes('secrets.yml')) { await chmod(fullPath, 0o600); } } } /** * Generate Ansible dependency documentation * * Creates requirements.yml and README.md files for Ansible collections * * @param moduleId - Module ID * @param outputPath - Output directory path * @param db - Database client * @returns Array of generated documentation files */ async function generateAnsibleDocs( moduleId: string, _outputPath: string, db: DbClient, ): Promise { const generatedDocs: GeneratedFile[] = []; // Get module manifest const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) { return generatedDocs; } const manifest = module.manifestData as { ansible?: { collections?: AnsibleCollection[] } }; const collections = manifest.ansible?.collections || []; if (collections.length === 0) { return generatedDocs; } // Generate requirements.yml const requirementsYml = `--- # Ansible collection dependencies for ${moduleId} # Generated by Celilo # # NOTE: These collections are automatically installed by Celilo # during module import. This file is provided for reference and # for manual installation if needed. # # Manual install command: # ansible-galaxy collection install -r requirements.yml collections: ${collections.map((col) => ` - name: ${col.name}\n version: "${col.version}"`).join('\n\n')} `; generatedDocs.push({ path: 'ansible/requirements.yml', content: requirementsYml, }); // Generate README.md const moduleName = module.name; const readmeMd = `# ${moduleName} - Ansible Configuration ## Prerequisites This playbook requires Ansible collections that were automatically installed by Celilo: ${collections.map((col) => `- \`${col.name}\` (${col.version})${col.reason ? ` - ${col.reason}` : ''}`).join('\n')} ### Manual Installation (if needed) If running this playbook outside of Celilo, install dependencies manually: \`\`\`bash ansible-galaxy collection install -r requirements.yml \`\`\` ### Verify Installation \`\`\`bash ansible-galaxy collection list \`\`\` You should see: ${collections.map((col) => `- ${col.name} (version ${col.version.replace(/^>=/, '')} or compatible)`).join('\n')} ## Running the Playbook \`\`\`bash # Check syntax ansible-playbook --syntax-check -i inventory playbook.yml # Run playbook ansible-playbook -i inventory playbook.yml \`\`\` ## Troubleshooting **Error: "couldn't resolve module/action"** This means required collections are missing. Reinstall with: \`\`\`bash ansible-galaxy collection install -r requirements.yml \`\`\` **Checksum verification failed** Collections may be corrupted. Remove and reinstall: \`\`\`bash # Remove corrupted collections ${collections.map((col) => `ansible-galaxy collection remove ${col.name}`).join('\n')} # Reimport module (will reinstall collections) celilo module remove ${moduleId} celilo module import modules/${moduleId} \`\`\` `; generatedDocs.push({ path: 'ansible/README.md', content: readmeMd, }); return generatedDocs; } /** * Generate templates for a module * * Orchestration function (Rule 10.1) - coordinates policy, planning, and execution * * @param options - Generation options * @returns Generation result */ /** Narrower than `GenerateResult`: this step produces no files, only an outcome. */ export type IngressIpResult = { success: true } | { success: false; error: string }; /** * Allocate-and-reserve a module's dedicated `internal`-subnet ingress IPs, ONCE * (ISS-0156, celilo#879). * * A service can need to live in a PROTECTED zone — the `dns_internal` resolver * sits in `dmz` so it can see protected-zone query sources for split-horizon, * and `caddy-internal` sits there so systems in the segmented zones can reach * it. `internal` devices have no route into the 10-net, so they reach such a * service through a firewall DNAT on a dedicated `internal`-subnet address. * That DNAT is internal-side only: it is NOT a public port-forward, and the two * are routinely confused. * * A module opts in by declaring an infrastructure variable whose name ends in * `ingress_ip` — `dns_ingress_ip` for the resolver's `:53`, `ingress_ip` for a * private web ingress's `:80/:443`. The hook then passes the stored value to * `firewall.exposeService({ ingressIp })`. * * **Idempotence is the whole point, and it is load-bearing.** `module generate` * runs repeatedly over a module's life. Re-allocating here on the second run * would move the address internal clients use to reach the service, every time * — while every command still reports success. That is why the stored value is * reused rather than re-derived, and why this is a named function instead of a * branch buried in `generateTemplates`: an invariant nothing can call is an * invariant nothing can test, and this one had no test at all. */ export async function ensureIngressIps( moduleId: string, manifest: ModuleManifest, db: DbClient, ): Promise { const wanted = (manifest.variables?.owns ?? []).filter( (v) => v.name.endsWith('ingress_ip') && v.source === 'infrastructure', ); if (wanted.length === 0) return { success: true }; for (const variable of wanted) { const result = await allocateIngressIp(moduleId, variable.name, db); if (!result.success) return result; } return { success: true }; } async function allocateIngressIp( moduleId: string, variableName: string, db: DbClient, ): Promise { const existing = getModuleConfigValue(moduleId, variableName, db)?.value; if (typeof existing === 'string' && existing.length > 0) { log.success(`Using existing ingress IP ${existing} (${variableName}) for ${moduleId}`); return { success: true }; } const subnetRow = db.$client .prepare('SELECT value FROM system_config WHERE key = ?') .get('network.internal.subnet') as { value: string } | undefined; if (!subnetRow?.value) { return { success: false, error: `network.internal.subnet is not configured — required to allocate the ${variableName} ingress IP (ISS-0156). Ensure the internal network is set up first.`, }; } const { allocateIPFromSubnet, reserveIP } = await import('../ipam/allocator'); const { stripCIDR } = await import('../ipam/subnet-parser'); try { const ip = stripCIDR(await allocateIPFromSubnet(subnetRow.value, 'internal', db)); await reserveIP(ip, 'internal', `ingress:${moduleId}:${variableName}`, null, db); upsertModuleConfig(db, moduleId, variableName, ip); log.success(`Allocated ingress IP ${ip} (internal subnet, ${variableName}) for ${moduleId}`); return { success: true }; } catch (error) { return { success: false, error: `Ingress IP allocation failed for ${variableName}: ${error instanceof Error ? error.message : String(error)}`, }; } } export async function generateTemplates(options: GenerateOptions): Promise { const { moduleId, modulePath, outputPath, db = getDb() } = options; // Validate module path exists if (!existsSync(modulePath)) { return { success: false, error: `Module path does not exist: ${modulePath}`, }; } // Get module manifest for validation const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) { return { success: false, error: `Module not found: ${moduleId}`, }; } // Validate required configuration fields const manifest = module.manifestData as ModuleManifest; const requiredVars = manifest.variables?.owns?.filter((v) => v.required) || []; if (!options.skipVariableValidation && requiredVars.length > 0) { // Get current module configuration const configs = db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, moduleId)) .all(); const configMap = new Map(configs.map((c) => [c.key, c.value || c.valueJson])); // Check for missing required variables // Skip infrastructure and terraform variables - they're auto-populated during/after generation. // Skip hook-owned variables too: they are discovered by the module's own // hooks at runtime (hook-owned-state D2), so a hook that has not run yet // is not a configuration error. const missingVars = requiredVars.filter( (v) => v.source !== 'infrastructure' && v.source !== 'terraform' && v.source !== 'hook' && !configMap.has(v.name), ); if (missingVars.length > 0) { const varList = missingVars.map((v) => ` • ${v.name}: ${v.description}`).join('\n'); return { success: false, error: `Missing required configuration for module ${moduleId}:\n\n${varList}\n\n` + `Set missing values with: celilo module config set ${moduleId} `, }; } } const zoneValidation = validateZoneRequirements(manifest); if (zoneValidation) { const errorMessages = zoneValidation.errors.map((e) => `${e.path}: ${e.message}`).join('\n'); return { success: false, error: `Zone validation failed:\n${errorMessages}`, }; } // Infrastructure Selection // Select infrastructure for this module and store in database // Only required for modules that specify requires.system (container-based or machine-pool-based) let infrastructureSelection: InfrastructureSelection | undefined; const hasResourcesSpec = getSingularSystemSpec(manifest); if (hasResourcesSpec) { // Check if infrastructure already selected (from previous generation) const existing = db .select() .from(moduleInfrastructure) .where(eq(moduleInfrastructure.moduleId, moduleId)) .get(); if (existing) { // Reuse existing infrastructure assignment infrastructureSelection = { type: existing.infrastructureType as 'machine' | 'container_service', machineId: existing.machineId || undefined, serviceId: existing.serviceId || undefined, }; log.success(`Infrastructure selected: ${infrastructureSelection.type} (existing)`); } else { // First-time generation: select new infrastructure try { infrastructureSelection = await selectInfrastructure(module); log.success(`Infrastructure selected: ${infrastructureSelection.type}`); // Store infrastructure selection in database db.insert(moduleInfrastructure) .values({ id: randomUUID(), moduleId, infrastructureType: infrastructureSelection.type, machineId: infrastructureSelection.machineId || null, serviceId: infrastructureSelection.serviceId || null, containerMetadata: null, // Will be populated after Terraform apply createdAt: new Date(), }) .run(); } catch (error) { return { success: false, error: `Infrastructure selection failed: ${error instanceof Error ? error.message : String(error)}`, }; } } } else { infrastructureSelection = undefined; } // IPAM Auto-Allocation // Only allocate for Proxmox container services (not Digital Ocean or machines) // Digital Ocean gets IPs from Terraform outputs; Proxmox needs local IPAM const zone = getSingularSystemSpec(manifest)?.zone; const hasManualVmid = manifest.variables?.owns?.some((v) => v.name === 'vmid'); const isContainerService = infrastructureSelection?.type === 'container_service'; // Check if this is a Proxmox service (only Proxmox needs IPAM) let isProxmoxService = false; if (isContainerService && infrastructureSelection?.serviceId) { const service = await db .select() .from(containerServices) .where(eq(containerServices.id, infrastructureSelection.serviceId)) .get(); isProxmoxService = service?.providerName === 'proxmox'; } if (zone && !hasManualVmid && isProxmoxService) { // Module uses IPAM auto-allocation (Proxmox only) const { allocateForModule, getAllocation } = await import('../ipam/auto-allocator'); try { let allocation = getAllocation(moduleId, db); if (!allocation) { // First generation - allocate VMID and IP allocation = await allocateForModule(moduleId, zone, db, db.$client); log.success( `Allocated VMID ${allocation.vmid} and IP ${allocation.containerIp} for ${moduleId}`, ); } else { // Reuse existing allocation log.success( `Using existing allocation: VMID ${allocation.vmid}, IP ${allocation.containerIp}`, ); } // Store allocation in config temporarily (will be available in context). // These are "virtual" config values (the __ipam_ prefix flags them as // framework-internal). Typed: vmid is number, container_ip is string. upsertModuleConfig(db, moduleId, '__ipam_vmid', allocation.vmid); upsertModuleConfig(db, moduleId, '__ipam_container_ip', allocation.containerIp); } catch (error) { return { success: false, error: `IPAM allocation failed: ${error instanceof Error ? error.message : String(error)}`, }; } } const ingress = await ensureIngressIps(moduleId, manifest, db); if (!ingress.success) return ingress; // Infrastructure Properties Resolution (Proxmox provider config) // For Proxmox services, extract provider config and store as temporary values // This happens during generation so templates can access target_node, lxc_template, etc. // Resolved live each generate (ISS-0090) and injected into the context below, // never cached in the DB. undefined for non-Proxmox / machine deploys. let resolvedTargetNode: string | undefined; let resolvedStorage: string | undefined; if (isContainerService && isProxmoxService && infrastructureSelection?.serviceId) { const service = await db .select() .from(containerServices) .where(eq(containerServices.id, infrastructureSelection.serviceId)) .get(); if (service) { const providerConfig = service.providerConfig as { default_target_node: string; lxc_template: string; storage: string; // Optional: only set once a cloud-init VM template has been built/selected // for the service (see service reconfigure). Required by `type: vm` modules; // absent for services that only deploy LXCs. vm_template?: string; }; // ISS-0090: deploy follows REALITY. Resolve the node from Proxmox (it sees // a hand-migration that tf-state wouldn't), else the recorded terraform // state, else the service default (FIRST placement only). A changed default // must never relocate a running container; deliberate moves are an explicit // migrate (ISS-0062). const decision = decideTargetNode({ proxmoxNode: await readProxmoxNodeForModule( moduleId, infrastructureSelection.serviceId, db, ), stateNode: await readDeployedTargetNode(moduleId), defaultNode: providerConfig.default_target_node, }); resolvedTargetNode = decision.node; if (decision.source !== 'default' && decision.node !== providerConfig.default_target_node) { const from = decision.source === 'proxmox' ? 'Proxmox' : 'terraform state'; log.info( `${moduleId} → node '${decision.node}' (from ${from}; service default is '${providerConfig.default_target_node}'). Relocating requires a deliberate migration.`, ); } // Storage is sticky for the same reason placement is: the service default // governs only a FIRST create. Emitting a changed default at an existing // container makes terraform plan a destroy-and-recreate. const storageDecision = decideStorage({ stateStorage: await readDeployedStorage(moduleId), defaultStorage: providerConfig.storage, }); resolvedStorage = storageDecision.storage; if (storageDecision.source === 'state' && resolvedStorage !== providerConfig.storage) { log.info( `${moduleId} → storage '${resolvedStorage}' (from terraform state; service default is '${providerConfig.storage}'). Moving storage requires a deliberate migration.`, ); } // Persist only the non-drift provider values. target_node and storage are // reality — they're injected into the resolution context below, never // cached (ISS-0090); drop any stale __infra_* rows a prior generate left. upsertModuleConfig(db, moduleId, '__infra_lxc_template', providerConfig.lxc_template); // vm_template is only present once a VM template exists for the service. // Persist it when set so `type: vm` modules can resolve $self:vm_template; // a `type: vm` deploy against a service without one then fails loudly at // variable resolution (the intended "no VM template configured" error). if (providerConfig.vm_template) { upsertModuleConfig(db, moduleId, '__infra_vm_template', providerConfig.vm_template); } deleteModuleConfig(db, moduleId, '__infra_target_node'); deleteModuleConfig(db, moduleId, '__infra_storage'); log.success( `Infrastructure resolved: target_node=${decision.node} (${decision.source}), ` + `storage=${storageDecision.storage} (${storageDecision.source})`, ); } } // Policy: Build resolution context let context: Awaited>; try { context = await buildResolutionContext(moduleId, db); // Add IPAM-allocated values to context (if present) const vmidConfig = db .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, '__ipam_vmid'))) .get(); const ipConfig = db .select() .from(moduleConfigs) .where( and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, '__ipam_container_ip')), ) .get(); if (vmidConfig && ipConfig) { // Add to context as if they were regular config values // biome-ignore lint/style/noNonNullAssertion: value is NOT NULL in schema, guaranteed to exist context.selfConfig.vmid = String(vmidConfig.value!); // biome-ignore lint/style/noNonNullAssertion: value is NOT NULL in schema, guaranteed to exist context.selfConfig.target_ip = ipConfig.value!; } // target_node and storage are the live-resolved reality of an existing // container (ISS-0090) — inject them directly, never from a cached // __infra_* row (which drifts against the service default). if (resolvedTargetNode) { context.selfConfig.target_node = resolvedTargetNode; } if (resolvedStorage) { context.selfConfig.storage = resolvedStorage; } // lxc_template / vm_template are provider config (intent, not drift-prone) — // read them back from the __infra_* rows persisted above. const infraKeys = ['lxc_template', 'vm_template']; for (const key of infraKeys) { const infraConfig = db .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, `__infra_${key}`))) .get(); if (infraConfig?.value) { context.selfConfig[key] = infraConfig.value; } } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, error: `Failed to build resolution context: ${errorMessage}`, details: error, }; } // Policy: fail loud at the source on a broken capability chain. A // required `source: capability` var whose chain doesn't resolve is // silently dropped during derivation, then surfaces downstream as a // cryptic `$self: not found` in some template. Assert it here against // the resolved capabilities map so generate names the broken link and // the provider to redeploy (ISS-0115; the data-plane sibling of ISS-0088). const capProblems = findBrokenCapabilityDerivations(moduleId, manifest, context.capabilities); if (capProblems.length > 0) { return { success: false, error: `Cannot generate '${moduleId}': ${capProblems.length} capability-derived variable(s) won't resolve:\n${capProblems .map((p) => ` - ${describeCapabilityProblem(p)}`) .join('\n')}`, details: capProblems, }; } // NOTE: derived variables are NOT persisted here. `buildResolutionContext` // (called above) already wrote them at variables/context.ts, so the // insert-only block that used to live here could never fire — it read as a // second, competing persistence policy while being unreachable. The single // write lives in context.ts; see openspec/changes/derived-value-recomputation // for the plan to remove it there too. // Execution: Resolve capability 'imports' variables into module_configs // This makes capability-derived values available in host_vars for Ansible if (manifest.variables?.imports && manifest.variables.imports.length > 0) { const { getOrCreateMasterKey } = await import('../secrets/master-key'); const { decryptSecret } = await import('../secrets/encryption'); const { secrets: secretsTable } = await import('../db/schema'); for (const imp of manifest.variables.imports) { if (imp.source !== 'capability') continue; // Parse "capability_name.path.to.value" from the 'from' field const dotIndex = imp.from.indexOf('.'); if (dotIndex === -1) continue; const capName = imp.from.substring(0, dotIndex); const dataPath = imp.from.substring(dotIndex + 1); // Look up capability data const capData = context.capabilities[capName] as Record | undefined; if (!capData) continue; // Find the provider module ID for resolving $self: references const capRecord = db .select() .from(capabilities) .where(eq(capabilities.capabilityName, capName)) .get(); // Resolve the value from capability data (supports nested paths) let value: unknown = capData; for (const key of dataPath.split('.')) { if (value && typeof value === 'object' && key in (value as Record)) { value = (value as Record)[key]; } else { value = undefined; break; } } // Resolve $self: references against the provider module's configs if (typeof value === 'string' && value.startsWith('$self:') && capRecord) { const selfKey = value.substring(6); // Strip "$self:" // First try module_configs (directly configured values) const providerConfig = db .select() .from(moduleConfigs) .where( and(eq(moduleConfigs.moduleId, capRecord.moduleId), eq(moduleConfigs.key, selfKey)), ) .get(); value = providerConfig?.valueJson ? JSON.parse(providerConfig.valueJson) : providerConfig?.value; // If not found in configs, try the provider's resolution context // (handles derive_from like "$system:primary_domain") if (value === undefined || value === null) { try { const providerContext = await buildResolutionContext(capRecord.moduleId, db); value = providerContext.selfConfig[selfKey]; } catch { // Resolution context may fail for various reasons — skip } } } // If not found in capability data, check capability secrets if (value === undefined && capRecord) { const providerSecrets = db .select() .from(secretsTable) .where(eq(secretsTable.moduleId, capRecord.moduleId)) .all(); if (providerSecrets.length > 0) { const masterKey = await getOrCreateMasterKey(); for (const s of providerSecrets) { if (s.name === dataPath) { value = decryptSecret( { encryptedValue: s.encryptedValue, iv: s.iv, authTag: s.authTag }, masterKey, ); break; } } } } if (value !== undefined) { // Store in module_configs so it appears in host_vars upsertModuleConfig( db, moduleId, imp.name, value as string | number | boolean | unknown[] | Record, ); // Redact sensitive values in logs const isSensitive = imp.name.includes('password') || imp.name.includes('secret') || imp.name.includes('key') || imp.name.includes('token'); const display = isSensitive ? '[redacted]' : typeof value === 'string' ? value.length > 40 ? `${value.substring(0, 40)}...` : value : JSON.stringify(value).substring(0, 40); log.info(`Resolved capability variable: ${imp.name} = ${display}`); } } } // Execution: Discover template files const allTemplates: string[] = []; for (const dir of TEMPLATE_DIRS) { if (dir.endsWith('terraform') && infrastructureSelection?.type === 'machine') { log.success('Terraform skipped (using existing machine)'); continue; } const dirPath = join(modulePath, dir); if (existsSync(dirPath) && statSync(dirPath).isDirectory()) { const templates = await discoverTemplateFiles(dirPath, dirPath); // Prepend directory name to paths allTemplates.push(...templates.map((t) => join(dir, t))); } } if (allTemplates.length === 0) { // Config-only modules (e.g., namecheap registrar) have no templates — that's valid. // They provide capabilities and store configuration but don't generate infrastructure files. await mkdir(outputPath, { recursive: true }); return { success: true, files: [], outputPath, }; } // Execution: Read template files let templateFiles: TemplateFile[]; try { templateFiles = await readTemplateFiles(modulePath, allTemplates); } catch (error) { return { success: false, error: 'Failed to read template files', details: error, }; } // Policy: Resolve variables in templates or copy Ansible files as-is const generatedFiles: GeneratedFile[] = []; const resolutionErrors: Array<{ file: string; errors: Array<{ variable: string; error: string }>; }> = []; for (const template of templateFiles) { // Check if this is a pure Ansible file (should be copied as-is) const isPureAnsibleFile = isCopyAsIsFile(template.sourcePath); if (isPureAnsibleFile) { // Ansible files (playbook.yml, tasks/main.yml, etc.) - copy without resolution generatedFiles.push({ path: template.targetPath, content: template.content, }); } else { // Template files (.tpl, .j2) - apply variable resolution const isAnsibleTemplate = template.targetPath.includes('ansible/'); // Framework-owned DNS-at-birth: stamp nameserver + ignore_changes onto // every proxmox_lxc resource before resolution (terraform files only). const content = !isAnsibleTemplate && template.targetPath.endsWith('.tf') ? injectProxmoxDns( omitUntaggedProxmoxVlan(template.content, context.selfConfig.vlan !== undefined), Boolean(context.selfConfig.lxc_nameserver), ) : template.content; const result = isAnsibleTemplate ? await convertSecretsToJinja(content, context, db) : await resolveTemplate(content, context, db); if (!result.success) { resolutionErrors.push({ file: template.targetPath, errors: result.errors, }); } else { generatedFiles.push({ path: template.targetPath, content: result.content, }); } } } if (resolutionErrors.length > 0) { const errorMessages = resolutionErrors .map((e) => { const varErrors = e.errors.map((ve) => ` ${ve.variable}: ${ve.error}`).join('\n'); return `${e.file}:\n${varErrors}`; }) .join('\n\n'); return { success: false, error: `Failed to resolve variables in templates:\n${errorMessages}`, }; } // Execution: Write generated files (templates only, secrets and inventory handled separately) try { await writeGeneratedFiles(outputPath, generatedFiles); } catch (error) { return { success: false, error: 'Failed to write generated files', details: error, }; } // Execution: Copy Ansible role-local `files/` directories verbatim. // These hold static assets (binaries, certs, scripts, blobs) used by // Ansible's `copy` module. They're outside the template pipeline because // (a) they don't need variable resolution and (b) they may be binary — // utf-8 round-tripping would corrupt them. The template pipeline only // handles .tpl/.j2/.yml/.yaml/.tf files. try { await copyAnsibleRoleFilesDirs(modulePath, outputPath); } catch (error) { return { success: false, error: 'Failed to copy Ansible role files/ directories', details: error, }; } // Execution: Copy capability-provided Ansible tasks into generated output // When a module requires capabilities that provide Ansible tasks (e.g., dns_registrar), // copy those tasks so the consumer's playbook can include them. if (manifest.requires?.capabilities) { for (const req of manifest.requires.capabilities) { const capRecord = db .select() .from(capabilities) .where(eq(capabilities.capabilityName, req.name)) .get(); if (!capRecord) continue; const providerModule = db .select() .from(modules) .where(eq(modules.id, capRecord.moduleId)) .get(); if (!providerModule) continue; const providerManifest = providerModule.manifestData as ModuleManifest; const capDef = providerManifest.provides?.capabilities?.find((c) => c.name === req.name); const ansibleTasksPath = capDef?.data?.ansible_tasks as string | undefined; if (ansibleTasksPath) { // Copy the task file from the provider module to the consumer's generated output const srcFile = join(providerModule.sourcePath, ansibleTasksPath); const destDir = join(outputPath, 'ansible', 'registrar'); const destFile = join(destDir, 'set-nameservers.yml'); if (existsSync(srcFile)) { await mkdir(destDir, { recursive: true }); const taskContent = await readFile(srcFile, 'utf-8'); await writeFile(destFile, taskContent, 'utf-8'); log.info(`Copied registrar tasks from ${capRecord.moduleId} module`); // Store registrar_tasks_path in module_configs so it appears in host_vars const tasksPathValue = '{{ playbook_dir }}/registrar'; const existingTasksPath = db .select() .from(moduleConfigs) .where( and( eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, 'registrar_tasks_path'), ), ) .get(); upsertModuleConfig(db, moduleId, 'registrar_tasks_path', tasksPathValue); // (existingTasksPath select above is now redundant — left for // the broader refactor; upsertModuleConfig is idempotent.) void existingTasksPath; } } } } // Store build_artifacts_dir in module_configs so it flows into host_vars. // Modules with build artifacts (e.g., compiled binaries, static assets) need // this path so Ansible can copy them to the target machine. if (manifest.build?.artifacts && manifest.build.artifacts.length > 0) { upsertModuleConfig(db, moduleId, 'build_artifacts_dir', modulePath); } // Execution: Generate Ansible inventory structure const inventoryResult = await generateInventory( moduleId, outputPath, db, infrastructureSelection || undefined, ); if (!inventoryResult.success) { return { success: false, error: 'Failed to generate Ansible inventory', details: inventoryResult.error, }; } // Execution: Generate encrypted Ansible secrets file (after inventory) // Place in group_vars/all/ so Ansible auto-loads vault-encrypted vars const ansiblePath = join(outputPath, 'ansible'); const secretsYamlPath = join(ansiblePath, 'inventory', 'group_vars', 'all', 'secrets.yml'); const secretsResult = await generateAnsibleSecrets(moduleId, secretsYamlPath, db); if (!secretsResult.success) { return { success: false, error: 'Failed to generate Ansible secrets', details: secretsResult.error, }; } // Combine all generated files for reporting const allFiles = [...generatedFiles]; // Add inventory files if (inventoryResult.files) { for (const filePath of inventoryResult.files) { allFiles.push({ path: filePath, content: '(inventory file)', }); } } // Add secrets.yml if (secretsResult.encryptedPath) { allFiles.push({ path: 'ansible/secrets.yml', content: '(encrypted with ansible-vault)', }); } // Execution: Generate Ansible dependency documentation try { const ansibleDocs = await generateAnsibleDocs(moduleId, outputPath, db); for (const doc of ansibleDocs) { const docPath = join(outputPath, doc.path); const docDir = dirname(docPath); await mkdir(docDir, { recursive: true }); await writeFile(docPath, doc.content, 'utf-8'); allFiles.push(doc); } } catch (error) { // Non-fatal - documentation is optional console.warn('Warning: Failed to generate Ansible documentation', error); } // Build infrastructure info for CLI output let infrastructureInfo: import('./types').InfrastructureInfo | undefined; if (infrastructureSelection) { const zone = getSingularSystemSpec(manifest)?.zone || 'unknown'; if (infrastructureSelection.type === 'machine' && infrastructureSelection.machineId) { const machine = await db .select() .from(machines) .where(eq(machines.id, infrastructureSelection.machineId)) .get(); infrastructureInfo = { type: 'machine', machineId: infrastructureSelection.machineId, machineName: machine?.hostname, zone, }; } else if ( infrastructureSelection.type === 'container_service' && infrastructureSelection.serviceId ) { const service = await db .select() .from(containerServices) .where(eq(containerServices.id, infrastructureSelection.serviceId)) .get(); infrastructureInfo = { type: 'container_service', serviceId: infrastructureSelection.serviceId, serviceName: service?.name, zone, }; } } return { success: true, files: allFiles, outputPath, infrastructure: infrastructureInfo, }; }