/** * Service Reconfigure Command * Re-run the interactive interview for an existing service's provider configuration */ import { type ProxmoxCredentials, type ProxmoxVmTemplate, buildTemplatePath, extractTemplateFilename, listAvailableTemplates, listNodeStorage, } from '../../api-clients/proxmox'; import { askConfirm, askSelect, askText, withInterviewSession } from '../../services/bus-interview'; import { getContainerServiceByServiceId, getServiceCredentials, updateServiceProviderConfig, } from '../../services/container-service'; import { celiloIntro, celiloOutro } from '../prompts'; import type { CommandResult } from '../types'; import { runApplianceDownload, selectUbuntuApplianceFromCatalog, } from './proxmox-template-selection'; import { buildCloudInitTemplate, detectExistingVmTemplates } from './proxmox-vm-template-build'; // A `type` (not `interface`) so it carries an implicit index signature and is // assignable to the loose `Record` providerConfig column. export type ProxmoxProviderConfig = { default_target_node: string; lxc_template: string; storage: string; // VM template to clone for `requires.system.type: vm` modules. Optional — // a service may only ever host LXC modules. MUST be carried through a // reconfigure (ISS-0128): updateServiceProviderConfig replaces the whole // column, so dropping it here silently deletes the operator's template. vm_template?: string; }; // Sentinel choices for the VM-template reconfigure step (non-template values // returned by the select). Any other value is an existing template name. const VM_KEEP = '__keep__'; const VM_BUILD = '__build__'; const VM_CLEAR = '__clear__'; const VM_SKIP = '__skip__'; export interface VmTemplateChoice { value: string; label: string; } /** * Build the select options for the VM-template reconfigure step, given the * current value and the templates detected on the node. Pure — no I/O — so the * menu is unit-testable and a headless caller (ISS-0127) can resolve a choice * without a prompt. */ export function buildVmTemplateChoices( current: string | undefined, existing: ProxmoxVmTemplate[], ): VmTemplateChoice[] { const choices: VmTemplateChoice[] = []; if (current) { choices.push({ value: VM_KEEP, label: `Keep current (${current})` }); } for (const template of existing) { if (template.name === current) continue; choices.push({ value: template.name, label: `${template.name} (VMID ${template.vmid})` }); } choices.push({ value: VM_BUILD, label: 'Build a new Ubuntu 24.04 cloud-init template now' }); choices.push( current ? { value: VM_CLEAR, label: 'Clear — remove the VM template' } : { value: VM_SKIP, label: 'Skip — no VM template' }, ); return choices; } /** * Resolve the full providerConfig to persist from the reconfigure interview's * resolved values. The caller seeds every field from the current config (via the * prompt defaults and the VM-template choice), so the result is a function of * `edits` alone. vm_template is included only when set — a falsy value clears it * rather than persisting an undefined key. This is the guard for the * wholesale-replace drop bug (ISS-0128): vm_template is a first-class edit, not * an afterthought that gets omitted from the written object. */ export function resolveReconfiguredProviderConfig(edits: { default_target_node: string; lxc_template: string; storage: string; vm_template: string | undefined; }): ProxmoxProviderConfig { const config: ProxmoxProviderConfig = { default_target_node: edits.default_target_node, lxc_template: edits.lxc_template, storage: edits.storage, }; if (edits.vm_template) { config.vm_template = edits.vm_template; } return config; } export async function handleServiceReconfigure( args: string[], _flags: Record = {}, ): Promise { const serviceId = args[0]; if (!serviceId) { return { success: false, error: 'Usage: celilo service reconfigure ', }; } // Every prompt below is a bus interview (ISS-0127), so reconfigure is // drivable headlessly via `celilo events respond --values` / `events reply`. // `withInterviewSession` runs a terminal-responder when stdin is a TTY so // those questions render locally; on a non-TTY run the headless responder // answers instead. const scope = `service:${serviceId}`; return withInterviewSession(async () => { try { const service = await getContainerServiceByServiceId(serviceId); if (!service) { return { success: false, error: `Service '${serviceId}' not found` }; } if (service.providerName !== 'proxmox') { return { success: false, error: `Service reconfigure is currently only supported for Proxmox services (got: ${service.providerName})`, }; } celiloIntro(`Reconfigure Service: ${service.name}`); const credentials = (await getServiceCredentials(service.id)) as ProxmoxCredentials; const currentConfig = service.providerConfig as unknown as ProxmoxProviderConfig; // Show current configuration console.log('Current configuration:'); console.log(` Target node: ${currentConfig.default_target_node}`); console.log(` Storage: ${currentConfig.storage}`); console.log(` LXC template: ${currentConfig.lxc_template}`); console.log(` VM template: ${currentConfig.vm_template ?? '(none)'}`); console.log(''); // Re-prompt for configurable fields with current values as defaults const targetNode = await askText({ scope, key: 'default_target_node', message: 'Default target node', defaultValue: currentConfig.default_target_node, placeholder: currentConfig.default_target_node, required: true, }); const storage = await askText({ scope, key: 'storage', message: 'Default storage', defaultValue: currentConfig.storage, placeholder: currentConfig.storage, required: true, }); // Find template storage. Done before catalog selection so the user only // ever sees the chosen storage in subsequent messages. console.log('\nFinding storage for templates...'); const storageListResult = await listNodeStorage(credentials, targetNode); let templateStorage = 'local'; if (storageListResult.success) { const vztmplStorage = storageListResult.data.find( (s) => s.active && s.enabled && s.content.includes('vztmpl'), ); if (vztmplStorage) { templateStorage = vztmplStorage.storage; console.log(`✓ Using storage '${templateStorage}' for templates`); } } // Pick a template from Proxmox's live catalog. Pre-selects the family // matching the existing volid (e.g. user has 24.04 -1 cached, mirror has -2). // The select + manual-entry fallback inside go through the bus interview // too (ISS-0127), scoped to this service. const selectionResult = await selectUbuntuApplianceFromCatalog(credentials, targetNode, { scope, currentTemplate: extractTemplateFilename(currentConfig.lxc_template), }); if (selectionResult.kind === 'cancelled') { return { success: false, error: 'Cancelled by user' }; } if (selectionResult.kind === 'error') { return { success: false, error: selectionResult.message }; } const templateFilename = selectionResult.choice.template; const lxcTemplate = buildTemplatePath(templateStorage, templateFilename); // Check if new template exists in storage. console.log(`\nChecking if template '${templateFilename}' exists...`); const templatesResult = await listAvailableTemplates( credentials, targetNode, templateStorage, ); let templateExists = false; if (templatesResult.success) { templateExists = templatesResult.data.some((t) => t.volid.includes(templateFilename)); } if (!templateExists) { console.log(`✗ Template '${templateFilename}' not found`); const shouldDownload = await askConfirm({ scope, key: 'download_template', message: 'Download template now?', defaultValue: true, }); if (!shouldDownload) { console.log( '\nTemplate not downloaded. Service will be updated but may fail verification.', ); } else { const outcome = await runApplianceDownload({ credentials, targetNode, templateStorage, templateFilename, }); if (!outcome.ready) { const detail = outcome.reason === 'task-failed' ? `pveam download exited with status: ${outcome.exitStatus ?? 'unknown'}` : outcome.reason === 'started-failed' ? `Proxmox rejected the download request: ${outcome.startError ?? 'unknown error'}` : 'Template download did not complete in time'; return { success: false, error: `${detail}\n\nTroubleshooting:\n 1. SSH into your Proxmox host and run: pveam update && pveam download ${templateStorage} ${templateFilename}\n 2. Re-try: celilo service reconfigure ${serviceId}\n 3. Check DNS and firewall settings on the Proxmox host`, }; } } } else { console.log(`✓ Template '${templateFilename}' found`); } // VM template (for `requires.system.type: vm` modules). Keep the current // value, swap to another detected template, build a new one, or clear it. // Without this step a reconfigure would silently drop vm_template (ISS-0128). let vmTemplate = currentConfig.vm_template; const existingTemplates = await detectExistingVmTemplates(credentials, targetNode); const vmChoice = await askSelect({ scope, key: 'vm_template', message: 'VM template for VM-type modules', options: buildVmTemplateChoices(currentConfig.vm_template, existingTemplates), }); if (vmChoice === VM_BUILD) { vmTemplate = await buildCloudInitTemplate({ credentials, nodeName: targetNode, diskStorage: storage, }); } else if (vmChoice === VM_CLEAR || vmChoice === VM_SKIP) { vmTemplate = undefined; } else if (vmChoice !== VM_KEEP) { vmTemplate = vmChoice; // an existing template name } // VM_KEEP leaves vmTemplate at currentConfig.vm_template. // Update service configuration. resolveReconfiguredProviderConfig preserves // every existing key and carries vm_template forward (ISS-0128 drop guard). const newConfig = resolveReconfiguredProviderConfig({ default_target_node: targetNode, lxc_template: lxcTemplate, storage, vm_template: vmTemplate, }); await updateServiceProviderConfig(service.id, newConfig); celiloOutro( `Service '${serviceId}' reconfigured successfully!\n\n` + ` Target node: ${targetNode}\n` + ` Storage: ${storage}\n` + ` LXC template: ${lxcTemplate}\n` + ` VM template: ${vmTemplate ?? '(none)'}\n\n` + `Next steps:\n - Verify: celilo service verify ${serviceId}\n - Deploy: celilo module deploy `, ); return { success: true, message: `Reconfigured service: ${serviceId}`, }; } catch (error) { return { success: false, error: `Failed to reconfigure service: ${error instanceof Error ? error.message : String(error)}`, }; } }); }