From 25b96af19490ef10c346f1cfe977e96d900a058a Mon Sep 17 00:00:00 2001 From: ergosteur <1992147+ergosteur@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:44:49 +0000 Subject: [PATCH] Add OVA/OVF import tools and virtio-win downloader Two implementations of the same OVA/OVF import flow: a Python version that parses the OVF with ElementTree and detects UEFI, and a shell version using xmllint that handles multi-disk appliances. Both check free space on the target storage before importing. download-virtio-win-progress.py fetches the latest virtio-win ISO, skipping the download when the local copy is already current. Co-Authored-By: Claude Opus 5 --- download-virtio-win-progress.py | 74 +++++++++++++++ import-ova-pve.py | 147 +++++++++++++++++++++++++++++ import-ova-to-proxmox.sh | 159 ++++++++++++++++++++++++++++++++ 3 files changed, 380 insertions(+) create mode 100755 download-virtio-win-progress.py create mode 100755 import-ova-pve.py create mode 100755 import-ova-to-proxmox.sh diff --git a/download-virtio-win-progress.py b/download-virtio-win-progress.py new file mode 100755 index 0000000..d2c20df --- /dev/null +++ b/download-virtio-win-progress.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 + +# install libraries +# apt install python3-requests python3-bs4 python3-tqdm +import os +import requests +from bs4 import BeautifulSoup +import re +from tqdm import tqdm +import argparse + +# Argument parser to handle --cron flag and directory path +parser = argparse.ArgumentParser(description='Download latest virtio ISO.') +parser.add_argument('--cron', action='store_true', help='Run in cron-friendly mode with minimal output') +parser.add_argument('--dir', type=str, default='.', help='Directory to save the ISO file') +args = parser.parse_args() + +# Ensure the directory exists +if not os.path.exists(args.dir): + os.makedirs(args.dir) + +# URL for the latest virtio ISO downloads +url = 'https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/latest-virtio/' +response = requests.get(url) +soup = BeautifulSoup(response.text, 'html.parser') + +latest_iso_link = None +version_number = None + +# Find the latest ISO link by matching filenames ending in '.iso' +for link in soup.find_all('a', href=True): + if link['href'].endswith('.iso'): + latest_iso_link = link + version_number = re.search(r'\d+\.\d+\.\d+', link['href']).group() + iso_name = f"virtio-win-{version_number}.iso" + break + +if latest_iso_link and version_number: + print(f"Version Number: {version_number}") + + # Construct the download URL + iso_url = url + latest_iso_link['href'] + iso_path = os.path.join(args.dir, iso_name) + + try: + response = requests.get(iso_url, stream=True) + total_size = int(response.headers.get('content-length', 0)) + + if os.path.exists(iso_path): + local_size = os.path.getsize(iso_path) + if local_size == total_size: + if not args.cron: + print(f"File {iso_path} already exists and is the same size. Skipping download.") + else: + if not args.cron: + print(f"File {iso_path} exists but has a different size. Redownloading...") + else: + if not args.cron: + print(f"File {iso_path} does not exist. Downloading...") + + if not os.path.exists(iso_path) or local_size != total_size: + with open(iso_path, 'wb') as f, tqdm( + total=total_size, unit='B', unit_scale=True, desc=iso_name, ncols=80, disable=args.cron) as progress_bar: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + if not args.cron: + progress_bar.update(len(chunk)) + + print(f"Downloaded ISO file successfully. Saved to {iso_path}") + except requests.exceptions.RequestException as e: + print(f"Error downloading the ISO file: {e}") +else: + print("No ISO file found or unable to extract version number.") diff --git a/import-ova-pve.py b/import-ova-pve.py new file mode 100755 index 0000000..a2459d8 --- /dev/null +++ b/import-ova-pve.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 + +import os +import sys +import tarfile +import tempfile +import subprocess +import xml.etree.ElementTree as ET +import argparse +import shutil + +# Proxmox OVF/OVA Importer v1.3 with UEFI support + +def parse_ovf(ovf_path): + ns = { + 'ovf': "http://schemas.dmtf.org/ovf/envelope/1", + 'rasd': "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData", + 'vssd': "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData" + } + tree = ET.parse(ovf_path) + + cpu = 0 + mem = 0 + disks = [] + uefi = False + for item in tree.findall(".//ovf:VirtualHardwareSection/ovf:Item", ns): + rtype = item.find("rasd:ResourceType", ns) + quantity = item.find("rasd:VirtualQuantity", ns) + if rtype is None or quantity is None: + continue + rtype_val = rtype.text.strip() + quantity_val = quantity.text.strip() + if rtype_val == "3": + cpu = int(quantity_val) + elif rtype_val == "4": + mem = int(quantity_val) + elif rtype_val == "17": + host_res = item.find("rasd:HostResource", ns) + if host_res is not None: + disks.append(host_res.text.split('/')[-1]) + + for disk in tree.findall(".//ovf:DiskSection/ovf:Disk", ns): + if 'capacity' in disk.attrib: + size_bytes = int(disk.attrib['capacity']) + size_gb = round(size_bytes / (1024**3), 1) + else: + size_gb = 0 + + vsys_type = tree.find(".//ovf:VirtualHardwareSection/ovf:System/vssd:VirtualSystemType", ns) + if vsys_type is not None and "efi" in vsys_type.text.lower(): + uefi = True + + return cpu, mem, disks, size_gb, uefi + +def run(cmd): + print(f"$ {' '.join(cmd)}") + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + if result.returncode != 0: + print(result.stdout) + sys.exit(1) + return result.stdout + +def check_storage(storage): + status = run(["pvesm", "status"]) + for line in status.splitlines(): + if line.startswith(storage): + parts = line.split() + avail_bytes = int(parts[5]) + return avail_bytes + print(f"❌ Storage {storage} not found!") + sys.exit(1) + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("ova_path") + parser.add_argument("vmid") + parser.add_argument("storage") + parser.add_argument("--name", default=None) + parser.add_argument("--force", action="store_true") + parser.add_argument("--uefi", action="store_true", help="Force UEFI/OVMF BIOS") + args = parser.parse_args() + + ova_path = os.path.abspath(args.ova_path) + vmid = str(args.vmid) + storage = args.storage + vmname = args.name or os.path.splitext(os.path.basename(ova_path))[0].replace(".", "-").replace("_", "-") + + print(f"✅ VM ID {vmid} is available") + avail_bytes = check_storage(storage) + print(f"🧮 Storage free space: {round(avail_bytes / (1024**3), 1)}G") + + with tempfile.TemporaryDirectory() as tmpdir: + print("==> Detected OVA bundle. Extracting...") + with tarfile.open(ova_path, 'r') as tar: + tar.extractall(path=tmpdir) + + ovf_file = next(f for f in os.listdir(tmpdir) if f.endswith(".ovf")) + ovf_path = os.path.join(tmpdir, ovf_file) + cpu, mem, disks, declared_gb, uefi_detected = parse_ovf(ovf_path) + + print(f"📦 Total VMDK declared size: {declared_gb}G") + if declared_gb * 1024**3 > avail_bytes: + print("❌ Not enough space to import.") + sys.exit(1) + else: + print("✅ Storage has enough capacity") + + print(f"🧠 Parsed RAM from OVF: {mem} MB") + print(f"🧮 Parsed CPU count from OVF: {cpu}") + + if mem < 16: + print("⚠️ OVF RAM too low. Defaulting to 512 MB.") + mem = 512 + if cpu < 1: + print("⚠️ OVF CPU count too low. Defaulting to 1 core.") + cpu = 1 + + use_uefi = args.uefi or uefi_detected + create_cmd = ["qm", "create", vmid, + "--name", vmname, + "--memory", str(mem), + "--cores", str(cpu), + "--net0", "virtio,bridge=vmbr0"] + if use_uefi: + print("🧬 Enabling UEFI BIOS (OVMF)...") + if not os.path.exists("/usr/share/OVMF/OVMF_CODE.fd"): + print("⚠️ Warning: UEFI firmware (OVMF) not found. You may need to install the 'ovmf' package.") + create_cmd += ["--bios", "ovmf"] + + print(f"==> Creating VM {vmid}...") + run(create_cmd) + + for idx, vmdk in enumerate(f for f in os.listdir(tmpdir) if f.endswith(".vmdk")): + disk_path = os.path.join(tmpdir, vmdk) + print(f"==> Importing disk {vmdk} to {storage}...") + run(["qm", "importdisk", vmid, disk_path, storage]) + + print(f"==> Attaching disks to VM {vmid}...") + run(["qm", "set", vmid, "--scsihw", "virtio-scsi-pci", "--scsi0", f"{storage}:vm-{vmid}-disk-0"]) + + print(f"==> Setting boot order to scsi0 (disabling net0 boot)...") + run(["qm", "set", vmid, "--boot", "order=scsi0"]) + + print("✅ Import complete!") + +if __name__ == "__main__": + main() diff --git a/import-ova-to-proxmox.sh b/import-ova-to-proxmox.sh new file mode 100755 index 0000000..0234267 --- /dev/null +++ b/import-ova-to-proxmox.sh @@ -0,0 +1,159 @@ +#!/bin/bash +set -euo pipefail + +# === OVF/OVA Importer for Proxmox === +# Supports: --force, OVF parsing, accurate storage check, multi-disk VMDK, CPU/RAM + +FORCE=0 +POSITIONAL=() + +# === Parse args manually to allow --force anywhere === +for arg in "$@"; do + case "$arg" in + --force) + FORCE=1 + ;; + *) + POSITIONAL+=("$arg") + ;; + esac +done + +set -- "${POSITIONAL[@]}" +if [[ $# -lt 3 ]]; then + echo "Usage: $0 [vm-name] [--force]" + exit 1 +fi + +INPUT="$1" +VMID="$2" +STORAGE="$3" +VMNAME="${4:-}" + +# === Auto-suggest VM name from filename if not given === +if [[ -z "$VMNAME" || "$VMNAME" == "--force" ]]; then + BASENAME=$(basename "$INPUT") + BASENAME="${BASENAME%.*}" + VMNAME=$(echo "$BASENAME" | sed -E 's/[^a-zA-Z0-9]+/-/g' | sed -E 's/^-+|-+$//g' | tr '[:upper:]' '[:lower:]') + echo "==> No VM name provided." + echo "==> Suggested VM Name: $VMNAME" + echo "==> Continuing in 5 seconds... (Ctrl-C to cancel)" + sleep 5 +fi + +# === Validate VM ID === +if qm status "$VMID" &>/dev/null; then + echo "❌ ERROR: VM ID $VMID already exists" + exit 1 +else + echo "✅ VM ID $VMID is available" +fi + +# === Validate storage === +if ! pvesm status | awk '{print $1}' | grep -qx "$STORAGE"; then + echo "❌ ERROR: Storage '$STORAGE' not found!" + echo "👉 Available storages:" + pvesm status | awk 'NR>1 {print $1}' + exit 1 +else + echo "✅ Storage '$STORAGE' exists" +fi + +# === Get free space in bytes from pvesm (field 6 × 1024) === +STORAGE_FREE_KB=$(pvesm status | awk -v storage="$STORAGE" '$1 == storage {print $6}') +if [[ -z "$STORAGE_FREE_KB" || "$STORAGE_FREE_KB" -eq 0 ]]; then + echo "❌ ERROR: Could not determine available space for '$STORAGE'" + exit 1 +fi +STORAGE_FREE=$((STORAGE_FREE_KB * 1024)) + +# === Temp workspace === +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +echo "==> Preparing to import VM $VMID from $INPUT (name: $VMNAME)" + +# === Extract OVA or OVF === +if [[ "$INPUT" == *.ova ]]; then + echo "==> Detected OVA bundle. Extracting..." + tar -xvf "$INPUT" -C "$TMPDIR" +elif [[ "$INPUT" == *.ovf ]]; then + echo "==> Detected OVF file. Copying OVF and VMDKs..." + cp "$INPUT" "$TMPDIR" + for vmdk in "${INPUT%.ovf}"*.vmdk; do cp "$vmdk" "$TMPDIR"; done +else + echo "❌ ERROR: Input must be .ova or .ovf" + exit 1 +fi + +cd "$TMPDIR" +OVF=$(find . -name "*.ovf" | head -n 1) +[[ -f "$OVF" ]] || { echo "❌ ERROR: OVF file not found!"; exit 1; } + +# === Get total declared capacity from OVF === +REQUIRED_CAPACITY=$(grep -oP 'ovf:capacity="\K[0-9]+' "$OVF" | awk '{sum+=$1} END{print sum}') +REQUIRED_FMT=$(numfmt --to=iec "$REQUIRED_CAPACITY" 2>/dev/null || echo "$REQUIRED_CAPACITY B") +STORAGE_FMT=$(numfmt --to=iec "$STORAGE_FREE" 2>/dev/null || echo "$STORAGE_FREE B") + +echo "🧮 Storage free space: $STORAGE_FMT" +echo "📦 Total VMDK declared size: $REQUIRED_FMT" + +if [[ "$FORCE" -eq 1 ]]; then + echo "⚠️ --force enabled, skipping free space check" +elif [[ -z "$REQUIRED_CAPACITY" || -z "$STORAGE_FREE" ]]; then + echo "❌ ERROR: Capacity values missing" + exit 1 +elif ! [[ "$REQUIRED_CAPACITY" =~ ^[0-9]+$ && "$STORAGE_FREE" =~ ^[0-9]+$ ]]; then + echo "❌ ERROR: Capacity values not integers" + exit 1 +elif (( STORAGE_FREE < REQUIRED_CAPACITY )); then + echo "❌ Not enough free space on storage '$STORAGE'" + exit 1 +else + echo "✅ Storage has enough capacity" +fi + +# === Parse RAM and CPU === +OVF_RAM=$(xmllint --xpath 'string(//*[local-name()="Item"][.//*[local-name()="ResourceType"]="4"]/*[local-name()="VirtualQuantity"])' "$OVF" 2>/dev/null || echo "2048") +OVF_CPU=$(xmllint --xpath 'string(//*[local-name()="Item"][.//*[local-name()="ResourceType"]="3"]/*[local-name()="VirtualQuantity"])' "$OVF" 2>/dev/null || echo "2") + +[[ "$OVF_RAM" =~ ^[0-9]+$ ]] || OVF_RAM=2048 +[[ "$OVF_CPU" =~ ^[0-9]+$ ]] || OVF_CPU=2 + +echo "🧠 Parsed RAM from OVF: ${OVF_RAM} MB" +echo "🧮 Parsed CPU count from OVF: $OVF_CPU" + +# === Create VM === +echo "==> Creating VM $VMID..." +qm create "$VMID" --name "$VMNAME" --memory "$OVF_RAM" --cores "$OVF_CPU" --net0 virtio,bridge=vmbr0 --ostype l26 + +# === Parse VMDK files reliably === +mapfile -t VMDK_FILES < <(xmllint --xpath "//*[local-name()='File']/@*" "$OVF" 2>/dev/null \ + | grep -oE 'href="[^"]+\.vmdk"' | sed -E 's/^href="|"$//g') + +if [[ ${#VMDK_FILES[@]} -eq 0 ]]; then + echo "❌ ERROR: No VMDK files found in OVF" + exit 1 +fi + +echo "📄 VMDK files to import:" +for f in "${VMDK_FILES[@]}"; do echo " - $f"; done + +i=0 +for VMDK in "${VMDK_FILES[@]}"; do + VMDK_FILE=$(basename "$VMDK") + [[ -f "$VMDK_FILE" ]] || { echo "❌ Missing VMDK file: $VMDK_FILE"; exit 1; } + + echo "💾 Importing $VMDK_FILE to $STORAGE..." + qm importdisk "$VMID" "$VMDK_FILE" "$STORAGE" --format qcow2 + + echo "🔗 Attaching disk as scsi$i..." + qm set "$VMID" --scsi$i "$STORAGE:vm-$VMID-disk-$i" + ((i++)) +done + +# === Finalize configuration === +qm set "$VMID" --scsihw virtio-scsi-pci --boot c --bootdisk scsi0 +qm set "$VMID" --vga std + +echo "✅ VM $VMID ('$VMNAME') successfully imported and ready to start!"