/** * Module config drift check. * * For each installed module, walks `manifest.variables.owns` looking * for variables that *must* have a value but don't. The check * ignores `source != 'user'` variables — those are auto-derived from * infrastructure / capability data and aren't supposed to be set by * the user. * * Severity rules: * - DEPLOYED module (state=INSTALLED|VERIFIED) with `required: true` * AND no current value AND no `default:` → BLOCKED. The module is * live and a config gap is a real divergence: the next deploy * would fail, capability consumers may be reading the unset value, * etc. * - NON-DEPLOYED module (IMPORTED, etc.) → TODO. The deploy * interview will collect required values when the operator * eventually runs `module deploy`. Telling them to manually * `module config set` is bad UX — the interview is the canonical * path. Surfacing as TODO keeps the visibility without escalating * the verdict. * - Variable has a `default:` (any state) → no finding. The default * resolves the value. */ import type { ModuleManifest, VariableDeclare } from '../../manifest/schema'; import type { DriftFinding } from './types'; const DEPLOYED_STATES = new Set(['INSTALLED', 'VERIFIED']); export interface InstalledModuleConfig { id: string; /** Lifecycle state from the modules table — drives severity. */ state: string; manifest: ModuleManifest; /** Map of config key → current value (string for primitives, parsed object for complex). */ configs: Record; } export interface ModuleConfigsAuditDeps { modules: InstalledModuleConfig[]; } function isUnset(value: unknown): boolean { return value === undefined || value === null || value === ''; } function ownVariableFindings( moduleId: string, state: string, variable: VariableDeclare, currentValue: unknown, ): DriftFinding[] { // Only audit user-sourced variables. Infrastructure/capability/system/ // terraform variables are auto-derived elsewhere; complaining about // them being unset is noise. if (variable.source !== 'user') return []; if (!isUnset(currentValue)) return []; const hasDefault = variable.default !== undefined && variable.default !== null; if (!variable.required || hasDefault) return []; // BLOCKED only when the module is currently deployed. For // IMPORTED-and-similar pre-deploy states, the deploy interview // collects this value automatically — surfacing as a blocker // wrongly directs the operator at `module config set` (a manual // workaround) when the right command is `module deploy `. const severity = DEPLOYED_STATES.has(state) ? 'blocked' : 'todo'; const remediation = DEPLOYED_STATES.has(state) ? `celilo module config set ${moduleId} ${variable.name} ` : `celilo module deploy ${moduleId}`; return [ { category: 'module_configs', severity, code: 'module_config_required_unset', message: `${moduleId}: required config "${variable.name}" is not set`, details: variable.description, remediation, actionable: true, subject: moduleId, }, ]; } export async function auditModuleConfigs(deps: ModuleConfigsAuditDeps): Promise { const findings: DriftFinding[] = []; for (const m of deps.modules) { const owned = m.manifest.variables?.owns ?? []; for (const variable of owned) { findings.push(...ownVariableFindings(m.id, m.state, variable, m.configs[variable.name])); } } return findings; }