/** * Module status command - show detailed information about a module */ import { eq } from 'drizzle-orm'; import { getDb } from '../../db/client'; import { capabilities, moduleConfigs, modules, secrets } from '../../db/schema'; import type { ModuleManifest } from '../../manifest/schema'; import { HEALTH_CHECK_INTERVAL_CONFIG_KEY, effectiveHealthCheckCadence, } from '../../services/alerting/health-cadence'; import { BACKUP_RETENTION_COUNT_CONFIG_KEY, BACKUP_RETENTION_MAX_AGE_DAYS_CONFIG_KEY, effectiveBackupRetention, prunesNothing, } from '../../services/backup-retention'; import { BACKUP_SCHEDULE_CONFIG_KEY, effectiveBackupSchedule, } from '../../services/backup-schedule'; import { formatCadence } from '../../services/cadence'; import { declaredVariables, isDerivedVariable } from '../../services/config-provenance'; import { getModuleSystems } from '../../services/deployed-systems'; import { configOverride, parseStoredConfigValue } from '../../services/module-config'; import { formatPlacementLine, reconcilePlacement } from '../../services/placement-reconcile'; import { getArg, validateRequiredArgs } from '../parser'; import type { CommandResult } from '../types'; /** * PURE (Rule 10.1): the per-module policy block — what celilo will do to this * module, and on whose authority. * * Both halves are shown deliberately. The stored override alone does not tell * an operator what they changed it FROM, and the effective value alone does not * tell them whether they set it or the module's author did. */ export function formatCadencePolicy(input: { manifest: ModuleManifest; configs: Record; }): string { const { manifest, configs } = input; const lines = ['Policy:']; if (manifest.hooks?.on_backup) { const override = configOverride(configs, BACKUP_SCHEDULE_CONFIG_KEY); const effective = formatCadence(effectiveBackupSchedule(manifest, override)); const suggested = manifest.backup?.schedule; if (override !== undefined) { lines.push( ` backup cadence: ${effective} (operator override; manifest suggests ${suggested ?? 'nothing'})`, ); } else if (suggested !== undefined) { lines.push(` backup cadence: ${effective} (from the manifest)`); } else { lines.push(` backup cadence: ${effective} (celilo default; nothing declared or set)`); } lines.push(` backup retention: ${describeRetention(manifest, configs)}`); } else { lines.push(' backup cadence: not backed up (module declares no on_backup hook)'); } if (manifest.hooks?.health_check) { const override = configOverride(configs, HEALTH_CHECK_INTERVAL_CONFIG_KEY); const effective = effectiveHealthCheckCadence(manifest, override); const suggested = manifest.hooks.health_check.interval; const value = effective === null ? 'not watched (no cadence set)' : formatCadence(effective); if (override !== undefined) { lines.push( ` health check: ${value} (operator override; manifest suggests ${suggested ?? 'nothing'})`, ); } else if (suggested !== undefined) { lines.push(` health check: ${value} (from the manifest)`); } else { lines.push(` health check: ${value}`); } } else { lines.push(' health check: not watched (module declares no health_check hook)'); } return lines.join('\n'); } /** * Retention in one line, per dimension, saying "unbounded" rather than a number * wherever nothing bounds it. An operator reading a bound they never set would * reasonably assume backups are being deleted — and the reverse assumption, * that something is pruning when nothing is, is how a disk fills. */ function describeRetention(manifest: ModuleManifest, configs: Record): string { const policy = effectiveBackupRetention(manifest, configs); if (prunesNothing(policy)) return 'none — every backup is kept'; const declared = manifest.backup?.retention; const dimension = ( effective: number, override: string | undefined, suggested: number | undefined, unit: string, ): string => { if (effective === Number.POSITIVE_INFINITY) return `unbounded ${unit}`; const source = override !== undefined ? `operator override; manifest suggests ${suggested ?? 'nothing'}` : 'from the manifest'; return `${effective} ${unit} (${source})`; }; return [ dimension( policy.count, configOverride(configs, BACKUP_RETENTION_COUNT_CONFIG_KEY), declared?.count, 'copies', ), dimension( policy.maxAgeDays, configOverride(configs, BACKUP_RETENTION_MAX_AGE_DAYS_CONFIG_KEY), declared?.max_age_days, 'days', ), ].join(', '); } /** * Handle module status command * * Usage: celilo module status * * @param args - Command arguments * @returns Command result */ export async function handleModuleStatus(args: string[]): Promise { // Validate arguments const error = validateRequiredArgs(args, 1); if (error) { return { success: false, error: `${error}\n\nUsage: celilo module status `, }; } const moduleId = getArg(args, 0); 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}`, }; } // Get configuration values const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all(); // Get secrets (names only, not values) const moduleSecrets = db.select().from(secrets).where(eq(secrets.moduleId, moduleId)).all(); // Get capabilities const moduleCapabilities = db .select() .from(capabilities) .where(eq(capabilities.moduleId, moduleId)) .all(); // Build sections as multi-line blocks joined by \n\n. index.ts writes the // whole message to stdout verbatim, so the blank line between sections is // exactly what the operator sees. const sections: string[] = []; // Section 1: Module metadata const metadataLines = [ `Module: ${module.id}`, `Name: ${module.name}`, `Version: ${module.version}`, `State: ${module.state}`, ]; if (module.description) { metadataLines.push(`Description: ${module.description}`); } metadataLines.push(`Source: ${module.sourcePath}`); metadataLines.push(`Imported: ${module.importedAt.toISOString()}`); metadataLines.push(`Updated: ${module.updatedAt.toISOString()}`); if (module.errorMessage) { metadataLines.push(`Error: ${module.errorMessage}`); } sections.push(metadataLines.join('\n')); // Section 1b: Placement (real node reconciled live from Proxmox — ISS-0060). // Shows where each deployed system ACTUALLY lives, not the cached // __infra_target_node config (which drifts). API-only modules (no deployed // systems) skip this section; a Proxmox outage degrades to "node unknown". const placements = await reconcilePlacement(getModuleSystems(moduleId, db)); if (placements.length > 0) { const placementLines = ['Placement (live from Proxmox):']; for (const p of placements) { placementLines.push(` ${formatPlacementLine(p.system, p.resolution)}`); } sections.push(placementLines.join('\n')); } // Section 2: Configuration, split by who owns each value. A flat list // 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. // `source` is the authority for the split, never the presence of a // `derive_from` — see services/config-provenance.ts. const declared = declaredVariables(module.manifestData as ModuleManifest); const isDerivedKey = (key: string) => { const variable = declared.get(key); return variable !== undefined && isDerivedVariable(variable); }; // `value` is the human-readable display form (e.g. "test-host" for a string, // "2222" for a number, JSON-stringified for complex types) — populated by // upsertModuleConfig alongside the canonical valueJson. Using it here keeps // the status output free of JSON-quote noise around primitives. const userConfigs = configs.filter((c) => !isDerivedKey(c.key)); const derivedConfigs = configs.filter((c) => isDerivedKey(c.key)); if (userConfigs.length > 0) { sections.push( ['Configuration:', ...userConfigs.map((c) => ` ${c.key}: ${c.value}`)].join('\n'), ); } else { sections.push('Configuration: (none)'); } if (derivedConfigs.length > 0) { sections.push( [ 'Derived by celilo (not settable):', ...derivedConfigs.map((c) => ` ${c.key}: ${c.value} [${declared.get(c.key)?.source}]`), ].join('\n'), ); } // Section 2b: Per-module policy — what celilo will DO to this module, and // whether that came from the operator or from the module's author. A raw // config key does not tell an operator what the manifest said, and an // effective value alone does not tell them whether they are the one who set // it. Both, always. sections.push( formatCadencePolicy({ manifest: module.manifestData as ModuleManifest, configs: Object.fromEntries(configs.map((c) => [c.key, parseStoredConfigValue(c)])), }), ); // Section 3: Secrets if (moduleSecrets.length > 0) { const secretLines = ['Secrets:']; for (const secret of moduleSecrets) { secretLines.push(` ${secret.name}: ********`); } sections.push(secretLines.join('\n')); } else { sections.push('Secrets: (none)'); } // Section 4: Capabilities if (moduleCapabilities.length > 0) { const capabilityLines = ['Provides Capabilities:']; for (const capability of moduleCapabilities) { capabilityLines.push(` ${capability.capabilityName} (v${capability.version})`); } sections.push(capabilityLines.join('\n')); } else { sections.push('Provides Capabilities: (none)'); } return { success: true, message: sections.join('\n\n'), data: { module, configs, secrets: moduleSecrets.map((s) => ({ name: s.name })), capabilities: moduleCapabilities, }, }; }