/** * `celilo module pause ` / `celilo module unpause `. * * A thin adapter (Rule 10.5): parse flags, build the plan, confirm, execute, * render. All the decisions live in `services/module-pause.ts` (pure planning) * and the injected deps below (the side effects). * * See openspec/changes/module-pause-lifecycle/. */ import { join } from 'node:path'; import { eq } from 'drizzle-orm'; import { ProxmoxClient } from '../../api-clients/proxmox'; import { getModuleStoragePath } from '../../config/paths'; import { type DbClient, getDb } from '../../db/client'; import { moduleSystems, modules } from '../../db/schema'; import type { ModuleManifest } from '../../manifest/schema'; import { ModuleManifestSchema } from '../../manifest/schema'; import { askConfirm, withInterviewSession } from '../../services/bus-interview'; import { getServiceCredentials } from '../../services/container-service'; import { deployModule } from '../../services/module-deploy'; import { checkInFlight } from '../../services/module-operations'; import { completeOperation, failOperation, startOperation } from '../../services/module-operations'; import { type ExecutionReport, type InfraStopOutcome, type ModuleSnapshot, type PauseDeps, type PausePlan, PauseRefusedError, actedOn, describeMachineStopInfra, executePause, executeUnpause, planPause, planUnpause, } from '../../services/module-pause'; import { registerModuleSubscriptions, unregisterModuleSubscriptions, } from '../../services/module-subscriptions'; import { getArg, hasFlag } from '../parser'; import { log } from '../prompts'; import type { CommandResult } from '../types'; /** * Every module celilo knows about, in the shape the planner wants. The planner * is pure, so the whole fleet is read once here rather than queried per step. * * A module whose stored manifest no longer parses is dropped rather than * failing the command: it cannot be a graph node, and refusing to pause the * fleet because one unrelated manifest went stale would be the wrong trade. */ function loadFleet(db: DbClient): ModuleSnapshot[] { const snapshots: ModuleSnapshot[] = []; for (const row of db.select().from(modules).all()) { const parsed = ModuleManifestSchema.safeParse(row.manifestData); if (!parsed.success) continue; snapshots.push({ id: row.id, state: row.state, pausedAt: row.pausedAt, pauseReason: row.pauseReason, manifest: parsed.data, }); } return snapshots; } /** moduleId → description, for the in-flight refusal (task 3.8). */ function inFlightByModule(): Map { const map = new Map(); for (const conflict of checkInFlight()) { map.set(conflict.operation.moduleId, conflict.describe); } return map; } /** * `--stop-infra` (design D2, revised). Opt-in, and deliberately NOT what a pause * means: pausing `caddy` to swap the *firewall* must not take every website * down. * * It acts ONLY on infrastructure celilo provisioned for this module: * - celilo-provisioned LXC/VM -> stopped; celilo created it, so it is celilo's * - machine-pool system -> not applicable, by design (see below) * - systemless driver -> nothing to stop * * The flag is a convenience for "I actually want the box off". Pause's real job * is control-plane quiescence, and the provider swap this feature exists for * never needs the flag at all. */ async function stopModuleInfrastructure(db: DbClient, moduleId: string): Promise { const systems = db.select().from(moduleSystems).where(eq(moduleSystems.moduleId, moduleId)).all(); if (systems.length === 0) { // A driver module (`greenwave`, `axon`, `namecheap`) declares no // `requires.system` — it talks to a device over HTTP. Nothing to stop, and // that is a normal outcome to report, not an error (spec scenario // "Shutdown on a systemless driver module reports nothing to stop"). return { stopped: false, detail: 'no infrastructure to stop' }; } const detail: string[] = []; for (const system of systems) { if (system.infraType === 'machine') { // NOT APPLICABLE, by design — not a missing feature (design D2, revised). // // `--stop-infra` acts only on infrastructure celilo PROVISIONED for the // module. A machine-pool system is operator-pre-provisioned: it may // predate celilo, and it may run things celilo has never heard of — not // merely other celilo modules, but arbitrary operator work. Powering it // off, or stopping services on it, reaches outside what celilo owns. // // Same principle as the sizing rule: a module must not own a host-level // fact, because the host outlives any one module's config. Framing this // as "celilo cannot identify the service unit" would be wrong — it // implies a capability gap, when the answer is that this is not celilo's // to stop. detail.push(describeMachineStopInfra(system.hostname, moduleId)); continue; } if (system.vmid == null || !system.serviceId) { detail.push(`${system.hostname}: no container recorded, nothing to stop`); continue; } const credentials = await getServiceCredentials(system.serviceId); if (!('api_url' in credentials)) { detail.push(`${system.hostname}: container service is not Proxmox, nothing to stop`); continue; } const client = new ProxmoxClient(credentials); const result = await client.setGuestPower(system.vmid, 'lxc', 'shutdown'); detail.push( result.success ? `stopped ${system.hostname} (vmid ${system.vmid})` : `could not stop ${system.hostname} (vmid ${system.vmid}): ${result.message}`, ); } return { stopped: true, detail: detail.join('; ') }; } function buildDeps(db: DbClient): PauseDeps { return { db, unsubscribe: (moduleId) => { // The primary quiescence mechanism: with no subscriber rows the // dispatcher has nothing to deliver to. `run-named-hook` guards the // paths that do not go through the bus. unregisterModuleSubscriptions(moduleId); }, resubscribe: (moduleId) => { // A deploy does NOT re-register subscriptions (only import and // `module update` do), so unpause has to — otherwise the module comes // back deployed but permanently deaf. const row = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!row) return; const manifest = row.manifestData as ModuleManifest; registerModuleSubscriptions(manifest, join(getModuleStoragePath(), moduleId)); }, redeploy: async (moduleId) => { try { const result = await deployModule(moduleId, db, {}); return { success: result.success, error: result.error }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; } }, stopInfrastructure: (moduleId) => stopModuleInfrastructure(db, moduleId), startOperation, completeOperation, failOperation, now: () => new Date(), log: (message) => log.info(message), }; } /** The ordered plan, rendered for `--dry-run` and for the confirmation. */ function renderPlan(plan: PausePlan): string { const verb = plan.action === 'pause' ? 'Pause' : 'Unpause'; const order = plan.action === 'pause' ? 'consumers first' : 'providers first'; const lines = [ plan.cascade ? `${verb} ${plan.requested} and its transitive consumers (${order}):` : `${verb} ${plan.requested}:`, ]; plan.steps.forEach((step, index) => { const suffix = step.disposition === 'act' ? '' : ` — skip (${step.note})`; lines.push(` ${index + 1}. ${step.moduleId}${suffix}`); }); if (plan.stopInfra) { lines.push('', 'Infrastructure will also be stopped (--stop-infra).'); } return lines.join('\n'); } function renderReport(report: ExecutionReport): string { const lines = report.outcomes.map((o) => { const mark = o.result === 'acted' ? '✓' : o.result === 'skipped' ? '·' : '✗'; return ` ${mark} ${o.moduleId}${o.detail ? ` — ${o.detail}` : ''}`; }); const acted = report.outcomes.filter((o) => o.result === 'acted').length; const skipped = report.outcomes.filter((o) => o.result === 'skipped').length; const failed = report.outcomes.filter((o) => o.result === 'failed').length; lines.push('', `${acted} changed, ${skipped} already done, ${failed} failed.`); return lines.join('\n'); } /** * Confirmation for a cascade. An event-bus interview question, never a stdin * prompt (design D6), so the operation is drivable headlessly — by CI, by the * MCP, by a remote responder. `--yes` satisfies it without asking. */ async function confirmCascade(plan: PausePlan, yes: boolean): Promise { if (!plan.cascade || yes) return true; const affected = actedOn(plan); if (affected.length === 0) return true; return withInterviewSession(() => askConfirm({ scope: `module-${plan.action}:${plan.requested}`, key: 'cascade', message: `${plan.action === 'pause' ? 'Pause' : 'Unpause'} ${affected.length} module(s): ${affected.join(', ')}?`, description: renderPlan(plan), defaultValue: false, }), ); } export async function handleModulePause( args: string[], flags: Record = {}, ): Promise { const moduleId = getArg(args, 0); if (!moduleId) { return { success: false, error: 'Module ID is required\n\nUsage: celilo module pause [--cascade] [--stop-infra] [--dry-run] [--yes] [--reason "..."]', }; } const db = getDb(); const reason = typeof flags.reason === 'string' ? flags.reason : null; let plan: PausePlan; try { plan = planPause({ moduleId, fleet: loadFleet(db), cascade: hasFlag(flags, 'cascade'), stopInfra: hasFlag(flags, 'stop-infra'), inFlight: inFlightByModule(), }); } catch (err) { if (err instanceof PauseRefusedError) return { success: false, error: err.message }; throw err; } if (hasFlag(flags, 'dry-run')) { return { success: true, message: `${renderPlan(plan)}\n\n(dry run — nothing was changed)` }; } if (!(await confirmCascade(plan, hasFlag(flags, 'yes')))) { return { success: false, error: 'Cancelled — nothing was paused' }; } const report = await executePause(plan, buildDeps(db), reason); return report.success ? { success: true, message: renderReport(report) } : { success: false, error: renderReport(report) }; } export async function handleModuleUnpause( args: string[], flags: Record = {}, ): Promise { const moduleId = getArg(args, 0); if (!moduleId) { return { success: false, error: 'Module ID is required\n\nUsage: celilo module unpause [--cascade] [--dry-run] [--yes]', }; } const db = getDb(); let plan: PausePlan; try { plan = planUnpause({ moduleId, fleet: loadFleet(db), cascade: hasFlag(flags, 'cascade'), }); } catch (err) { if (err instanceof PauseRefusedError) return { success: false, error: err.message }; throw err; } if (hasFlag(flags, 'dry-run')) { return { success: true, message: `${renderPlan(plan)}\n\n(dry run — nothing was changed)` }; } if (!(await confirmCascade(plan, hasFlag(flags, 'yes')))) { return { success: false, error: 'Cancelled — nothing was unpaused' }; } const report = await executeUnpause(plan, buildDeps(db)); return report.success ? { success: true, message: renderReport(report) } : { success: false, error: renderReport(report) }; }