mirror of
https://github.com/ergosteur/ergo-proxmox-scripts.git
synced 2026-10-28 13:00:37 -04:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3ef068e846
commit
25b96af194
Executable
+74
@@ -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.")
|
||||||
Executable
+147
@@ -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()
|
||||||
Executable
+159
@@ -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 <ova|ovf-file> <vmid> <storage> [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!"
|
||||||
Reference in New Issue
Block a user