#!/bin/bash # ========================================================== # ProxMenux - Import VM from OVA or OVF # ========================================================== # Author : MacRimi # Copyright : (c) 2024 MacRimi # License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) # Version : 1.0 # Last Updated: 10/04/2026 # ========================================================== # Description: # Imports a virtual machine from an OVA or OVF package into Proxmox VE. # Compatible with exports from VMware ESXi, VMware Workstation/Fusion, # VirtualBox, and Proxmox itself (via export_vm_ova_ovf). # # What is imported: # - Disk images (VMDK converted to the target storage format) # - CPU and memory settings # - Number of network interfaces # - VM name and OS type hint # # What requires manual review after import: # - Network bridge assignment (vmbr0 assigned by default) # - NIC model (e1000 by default — change to VirtIO if guest supports it) # - Firmware (BIOS/UEFI — must match what the original VM used) # - VirtIO/qemu-guest-agent installation inside the guest (especially from ESXi) # - PCI passthrough, TPM, cloud-init, snapshots — not portable in OVF/OVA # ========================================================== BASE_DIR="/usr/local/share/proxmenux" UTILS_FILE="$BASE_DIR/utils.sh" [[ -f "$UTILS_FILE" ]] && source "$UTILS_FILE" load_language initialize_cache BACKTITLE="ProxMenux" UI_MENU_H=20 UI_MENU_W=84 UI_MENU_LIST_H=10 # Globals populated during the flow SOURCE_FILE="" OVF_FILE="" OVF_DIR="" WORK_DIR="" OVF_VM_NAME="" OVF_VCPUS=1 OVF_MEMORY_MB=1024 OVF_DISK_FILES=() OVF_DISK_CAPACITIES=() OVF_NET_COUNT=0 OVF_OS_TYPE="other" NEW_VMID="" NEW_VM_NAME="" STORAGE="" BRIDGE="vmbr0" # ------------------------------------------------------- # HELPERS # ------------------------------------------------------- human_bytes() { local bytes="$1" local units=("B" "KB" "MB" "GB" "TB") local idx=0 value="$bytes" [[ -z "$value" || ! "$value" =~ ^[0-9]+$ ]] && { echo "N/A"; return; } while [[ "$value" -ge 1024 && "$idx" -lt 4 ]]; do value=$((value / 1024)) idx=$((idx + 1)) done echo "${value}${units[$idx]}" } # ------------------------------------------------------- # SELECT SOURCE FILE # ------------------------------------------------------- select_source_file() { local dump_dir="/var/lib/vz/dump" local iso_dir="/var/lib/vz/template/iso" local options=( "1" "$dump_dir" "2" "$iso_dir" "M" "$(translate "Manual path entry")" ) while true; do local choice choice=$(dialog --backtitle "$BACKTITLE" \ --title "$(translate "Import VM from OVA or OVF")" \ --menu "$(translate "Where is the OVA/OVF file located?")" \ 14 82 4 "${options[@]}" 3>&1 1>&2 2>&3) [[ -n "$choice" ]] || return 1 local search_dir="" case "$choice" in 1) search_dir="$dump_dir" ;; 2) search_dir="$iso_dir" ;; M) search_dir=$(dialog --backtitle "$BACKTITLE" \ --title "$(translate "Custom Path")" \ --inputbox "\n$(translate "Enter directory containing OVA/OVF files:")" \ 10 82 "/var/lib/vz/dump" 3>&1 1>&2 2>&3) [[ -n "$search_dir" ]] || continue ;; esac if [[ ! -d "$search_dir" ]]; then dialog --backtitle "$BACKTITLE" --title "$(translate "Not found")" \ --msgbox "$(translate "Directory does not exist:")\n$search_dir" 8 74 continue fi local file_opts=() while IFS= read -r f; do local fname size_h fname=$(basename "$f") size_h=$(du -sh "$f" 2>/dev/null | awk '{print $1}') file_opts+=("$f" "$fname [$size_h]") done < <(find "$search_dir" -maxdepth 2 \( -name "*.ova" -o -name "*.ovf" \) 2>/dev/null | sort) if [[ ${#file_opts[@]} -eq 0 ]]; then dialog --backtitle "$BACKTITLE" \ --title "$(translate "No files found")" \ --msgbox "$(translate "No .ova or .ovf files found in:")\n\n$search_dir" 10 74 continue fi local selected selected=$(dialog --backtitle "$BACKTITLE" \ --title "$(translate "Select OVA/OVF file")" \ --menu "$(translate "Select the file to import:")" \ $UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \ "${file_opts[@]}" 3>&1 1>&2 2>&3) [[ -n "$selected" ]] || continue SOURCE_FILE="$selected" return 0 done } # ------------------------------------------------------- # EXTRACT OVA / LOCATE OVF # ------------------------------------------------------- prepare_ovf() { local src="$SOURCE_FILE" local ext="${src##*.}" ext="${ext,,}" if [[ "$ext" == "ova" ]]; then WORK_DIR=$(mktemp -d "/tmp/.proxmenux-import-XXXXXX") trap 'rm -rf "$WORK_DIR" 2>/dev/null' EXIT msg_info "$(translate "Extracting OVA archive...")" if ! tar xf "$src" -C "$WORK_DIR" 2>/dev/null; then msg_error "$(translate "Failed to extract OVA file:") $src" return 1 fi msg_ok "$(translate "Archive extracted.")" OVF_FILE=$(find "$WORK_DIR" -maxdepth 2 -name "*.ovf" | head -1) if [[ -z "$OVF_FILE" ]]; then msg_error "$(translate "No .ovf descriptor found inside OVA.")" return 1 fi OVF_DIR=$(dirname "$OVF_FILE") elif [[ "$ext" == "ovf" ]]; then OVF_FILE="$src" OVF_DIR=$(dirname "$src") WORK_DIR="" else msg_error "$(translate "Unsupported format. Only .ova and .ovf files are supported.")" return 1 fi return 0 } # ------------------------------------------------------- # PARSE OVF XML # ------------------------------------------------------- parse_ovf() { local ovf_file="$1" local result result=$(awk ' BEGIN { in_item=0; rt=""; qty="" file_count=0; cap_count=0; net_count=0 name=""; vcpu="1"; mem="1024"; os="" } /<[Nn]ame>/ { match($0, /<[Nn]ame>([^<]+) 0) caps[cap_count++] = a[1] } /|/ { if (in_item) { if (rt=="3" && qty ~ /^[0-9]+$/) vcpu=qty if (rt=="4" && qty ~ /^[0-9]+$/) mem=qty if (rt=="10") net_count++ } in_item=0 } /ResourceType>/ { match($0, /ResourceType>([0-9]+)/ { match($0, /VirtualQuantity>([0-9]+)/dev/null || echo "100") while true; do NEW_VMID=$(dialog --backtitle "$BACKTITLE" \ --title "$(translate "VM ID")" \ --inputbox "\n$(translate "Enter the VMID for the new VM:") ($(translate "suggested:") $suggested_vmid)" \ 10 72 "$suggested_vmid" 3>&1 1>&2 2>&3) [[ -n "$NEW_VMID" ]] || return 1 if ! [[ "$NEW_VMID" =~ ^[0-9]+$ ]]; then dialog --backtitle "$BACKTITLE" --title "$(translate "Invalid VMID")" \ --msgbox "$(translate "VMID must be a number.")" 8 50 continue fi if qm status "$NEW_VMID" &>/dev/null; then dialog --backtitle "$BACKTITLE" --title "$(translate "VMID in use")" \ --msgbox "$(translate "VMID $NEW_VMID is already in use. Please choose another.")" 8 60 continue fi break done # VM Name NEW_VM_NAME=$(dialog --backtitle "$BACKTITLE" \ --title "$(translate "VM Name")" \ --inputbox "\n$(translate "Enter name for the imported VM:")" \ 10 72 "$OVF_VM_NAME" 3>&1 1>&2 2>&3) [[ -n "$NEW_VM_NAME" ]] || return 1 # Storage local storage_list storage_opts=() storage_list=$(pvesm status -content images 2>/dev/null | awk 'NR>1 {print $1}') if [[ -z "$storage_list" ]]; then dialog --backtitle "$BACKTITLE" --title "$(translate "No storage")" \ --msgbox "$(translate "No storage volumes available for VM images.")" 8 60 return 1 fi while IFS= read -r s; do storage_opts+=("$s" "") done <<< "$storage_list" STORAGE=$(dialog --backtitle "$BACKTITLE" \ --title "$(translate "Select Storage")" \ --menu "$(translate "Select storage for imported disk(s):")" \ $UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \ "${storage_opts[@]}" 3>&1 1>&2 2>&3) [[ -n "$STORAGE" ]] || return 1 # Network bridge local bridge_opts=() while IFS= read -r br; do [[ -n "$br" ]] && bridge_opts+=("$br" "") done < <(ip link show type bridge 2>/dev/null | awk -F': ' '/^[0-9]+:/{print $2}' | sed 's/@.*//') if [[ ${#bridge_opts[@]} -gt 1 ]]; then BRIDGE=$(dialog --backtitle "$BACKTITLE" \ --title "$(translate "Network Bridge")" \ --menu "$(translate "Select bridge for network interface(s):")" \ $UI_MENU_H $UI_MENU_W $UI_MENU_LIST_H \ "${bridge_opts[@]}" 3>&1 1>&2 2>&3) [[ -n "$BRIDGE" ]] || return 1 elif [[ ${#bridge_opts[@]} -eq 1 ]]; then BRIDGE="${bridge_opts[0]}" fi return 0 } # ------------------------------------------------------- # CONFIRM BEFORE IMPORT (dialog) # ------------------------------------------------------- confirm_import() { local disk_count="${#OVF_DISK_FILES[@]}" local disk_info="" i for i in "${!OVF_DISK_FILES[@]}"; do local cap="${OVF_DISK_CAPACITIES[$i]:-0}" disk_info+="\n disk$((i+1)): ${OVF_DISK_FILES[$i]} ($(human_bytes "$cap"))" done local msg msg="$(translate "New VM:") $NEW_VMID ($NEW_VM_NAME)\n" msg+="$(translate "vCPUs:") $OVF_VCPUS $(translate "Memory:") ${OVF_MEMORY_MB} MB $(translate "OS type:") $OVF_OS_TYPE\n" msg+="$(translate "NICs:") $OVF_NET_COUNT $(translate "Bridge:") $BRIDGE\n" msg+="$(translate "Storage:") $STORAGE\n" msg+="$(translate "Disks to import:") $disk_count${disk_info}\n\n" msg+="$(translate "Continue?")" dialog --backtitle "$BACKTITLE" \ --title "$(translate "Confirm Import")" \ --yesno "$msg" 18 84 3>&1 1>&2 2>&3 } # ------------------------------------------------------- # RUN IMPORT (terminal output only — no dialogs) # ------------------------------------------------------- run_import() { show_proxmenux_logo msg_title "$(translate "Import VM from OVA or OVF")" msg_ok "$(translate "VM:") $NEW_VMID ($NEW_VM_NAME)" msg_ok "$(translate "vCPUs:") $OVF_VCPUS $(translate "Memory:") ${OVF_MEMORY_MB} MB $(translate "OS:") $OVF_OS_TYPE" msg_ok "$(translate "Storage:") $STORAGE $(translate "Bridge:") $BRIDGE $(translate "NICs:") $OVF_NET_COUNT" echo "" # 1. Create VM shell msg_info "$(translate "Creating VM...")" if ! qm create "$NEW_VMID" \ --name "$NEW_VM_NAME" \ --memory "$OVF_MEMORY_MB" \ --cores "$OVF_VCPUS" \ --ostype "$OVF_OS_TYPE" \ --scsihw lsi \ --net0 "e1000,bridge=$BRIDGE" \ &>/dev/null; then msg_error "$(translate "Failed to create VM") $NEW_VMID" return 1 fi msg_ok "$(translate "VM shell created:") $NEW_VMID" # Add extra NICs (net0 already created above) local n for n in $(seq 1 $((OVF_NET_COUNT - 1))); do qm set "$NEW_VMID" "--net${n}" "e1000,bridge=$BRIDGE" &>/dev/null || true done [[ "$OVF_NET_COUNT" -gt 1 ]] && msg_ok "$(translate "Network interfaces added:") $OVF_NET_COUNT" # 2. Import disks local disk_count="${#OVF_DISK_FILES[@]}" local i disk_file src_path local TEMP_STATUS_FILE TEMP_DISK_FILE for i in "${!OVF_DISK_FILES[@]}"; do disk_file="${OVF_DISK_FILES[$i]}" src_path="$OVF_DIR/$disk_file" if [[ ! -f "$src_path" ]]; then msg_error "$(translate "Disk file not found:") $src_path" return 1 fi echo "" msg_info "$(translate "Importing disk") $((i + 1))/$disk_count: $disk_file" msg_info2 "$(translate "Source:") $src_path" TEMP_STATUS_FILE=$(mktemp) TEMP_DISK_FILE=$(mktemp) ( qm importdisk "$NEW_VMID" "$src_path" "$STORAGE" 2>&1 echo $? > "$TEMP_STATUS_FILE" ) | while IFS= read -r line; do if [[ "$line" =~ transferred ]]; then local pct pct=$(echo "$line" | grep -oP "\d+\.\d+(?=%)") [[ -n "$pct" ]] && echo -ne "\r${TAB}${BL}- $(translate "Importing:") $disk_file -${CL} ${pct}%" elif [[ "$line" =~ successfully\ imported\ disk ]]; then echo "$line" | grep -oP "(?<=successfully imported disk ').*(?=')" > "$TEMP_DISK_FILE" fi done echo -ne "\n" local import_status import_status=$(cat "$TEMP_STATUS_FILE" 2>/dev/null) rm -f "$TEMP_STATUS_FILE" [[ -z "$import_status" ]] && import_status=1 if [[ "$import_status" -ne 0 ]]; then msg_error "$(translate "Import failed for:") $disk_file" rm -f "$TEMP_DISK_FILE" return 1 fi # Locate the unused disk entry in VM config local unused_id unused_disk unused_id=$(qm config "$NEW_VMID" | grep -E '^unused[0-9]+:' | tail -1 | cut -d: -f1) unused_disk=$(qm config "$NEW_VMID" | grep -E '^unused[0-9]+:' | tail -1 | cut -d: -f2- | xargs) rm -f "$TEMP_DISK_FILE" if [[ -z "$unused_disk" ]]; then msg_error "$(translate "Could not locate imported disk in VM config.")" return 1 fi # Attach to scsi slot i if ! qm set "$NEW_VMID" "--scsi${i}" "$unused_disk" &>/dev/null; then msg_error "$(translate "Failed to attach disk as scsi$i.")" return 1 fi # Remove the unused marker [[ -n "$unused_id" ]] && qm set "$NEW_VMID" --delete "$unused_id" &>/dev/null || true msg_ok "$(translate "Disk attached as:") scsi${i} (${disk_file})" done # 3. Set boot disk echo "" msg_info "$(translate "Configuring boot order...")" if qm set "$NEW_VMID" --boot c --bootdisk "scsi0" &>/dev/null; then msg_ok "$(translate "Boot disk:") scsi0" fi return 0 } # ------------------------------------------------------- # PRINT FINAL RESULT # ------------------------------------------------------- print_import_result() { local disk_count="${#OVF_DISK_FILES[@]}" echo "" msg_title "$(translate "Import Summary")" msg_ok "$(translate "VM imported successfully")" msg_ok "$(translate "VM ID:") $NEW_VMID $(translate "Name:") $NEW_VM_NAME" msg_ok "$(translate "vCPUs:") $OVF_VCPUS $(translate "Memory:") ${OVF_MEMORY_MB} MB $(translate "Disks:") $disk_count" msg_ok "$(translate "Storage:") $STORAGE $(translate "Bridge:") $BRIDGE $(translate "NICs:") $OVF_NET_COUNT" echo "" msg_ok "$(translate "To start the VM:") qm start $NEW_VMID" echo "" msg_title "$(translate "Manual steps recommended after import")" msg_info2 "$(translate "Network :") $(translate "Verify bridge assignment and NIC model — change to VirtIO if guest drivers are available")" msg_info2 "$(translate "Firmware :") $(translate "Check BIOS/UEFI in Hardware > BIOS — must match what the original VM used")" msg_info2 "$(translate "Drivers :") $(translate "If imported from ESXi: install qemu-guest-agent inside the guest OS")" msg_info2 "$(translate "Display :") $(translate "Set Display > Graphic card (VGA, SPICE or VirtIO) to match the guest")" msg_info2 "$(translate "OS type :") $(translate "Verify Options > OS Type — currently set to:") $OVF_OS_TYPE" echo "" msg_info2 "$(translate "Not imported:") $(translate "PCI passthrough, TPM state, cloud-init, snapshots, Proxmox-specific hooks")" echo "" } # ------------------------------------------------------- # MAIN # ------------------------------------------------------- main() { if ! command -v pveversion >/dev/null 2>&1; then dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \ --msgbox "$(translate "This script must be run on a Proxmox host.")" 8 60 exit 1 fi for cmd in dialog qm pvesm qemu-img tar; do if ! command -v "$cmd" >/dev/null 2>&1; then dialog --backtitle "$BACKTITLE" --title "$(translate "Missing dependency")" \ --msgbox "$(translate "Required command not found:") $cmd" 8 60 exit 1 fi done # Step 1: pick the OVA/OVF file (dialog) select_source_file || exit 0 # Step 2: extract + parse (terminal output) show_proxmenux_logo msg_title "$(translate "Import VM from OVA or OVF")" msg_ok "$(translate "Source:") $SOURCE_FILE" echo "" prepare_ovf || { echo "" msg_success "$(translate "Press Enter to return...")" read -r exit 1 } msg_info "$(translate "Parsing OVF descriptor...")" if ! parse_ovf "$OVF_FILE"; then msg_error "$(translate "Could not parse OVF file, or no disk image references found.")" echo "" msg_success "$(translate "Press Enter to return...")" read -r exit 1 fi msg_ok "$(translate "OVF parsed:")" msg_info2 " $(translate "Name:") $OVF_VM_NAME $(translate "vCPUs:") $OVF_VCPUS $(translate "Memory:") ${OVF_MEMORY_MB} MB" msg_info2 " $(translate "Disks:") ${#OVF_DISK_FILES[@]} $(translate "NICs:") $OVF_NET_COUNT $(translate "OS hint:") $OVF_OS_TYPE" # Clean screen before returning to dialogs show_proxmenux_logo # Step 3: configure the new VM (dialogs) select_import_options || exit 0 # Step 4: confirm (dialog) confirm_import || exit 0 # Step 5: do the import (terminal output only) if run_import; then print_import_result msg_success "$(translate "Press Enter to return to menu...")" read -r exit 0 else echo "" msg_error "$(translate "Import failed. VM $NEW_VMID may be in partial state.")" msg_info2 "$(translate "To remove partial VM:") qm destroy $NEW_VMID --destroy-unreferenced-disks 1" echo "" msg_success "$(translate "Press Enter to return...")" read -r exit 1 fi } main "$@"