/** * Proxmox reconciliation (openspec/specs/base-module-aspects/spec.md D5). * * When a base-module aspect declares `proxmox_reconcile.tfvars` and * its fan-out plan touches Proxmox-provisioned LXCs, the running * config (Ansible writes /etc/resolv.conf) is only half the story: * the LXC's persisted Proxmox terraform config also needs the new * value, otherwise a re-provision would revert to a stale resolver * and break the fleet. * * SC5 SCOPE (this file): * - Plan: pure function that takes the aspect + fan-out targets * + resolution context, returns one action per affected LXC. * - Execute: surfaces a clear WARN per action, documenting which * tfvar would be updated on which owning-module's terraform * state. * * DEFERRED to a follow-up (Phase 1c / Phase 2): * - Actually running terraform apply per LXC owning-module. The * value needs to flow through celilo's persistent state * (system_config / module_config) AND be picked up by the * owning module's terraform template on next regular deploy, * not just injected as a one-off -var. Doing that right * requires (a) a celilo system-config key with a clear * semantic, (b) updates to every Proxmox-using module's * terraform template to read from it, and (c) reconciliation * against existing tfvars files so the next deploy doesn't * overwrite. * * That's meaningful design work and the simulator's Phase 1 * e2e doesn't have a real Proxmox to validate against. The * responsible move is to ship the SHAPE (manifest schema + * planning + observable warn-on-skip), defer the persistence * layer, and pick it up when production rollout actually * touches Proxmox LXCs. */ import { log } from '../cli/prompts'; import type { getDb } from '../db/client'; import type { BaseModuleAspect } from '../manifest/schema'; import { resolveAspectTemplateRecord } from './aspect-template-resolver'; import { type ContainerSystem, getContainerSystemsByZone } from './machine-pool'; type DbClient = ReturnType; export interface ProxmoxReconcileAction { /** The owning module of the affected LXC — its terraform state holds the var. */ moduleId: string; /** The container_service this LXC was provisioned through. */ serviceId: string; /** Resolved tfvar updates, mapping tfvar name → final string value. */ tfvarUpdates: Record; /** Source LXC for logging / observability. */ containerSystem: ContainerSystem; } export interface ProxmoxReconcilePlan { actions: ProxmoxReconcileAction[]; /** Containers that matched the zones but were skipped (non-Proxmox provider). */ skipped: Array<{ system: ContainerSystem; reason: string }>; } /** * Planning phase: figure out which Proxmox LXCs in the aspect's * zone scope would need a terraform-config update. Pure aside from * the DB reads (container_service rows + value resolution). * * The `providerModuleId` is the module whose aspect we're acting * on (e.g., knot-unbound-internal). Its config + capability data * is what tfvar value templates resolve against — the aspect knows * its own service's IP, the resolution context just makes that * available. */ export async function planProxmoxReconcile(args: { aspect: BaseModuleAspect; providerModuleId: string; db: DbClient; }): Promise { const { aspect, providerModuleId, db } = args; if (!aspect.proxmox_reconcile) { return { actions: [], skipped: [] }; } const systems = await getContainerSystemsByZone(aspect.applicable_zones); const actions: ProxmoxReconcileAction[] = []; const skipped: ProxmoxReconcilePlan['skipped'] = []; for (const sys of systems) { if (sys.providerName !== 'proxmox') { // DO droplets / future providers don't ride the proxmox_lxc // terraform path. The aspect's Ansible run still hit them; // they just don't get a tfvar-overlay update from this // mechanism. skipped.push({ system: sys, reason: `non-proxmox provider (${sys.providerName})` }); continue; } // Resolve every tfvar template against the provider module's // context. Any resolution failure surfaces as a thrown error // up to the caller — aspect authors are expected to declare // resolvable templates. const tfvarUpdates = await resolveAspectTemplateRecord( aspect.proxmox_reconcile.tfvars, providerModuleId, db, 'proxmox_reconcile.tfvars', ); actions.push({ moduleId: sys.moduleId, serviceId: sys.serviceId, tfvarUpdates, containerSystem: sys, }); } return { actions, skipped }; } /** * Execution phase. **Currently surfaces operator warnings instead * of running terraform** — see file header for why. * * The warning is structured so operators see exactly which * Proxmox LXC has drifted from its desired tfvar state and can * manually reconcile via: * * celilo module deploy * * (which re-runs that module's terraform with whatever the * current celilo state says the tfvars should be). * * When the persistence layer lands, this function gains a real * terraform-apply path; the planning function above stays * unchanged. */ export function executeProxmoxReconcile(plan: ProxmoxReconcilePlan): void { if (plan.actions.length === 0) { return; } log.warn( `Proxmox reconciliation pending (${plan.actions.length} LXC${plan.actions.length === 1 ? '' : 's'} affected). The aspect's Ansible run updated the running config on each, but the persisted Proxmox terraform config has NOT been changed. Re-provisioning would revert. Affected:`, ); for (const action of plan.actions) { const tfvarSummary = Object.entries(action.tfvarUpdates) .map(([k, v]) => `${k}=${v}`) .join(', '); log.warn( ` - LXC owned by module '${action.moduleId}' (service ${action.serviceId}, vmid ${(action.containerSystem.containerMetadata as { vmid?: string | number } | null)?.vmid ?? '?'}): would set ${tfvarSummary}`, ); } log.warn( 'To persist these values, re-deploy the owning modules after the underlying celilo state catches up. Tracked as Phase 1c in openspec/specs/base-module-aspects/spec.md.', ); }