/** * Module config command */ import { eq } from 'drizzle-orm'; import { z } from 'zod'; import { getDb } from '../../db/client'; import { modules } from '../../db/schema'; import type { ModuleManifest, VariableDeclare } from '../../manifest/schema'; import { HEALTH_CHECK_INTERVAL_CONFIG_KEY, reconcileModuleWatchState, } from '../../services/alerting/health-cadence'; import { BACKUP_RETENTION_COUNT_CONFIG_KEY, BACKUP_RETENTION_MAX_AGE_DAYS_CONFIG_KEY, } from '../../services/backup-retention'; import { BACKUP_SCHEDULE_CONFIG_KEY } from '../../services/backup-schedule'; import { BACKUP_CADENCE_FLOOR_MINUTES, MONITOR_INTERVAL_FLOOR_MINUTES, cadenceSchema, } from '../../services/cadence'; import { declaredVariables, describeDerivedSource, explainNotSettable, isDerivedVariable, } from '../../services/config-provenance'; import { deleteModuleConfig, formatConfigValue, getAllModuleConfigValues, getModuleConfigValue, setModuleConfigValue, } from '../../services/module-config'; import { readResolutionContext } from '../../variables/context'; import { getArg, validateRequiredArgs } from '../parser'; import type { CommandResult } from '../types'; /** * Operator keys that EVERY module accepts, whether or not its manifest declares * them, with their permitted values. * * This is also where a module's per-module POLICY lives: how often to back it * up, how often to health-check it. A manifest states those as the author's * suggestion about a fleet they have never seen; the row an operator writes * here wins, and every reader resolves the two at read time so a corrected * manifest still reaches installs that have not overridden it. * * These describe how celilo TREATS a module (its CD policy), not how the module * configures itself, so gating them on `variables.owns` had it backwards: it * required each module author to opt into being manageable. The failure was * silent — `upgrade_policy` was declared by no module at all, so * `pickUpgradePolicy()` could only ever read `undefined` and fall back to * `by-semver`, leaving `always-safe` (the ONLY control over unattended-upgrade * risk) permanently unreachable. `auto_upgrade` worked only because lunacycle * happened to declare it. See #515. * * Values are checked at SET time rather than coerced at read time. Both readers * fail OPEN on an unrecognized value — `pickUpgradePolicy()` returns * `by-semver`, which for a patch means fast posture and NO backup. So a typo * like `alwayssafe` would leave the operator believing they had armed the safe * floor while nothing changed. A safety control that fails open on a typo is * worse than no control. */ export interface FrameworkConfigKey { /** Accepts the value the operator typed, or explains what it should be. */ schema: z.ZodTypeAny; /** * Why this key is refused rather than coerced. Carried per key rather than * derived from the schema's error: the substance is what a WRONG value would * silently do, and no validator knows that. */ why: string; } const REFUSED_NOT_COERCED_UPGRADE = 'Rejected rather than coerced: an unrecognized value silently falls back to the PERMISSIVE default (upgrade_policy → by-semver, which skips the pre-deploy backup on a patch), so a typo would look like it took effect.'; const REFUSED_NOT_COERCED_CADENCE = 'Rejected rather than coerced: an unrecognized cadence falls back to the manifest\'s suggestion, so a typo would leave the module on the cadence you meant to change — visibly "set", and doing nothing.'; const REFUSED_NOT_COERCED_RETENTION = 'Rejected rather than coerced: an unrecognized retention value falls back to the manifest, so a typo would leave the module keeping a different number of backups than you asked for — and the direction that goes wrong deletes data.'; /** An enum whose rejection message reads as an operator instruction, not a type error. */ function oneOf(values: readonly [string, ...string[]]): z.ZodTypeAny { return z.enum(values, { errorMap: () => ({ message: `Allowed: ${values.join(', ')}` }) }); } /** * A whole number of things, at least one. Zero is refused rather than read as * "keep nothing": a retention of 0 would delete every backup the module has. */ function positiveInteger(what: string): z.ZodTypeAny { return z.string().superRefine((value, ctx) => { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Allowed: a whole number of ${what}, 1 or greater. Unset the key to keep everything.`, }); } }); } export const FRAMEWORK_CONFIG_KEYS: Record = { auto_upgrade: { schema: oneOf(['true', 'false']), why: REFUSED_NOT_COERCED_UPGRADE }, upgrade_policy: { schema: oneOf(['by-semver', 'always-safe', 'always-fast']), why: REFUSED_NOT_COERCED_UPGRADE, }, [BACKUP_SCHEDULE_CONFIG_KEY]: { schema: cadenceSchema({ floorMinutes: BACKUP_CADENCE_FLOOR_MINUTES }), why: REFUSED_NOT_COERCED_CADENCE, }, [HEALTH_CHECK_INTERVAL_CONFIG_KEY]: { schema: cadenceSchema({ floorMinutes: MONITOR_INTERVAL_FLOOR_MINUTES }), why: REFUSED_NOT_COERCED_CADENCE, }, [BACKUP_RETENTION_COUNT_CONFIG_KEY]: { schema: positiveInteger('copies to keep'), why: REFUSED_NOT_COERCED_RETENTION, }, [BACKUP_RETENTION_MAX_AGE_DAYS_CONFIG_KEY]: { schema: positiveInteger('days to keep a backup'), why: REFUSED_NOT_COERCED_RETENTION, }, }; /** * PURE (Rule 10.1): validate a framework key's value. Returns an error message, * or null when the key is not a framework key or the value is permitted. */ export function validateFrameworkConfigValue(key: string, value: string): string | null { const framework = FRAMEWORK_CONFIG_KEYS[key]; if (!framework) return null; const result = framework.schema.safeParse(value); if (result.success) return null; const explanation = result.error.issues.map((issue) => issue.message).join('\n'); return `Invalid value '${value}' for '${key}'.\n\n${explanation}\n\n${framework.why}`; } /** * Handle module config set command * * Usage: celilo module config set * * @param args - Command arguments * @returns Command result */ export async function handleModuleConfigSet(args: string[]): Promise { // Validate arguments const error = validateRequiredArgs(args, 3); if (error) { return { success: false, error: `${error}\n\nUsage: celilo module config set `, }; } const moduleId = getArg(args, 0); const key = getArg(args, 1); const value = getArg(args, 2); if (!moduleId || !key || !value) { return { success: false, error: 'Module ID, key, and value are required', }; } const db = getDb(); // Check if module exists const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) { return { success: false, error: `Module not found: ${moduleId}`, }; } // Framework keys bypass the manifest check entirely — see FRAMEWORK_CONFIG_KEYS. const frameworkError = validateFrameworkConfigValue(key, value); if (frameworkError) return { success: false, error: frameworkError }; const isFrameworkKey = key in FRAMEWORK_CONFIG_KEYS; // Validate key against manifest const manifest = module.manifestData as ModuleManifest; const declaredVars = manifest.variables?.owns ?? []; // Check if key is declared in manifest const declaredVar = declaredVars.find((v) => v.name === key); if (!declaredVar && !isFrameworkKey) { const settableKeys = declaredVars .filter((v) => !isDerivedVariable(v)) .map((v) => v.name) .join(', '); const frameworkKeys = Object.keys(FRAMEWORK_CONFIG_KEYS).join(', '); return { success: false, error: `Invalid config key '${key}' for module ${moduleId}.\n\nValid keys: ${settableKeys || '(none declared)'}\nCelilo-managed keys (any module): ${frameworkKeys}`, }; } // Refuse EVERY derived source, not just `infrastructure`. // // ISS-0069 established the principle — refuse at SET time rather than // accepting-then-silently-overriding at deploy — and then applied it to one // source out of four. So `celilo module config set authentik auth_url …` // (a `capability`-sourced value) reported success, wrote the row, and was // discarded on the next deploy. A command that says "Set config for authentik" // and changes nothing is worse than one that refuses. // // No counter-example survived review of a good reason to pin a derived value: // a derived value computes the right answer from one source of truth, so if // the answer is wrong the source is wrong, and fixing the source fixes every // consumer at once while pinning one module hides the divergence. if (declaredVar && isDerivedVariable(declaredVar)) { return { success: false, error: explainNotSettable(moduleId, declaredVar), }; } // Set config value using service (handles primitive and complex types) try { await setModuleConfigValue(moduleId, key, value); const resolved = settlingWatchState(db, moduleId, key); return { success: true, message: resolved > 0 ? `Set config for ${moduleId}: ${key} (resolved ${resolved} alert(s) — nothing will report on this module again until it is watched)` : `Set config for ${moduleId}: ${key}`, }; } catch (error) { return { success: false, error: `Failed to set config: ${error instanceof Error ? error.message : String(error)}`, }; } } /** * Handle module config unset command * * Usage: celilo module config unset * * Removing an override is what returns a module to following its manifest. * Without it, an operator who once set a cadence could never go back to the * author's suggestion, and every later correction would stop reaching them — * the exact failure read-time resolution exists to prevent, aimed at the * operators who engaged with the feature. * * @param args - Command arguments * @returns Command result */ export async function handleModuleConfigUnset(args: string[]): Promise { const error = validateRequiredArgs(args, 2); if (error) { return { success: false, error: `${error}\n\nUsage: celilo module config unset `, }; } const moduleId = getArg(args, 0); const key = getArg(args, 1); if (!moduleId || !key) { return { success: false, error: 'Module ID and key are required' }; } const db = getDb(); const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) { return { success: false, error: `Module not found: ${moduleId}` }; } // Unsetting a key that was never set SUCCEEDS. `unset` states a desired end // state, and failing on an already-clean one makes it unusable from any // script that cannot check first. if (!getModuleConfigValue(moduleId, key, db)) { return { success: true, message: `No override set for ${moduleId}: ${key}` }; } deleteModuleConfig(db, moduleId, key); settlingWatchState(db, moduleId, key); return { success: true, message: `Unset config for ${moduleId}: ${key} (now follows the module's manifest)`, }; } /** * A module that has just stopped being watched may still own live alerts from * its last scheduled runs, and nothing will ever report on them again. Resolve * them here rather than leaving them firing with no action able to clear them. * No-op for every other key. */ function settlingWatchState(db: ReturnType, moduleId: string, key: string): number { if (key !== HEALTH_CHECK_INTERVAL_CONFIG_KEY) return 0; return reconcileModuleWatchState(db, moduleId, new Date()); } /** * Handle module config get command * * Usage: celilo module config get [key] * * @param args - Command arguments * @returns Command result */ export async function handleModuleConfigGet(args: string[]): Promise { // Validate arguments const error = validateRequiredArgs(args, 1); if (error) { return { success: false, error: `${error}\n\nUsage: celilo module config get [key]`, }; } const moduleId = getArg(args, 0); const key = getArg(args, 1); if (!moduleId) { return { success: false, error: 'Module ID is required', }; } const db = getDb(); // Check if module exists const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) { return { success: false, error: `Module not found: ${moduleId}`, }; } if (key) { // Get specific config value const configValue = getModuleConfigValue(moduleId, key); if (!configValue) { return { success: false, error: `Config key not found: ${key}`, }; } const formatted = formatConfigValue(configValue); return { success: true, message: `${key} = ${formatted}`, data: { key, value: configValue.value }, }; } // Get all config for module, split by who owns each value. // // Printing every row flat presented a value celilo computed as if the // operator had chosen it, which is how a derived value gets "corrected" by // hand and silently reverted. The derived section is also read from the // resolution context rather than the config rows, so it shows what celilo // computes RIGHT NOW — including values that were never written down, the // absence that caused the 2026-08-14 DNS outage. const declared = declaredVariables(module.manifestData as ModuleManifest); const stored = getAllModuleConfigValues(moduleId); const userConfigs = stored.filter((c) => { const variable = declared.get(c.key); return !variable || !isDerivedVariable(variable); }); const derivedValues = await resolveDerivedForDisplay(moduleId, declared, db); if (userConfigs.length === 0 && derivedValues.length === 0) { return { success: true, message: `No configuration set for ${moduleId}`, }; } const lines = [`Configuration for ${moduleId}:`, '']; if (userConfigs.length > 0) { for (const config of userConfigs) { lines.push(`${config.key} = ${formatConfigValue(config)}`); } } else { lines.push('(nothing set by you)'); } if (derivedValues.length > 0) { lines.push('', 'Derived by celilo (not settable — fix the source instead):', ''); for (const derived of derivedValues) { lines.push(`${derived.key} = ${derived.value}`); lines.push(` ${describeDerivedSource(derived.variable)}`); } } return { success: true, message: lines.join('\n'), data: { config: userConfigs.map((c) => ({ key: c.key, value: c.value })), derived: derivedValues.map((d) => ({ key: d.key, value: d.value, source: d.variable.source, })), }, }; } /** * The current value of each derived variable, for display only. * * Uses the side-effect-free resolution context: reading a config must not seed * rows or allocate addresses. A module whose derives cannot resolve yet (an * undeployed provider, an unset system key) reports nothing derived rather than * failing the whole command — `get` is how an operator diagnoses that state, so * it has to survive it. */ async function resolveDerivedForDisplay( moduleId: string, declared: Map, db: ReturnType, ): Promise> { const derivedVars = [...declared.values()].filter(isDerivedVariable); if (derivedVars.length === 0) return []; let selfConfig: Record; try { selfConfig = (await readResolutionContext(moduleId, db)).selfConfig; } catch { // Reported as "not computed yet" below rather than as an error, so the // command still shows the operator their own config. return []; } const resolved: Array<{ key: string; value: string; variable: VariableDeclare }> = []; for (const variable of derivedVars) { const value = selfConfig[variable.name]; resolved.push({ key: variable.name, value: value ?? '(not computed yet)', variable, }); } return resolved; }