/** * Build a cloud-init Ubuntu VM template on Proxmox via the API (no SSH needed). * Called from service-add-proxmox when the operator opts in. * * Steps: * 1. Find ISO-capable storage on the target node. * 2. Find a free template VMID (>= 9000, outside celilo's IPAM range). * 3. Download Ubuntu 24.04 cloud image to ISO storage. (~2–5 min) * 4. Create a VM with the cloud image imported as its boot disk + a cloud-init * CDROM drive. (~30s) * 5. Convert the VM to a template. (~5s) * * The finished template is a blank cloud-init base — no credentials, IP, or SSH * keys baked in. celilo's terraform sets those at clone time (ipconfig0, sshkeys, * ciuser, nameserver). Requires PVE 8.0+ (the storage download-url endpoint is * 7.2+, but importing the disk via `import-from` in the VM-create call is 8.0+, * so the build as a whole needs 8.0+). */ import type { ProxmoxCredentials, ProxmoxVmTemplate } from '../../api-clients/proxmox'; import { buildCloudImageVolid, convertVmToTemplate, createVmFromCloudImage, deleteVm, downloadCloudImage, findFreeTemplateVmid, findImportStorage, listVmTemplates, pollTaskUntilDone, } from '../../api-clients/proxmox'; import { FuelGauge } from '../fuel-gauge'; const UBUNTU_2404_AMD64_URL = 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img'; // Stored under the storage's import/ namespace, so the filename must end in an // extension PVE accepts for `import` content (UPLOAD_IMPORT_EXT_RE_1 = // ova|qcow2|raw|vmdk) — a `.img` is rejected with "invalid filename or wrong // extension". Ubuntu's cloud image is qcow2 format despite the upstream `.img` // URL, so we download it under a `.qcow2` name. The URL above is unchanged. const UBUNTU_2404_AMD64_FILENAME = 'noble-server-cloudimg-amd64.qcow2'; const DEFAULT_TEMPLATE_NAME = 'ubuntu-2404-cloudinit'; export interface VmTemplateBuildParams { credentials: ProxmoxCredentials; nodeName: string; diskStorage: string; } /** * List existing VM templates on the node. Returns an empty array (not an error) * if the API call fails — the caller treats failure as "no templates found". */ export async function detectExistingVmTemplates( credentials: ProxmoxCredentials, nodeName: string, ): Promise { const result = await listVmTemplates(credentials, nodeName); return result.success ? result.data : []; } /** * Build a cloud-init Ubuntu 24.04 VM template on the Proxmox node. * Shows a FuelGauge progress indicator — takes 2–10 min depending on the * operator's internet connection (image download is ~600 MB). * Returns the template name (e.g. "ubuntu-2404-cloudinit"). * Cleans up the VM on failure so the operator isn't left with a partial guest. */ export async function buildCloudInitTemplate(params: VmTemplateBuildParams): Promise { const { credentials, nodeName, diskStorage } = params; const gauge = new FuelGauge('Building cloud-init VM template'); gauge.start(); let vmid: number | null = null; try { // Step 1: find import-capable storage. The cloud image is consumed via the // VM-create `import-from=`, which requires an `import`/`images` source — a // `.img` in the `iso/` namespace is rejected (`has wrong type 'iso'`). gauge.addOutput('Finding import-capable storage on the node…'); const importStorageResult = await findImportStorage(credentials, nodeName); if (!importStorageResult.success) { throw new Error(`Could not list node storage: ${importStorageResult.message}`); } const importStorage = importStorageResult.data; if (!importStorage) { throw new Error( 'No import-capable storage found on the node. ' + "Enable 'import' content on a storage (typically 'local') in the Proxmox UI: " + 'Datacenter → Storage → Edit → Content.', ); } gauge.addOutput(`Import storage: ${importStorage}`); // Step 2: find a free template VMID gauge.addOutput('Finding a free template VMID (>= 9000)…'); const vmidResult = await findFreeTemplateVmid(credentials, nodeName); if (!vmidResult.success) throw new Error(`VMID lookup failed: ${vmidResult.message}`); vmid = vmidResult.data; gauge.addOutput(`Using VMID ${vmid}`); // Step 3: download cloud image to import storage gauge.addOutput( `Downloading Ubuntu 24.04 cloud image to '${importStorage}' (~600 MB, may take several minutes)…`, ); const downloadResult = await downloadCloudImage( credentials, nodeName, importStorage, UBUNTU_2404_AMD64_URL, UBUNTU_2404_AMD64_FILENAME, ); if (!downloadResult.success) { if (downloadResult.message.includes('403') || downloadResult.message.includes('not found')) { throw new Error( `Proxmox storage download-url endpoint rejected the request. Downloading to 'import' content requires Proxmox VE 8.2+. Error: ${downloadResult.message}`, ); } throw new Error(`Cloud image download failed: ${downloadResult.message}`); } gauge.addOutput('Waiting for download to complete…'); await pollTaskUntilDone(credentials, nodeName, downloadResult.data, 600_000); gauge.addOutput('Cloud image downloaded'); // Step 4: create VM from cloud image const imageVolid = buildCloudImageVolid(importStorage, UBUNTU_2404_AMD64_FILENAME); gauge.addOutput(`Creating VM ${vmid} '${DEFAULT_TEMPLATE_NAME}' (importing disk)…`); const createResult = await createVmFromCloudImage({ credentials, nodeName, vmid, name: DEFAULT_TEMPLATE_NAME, imageVolid, diskStorage, }); if (!createResult.success) { throw new Error(`VM creation failed: ${createResult.message}`); } gauge.addOutput('Waiting for disk import to complete…'); await pollTaskUntilDone(credentials, nodeName, createResult.data, 120_000); gauge.addOutput('VM created'); // Step 5: convert to template gauge.addOutput(`Converting VM ${vmid} to a template…`); const templateResult = await convertVmToTemplate(credentials, nodeName, vmid); if (!templateResult.success) { throw new Error(`Convert-to-template failed: ${templateResult.message}`); } await pollTaskUntilDone(credentials, nodeName, templateResult.data, 60_000); gauge.stop(true); return DEFAULT_TEMPLATE_NAME; } catch (error) { gauge.stop(false); // Best-effort cleanup — remove the partially-created VM so the operator // isn't left with a stranded guest. if (vmid !== null) { try { const del = await deleteVm(credentials, nodeName, vmid); if (del.success) { console.log(`✗ Cleaned up VM ${vmid} after build failure`); } } catch { console.log(`⚠ Could not clean up VM ${vmid} — delete it manually in the Proxmox UI`); } } throw error; } }