/** * Service Add Proxmox Command * Configure a Proxmox container service */ import { buildProxmoxApiUrl, buildTemplatePath, listAvailableTemplates, listNodeStorage, } from '../../api-clients/proxmox'; import type { NetworkZone } from '../../db/schema'; import { askConfirm, askMultiselect, askSelect, askText, withInterviewSession, } from '../../services/bus-interview'; import { addContainerService, testConnection as testServiceConnection, updateVerificationStatus, } from '../../services/container-service'; import { celiloIntro, celiloOutro } from '../prompts'; import { resolveServiceCredential } from '../service-credential'; import type { CommandResult } from '../types'; import { runApplianceDownload, selectUbuntuApplianceFromCatalog, } from './proxmox-template-selection'; import { buildCloudInitTemplate, detectExistingVmTemplates } from './proxmox-vm-template-build'; // Sentinel VM-template choices returned by the select. Any other value is an // existing template name. const VM_BUILD = '__build__'; const VM_SKIP = '__skip__'; /** * Handle service add proxmox command * * @param args - Command arguments (unused for interactive mode) * @param flags - Command flags */ export async function handleServiceAddProxmox( _args: string[], flags: Record = {}, ): Promise { // Non-secret prompts route through the bus interview (ISS-0127); the API // token id + secret are service credentials that travel by flag/env only // (D7). `withInterviewSession` renders bus questions locally on a TTY. const scope = 'service-add:proxmox'; return withInterviewSession(async () => { try { celiloIntro('Add Proxmox Container Service'); const name = await askText({ scope, key: 'name', message: 'Human-readable name', defaultValue: 'Proxmox Home Lab', placeholder: 'Proxmox Home Lab', required: true, }); const zones = await askMultiselect({ scope, key: 'zones', message: 'Zones this service can provision to', options: [ { value: 'internal', label: 'internal' }, { value: 'dmz', label: 'dmz' }, { value: 'app', label: 'app' }, { value: 'secure', label: 'secure' }, { value: 'secure-mgmt', label: 'secure-mgmt' }, { value: 'external', label: 'external' }, ], required: true, }); console.log('\nProxmox Configuration'); console.log('─────────────────────'); const ipAddress = await askText({ scope, key: 'ip_address', message: 'Proxmox IP address', placeholder: 'e.g., 192.168.1.100 or proxmox.local', required: true, }); const port = await askText({ scope, key: 'port', message: 'Proxmox API port', defaultValue: '8006', placeholder: '8006', required: true, type: 'integer', pattern: '^[0-9]+$', }); // Build the full API URL const apiUrl = buildProxmoxApiUrl(ipAddress, Number(port)); // API token ID + secret are service credentials — flag/env only (D7). const apiTokenId = await resolveServiceCredential({ field: 'API token ID', flag: 'api-token-id', envVar: 'PROXMOX_API_TOKEN_ID', flagValue: flags['api-token-id'], }); const apiTokenSecret = await resolveServiceCredential({ field: 'API token secret', flag: 'api-token-secret', envVar: 'PROXMOX_API_TOKEN_SECRET', flagValue: flags['api-token-secret'], }); const targetNode = await askText({ scope, key: 'default_target_node', message: 'Default target node', defaultValue: 'pve', placeholder: 'pve', required: true, }); const storage = await askText({ scope, key: 'storage', message: 'Default storage', defaultValue: 'local-lvm', placeholder: 'local-lvm', required: true, }); // Build credentials here — needed for VM template detection below and // for LXC template catalog lookup further down. const credentials = { api_url: apiUrl, api_token_id: apiTokenId, api_token_secret: apiTokenSecret, }; // VM template for `requires.system.type: vm` modules. // Auto-detect existing templates on the node; offer to build if none found. // See reference/PROXMOX_VM_TEMPLATE.md. let vmTemplate: string | undefined; const existingTemplates = await detectExistingVmTemplates(credentials, targetNode); if (existingTemplates.length > 0) { const templateChoice = await askSelect({ scope, key: 'vm_template', message: 'VM template for VM-type modules', options: [ ...existingTemplates.map((t) => ({ value: t.name, label: `${t.name} (VMID ${t.vmid})`, })), { value: VM_BUILD, label: 'Build a new Ubuntu 24.04 cloud-init template now' }, { value: VM_SKIP, label: 'Skip — I only need LXC modules' }, ], }); if (templateChoice === VM_BUILD) { vmTemplate = await buildCloudInitTemplate({ credentials, nodeName: targetNode, diskStorage: storage, }); } else if (templateChoice !== VM_SKIP) { vmTemplate = templateChoice; } } else { const shouldBuild = await askConfirm({ scope, key: 'vm_template_build', message: 'No VM templates found. Build a Ubuntu 24.04 cloud-init template now? (~2–10 min)', defaultValue: false, }); if (shouldBuild) { vmTemplate = await buildCloudInitTemplate({ credentials, nodeName: targetNode, diskStorage: storage, }); } else { const manualEntry = await askText({ scope, key: 'vm_template_manual', message: 'VM template name (optional — leave blank to skip)', placeholder: 'e.g., ubuntu-2404-cloudinit', }); vmTemplate = manualEntry.trim() || undefined; } } // Find storage that supports vztmpl content. We do this BEFORE prompting // for a template so the user only ever sees one storage in subsequent // messages, and so the saved volid uses the right storage. 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`); } else { console.log(`⚠ No storage found with 'vztmpl' support, using '${templateStorage}'`); } } // Pick a template from Proxmox's live catalog (replaces the old hardcoded // version select that built `-1`-revision URLs by hand). const selectionResult = await selectUbuntuApplianceFromCatalog(credentials, targetNode, { scope, }); 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 the template is already cached in the chosen 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)); } // Save the service FIRST so credentials aren't lost if the download fails. const service = await addContainerService({ name, providerName: 'proxmox', zones: zones as unknown as NetworkZone[], apiCredentials: credentials, providerConfig: { default_target_node: targetNode, lxc_template: lxcTemplate, storage, ...(vmTemplate ? { vm_template: vmTemplate } : {}), }, }); console.log(`✓ Service '${service.serviceId}' saved\n`); let templateReady = templateExists; if (!templateExists) { console.log(`✗ Template '${templateFilename}' not found in storage '${templateStorage}'`); const shouldDownload = await askConfirm({ scope, key: 'download_template', message: 'Download template now?', defaultValue: true, }); if (shouldDownload) { const downloadOutcome = await runApplianceDownload({ credentials, targetNode, templateStorage, templateFilename, }); templateReady = downloadOutcome.ready; } else { console.log( '\nTemplate not downloaded. Service saved but will need a template before deployment.', ); } if (!templateReady) { console.log( '\nTemplate is not available. The service has been saved but needs a template.\n', ); console.log('Next steps:'); console.log( ` 1. Try a different version: celilo service reconfigure ${service.serviceId}`, ); console.log( ` 2. Download manually: ssh root@${ipAddress} pveam download ${templateStorage} ${templateFilename}`, ); console.log(` 3. Then verify: celilo service verify ${service.serviceId}`); } } else { console.log(`✓ Template '${templateFilename}' found`); } // Test connection console.log('\nTesting connection...'); const testResult = await testServiceConnection(service); await updateVerificationStatus(service.id, testResult); if (!testResult.success) { console.log(`✗ Connection test failed: ${testResult.message}`); console.log( '\nService saved but not verified. The service will not be used until verification succeeds.', ); celiloOutro( `Service '${service.serviceId}' (${name}) added but not verified.\n\nNext steps:\n - Fix the connection issue\n - Re-verify: celilo service verify ${service.serviceId}\n - Check status: celilo service list`, ); } else { console.log(`✓ ${testResult.message}`); celiloOutro( `Service '${service.serviceId}' (${name}) added and verified successfully!\n\nService ID: ${service.serviceId}\n\nNext steps:\n - Generate module: celilo module generate \n - List services: celilo service list`, ); } return { success: true, message: `Added Proxmox service: ${service.id}`, }; } catch (error) { return { success: false, error: `Failed to add Proxmox service: ${error instanceof Error ? error.message : String(error)}`, }; } }); }