#!/bin/bash # ========================================================== # ProxMenux - Export VM to 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: 07/04/2026 # ========================================================== LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" BASE_DIR="/usr/local/share/proxmenux" UTILS_FILE="$BASE_DIR/utils.sh" if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi load_language initialize_cache require_cmd() { local cmd="$1" if ! command -v "$cmd" >/dev/null 2>&1; then dialog --backtitle "ProxMenux" --title "$(translate "Missing dependency")" \ --msgbox "$(translate "Required command not found:") $cmd" 8 60 return 1 fi return 0 } human_bytes() { local bytes="$1" local units=("B" "KB" "MB" "GB" "TB" "PB") local idx=0 local value="$bytes" [[ -z "$value" || ! "$value" =~ ^[0-9]+$ ]] && { echo "N/A"; return; } while [[ "$value" -ge 1024 && "$idx" -lt 5 ]]; do value=$((value / 1024)) idx=$((idx + 1)) done echo "${value}${units[$idx]}" } sanitize_name() { local raw="$1" local out out=$(echo "$raw" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-zA-Z0-9._-]/_/g' | sed 's/__*/_/g' | sed 's/^_\+//;s/_\+$//') [[ -z "$out" ]] && out="vm" echo "$out" } xml_escape() { local s="$1" s=${s//&/&} s=${s///>} s=${s//\"/"} s=${s//\'/'} echo "$s" } validate_destination_dir() { local dir="$1" if [[ ! -d "$dir" ]]; then dialog --backtitle "ProxMenux" --title "$(translate "Directory error")" \ --msgbox "$(translate "Destination directory does not exist:")\n$dir" 8 74 return 1 fi if [[ ! -w "$dir" ]]; then dialog --backtitle "ProxMenux" --title "$(translate "Permission error")" \ --msgbox "$(translate "Destination directory is not writable:")\n$dir" 8 70 return 1 fi return 0 } select_vm() { local options=() local line vmid name status while read -r line; do [[ -z "$line" ]] && continue vmid=$(echo "$line" | awk '{print $1}') name=$(echo "$line" | awk '{print $2}') status=$(echo "$line" | awk '{print $3}') [[ -z "$vmid" || "$vmid" == "VMID" ]] && continue [[ -z "$name" ]] && name="vm-${vmid}" options+=("$vmid" "$name [$status]") done < <(qm list 2>/dev/null) if [[ ${#options[@]} -eq 0 ]]; then dialog --backtitle "ProxMenux" --title "$(translate "No VMs found")" \ --msgbox "$(translate "No virtual machines were found on this host.")" 8 60 return 1 fi VMID=$(dialog --backtitle "ProxMenux" --title "$(translate "Export VM to OVA or OVF")" \ --menu "$(translate "Select VM to export:")" 20 80 12 \ "${options[@]}" 3>&1 1>&2 2>&3) [[ -n "$VMID" ]] || return 1 return 0 } ensure_vm_stopped() { local status status=$(qm status "$VMID" 2>/dev/null | awk '{print $2}') if [[ "$status" == "stopped" ]]; then return 0 fi if ! dialog --backtitle "ProxMenux" --title "$(translate "VM is running")" --yesno \ "$(translate "For a consistent export, the VM should be stopped.")\n\n$(translate "Do you want ProxMenux to stop it now?")" 10 70; then return 1 fi qm shutdown "$VMID" --timeout 120 >/dev/null 2>&1 || true local i for i in $(seq 1 60); do status=$(qm status "$VMID" 2>/dev/null | awk '{print $2}') [[ "$status" == "stopped" ]] && return 0 sleep 2 done if dialog --backtitle "ProxMenux" --title "$(translate "Shutdown timeout")" --yesno \ "$(translate "Graceful shutdown timed out.")\n\n$(translate "Force stop VM now?")" 10 60; then qm stop "$VMID" >/dev/null 2>&1 || true sleep 2 status=$(qm status "$VMID" 2>/dev/null | awk '{print $2}') [[ "$status" == "stopped" ]] && return 0 fi dialog --backtitle "ProxMenux" --title "$(translate "Cannot continue")" \ --msgbox "$(translate "VM is still running. Export cancelled.")" 8 60 return 1 } select_export_mode() { EXPORT_MODE=$(dialog --backtitle "ProxMenux" --title "$(translate "Export Format")" \ --menu "$(translate "Select export format:")" 14 70 4 \ "ova" "$(translate "OVA (single portable file)")" \ "ovf" "$(translate "OVF (descriptor + VMDK files)")" \ 3>&1 1>&2 2>&3) [[ -n "$EXPORT_MODE" ]] || return 1 return 0 } select_destination_dir() { local dump_dir="/var/lib/vz/dump" local iso_dir="/var/lib/vz/template/iso" local options=( "1" "$dump_dir [$(translate "recommended")]" "2" "$iso_dir [$(translate "recommended")]" "M" "$(translate "Manual path entry")" ) while true; do local choice choice=$(dialog --backtitle "ProxMenux" --title "$(translate "Destination Directory")" \ --menu "$(translate "Select where to export VM files (OVA/OVF + temporary workspace):")" \ 16 84 8 "${options[@]}" 3>&1 1>&2 2>&3) [[ -n "$choice" ]] || return 1 case "$choice" in M) DEST_DIR=$(dialog --backtitle "ProxMenux" --title "$(translate "Manual destination path")" \ --inputbox "$(translate "Enter destination directory for exported file(s):")" \ 10 90 "/var/lib/vz/dump" 3>&1 1>&2 2>&3) [[ -n "$DEST_DIR" ]] || continue if [[ ! -d "$DEST_DIR" ]]; then if dialog --backtitle "ProxMenux" --title "$(translate "Create directory")" \ --yesno "$(translate "The selected directory does not exist:")\n$DEST_DIR\n\n$(translate "Do you want to create it now?")" \ 11 80; then if ! mkdir -p "$DEST_DIR" 2>/dev/null; then dialog --backtitle "ProxMenux" --title "$(translate "Directory error")" \ --msgbox "$(translate "Could not create destination directory:")\n$DEST_DIR" 8 74 continue fi else continue fi fi validate_destination_dir "$DEST_DIR" || continue return 0 ;; 1) DEST_DIR="$dump_dir" validate_destination_dir "$DEST_DIR" || continue return 0 ;; 2) DEST_DIR="$iso_dir" validate_destination_dir "$DEST_DIR" || continue return 0 ;; *) continue ;; esac done } get_vm_metadata() { VM_CONF=$(qm config "$VMID" 2>/dev/null) || return 1 VM_NAME=$(echo "$VM_CONF" | awk -F': ' '/^name:/{print $2; exit}') [[ -z "$VM_NAME" ]] && VM_NAME="vm-${VMID}" VM_MEMORY=$(echo "$VM_CONF" | awk -F': ' '/^memory:/{print $2; exit}') [[ -z "$VM_MEMORY" ]] && VM_MEMORY=1024 VM_CORES=$(echo "$VM_CONF" | awk -F': ' '/^cores:/{print $2; exit}') VM_SOCKETS=$(echo "$VM_CONF" | awk -F': ' '/^sockets:/{print $2; exit}') [[ -z "$VM_CORES" ]] && VM_CORES=1 [[ -z "$VM_SOCKETS" ]] && VM_SOCKETS=1 VM_VCPUS=$((VM_CORES * VM_SOCKETS)) VM_OSTYPE=$(echo "$VM_CONF" | awk -F': ' '/^ostype:/{print $2; exit}') case "$VM_OSTYPE" in l26|l24) VM_OS_DESC="Linux" ;; win11|win10|win8|win7|w2k8|w2k12|w2k16|w2k19|w2k22|wxp|w2k|w2k3) VM_OS_DESC="Windows" ;; *) VM_OS_DESC="Other" ;; esac NET_COUNT=$(echo "$VM_CONF" | grep -E '^net[0-9]+:' | wc -l) } get_virtual_size_bytes() { local src="$1" local bytes="" bytes=$(qemu-img info "$src" 2>/dev/null | sed -n 's/.*virtual size:.*(\([0-9]\+\) bytes).*/\1/p' | head -1) if [[ -n "$bytes" && "$bytes" =~ ^[0-9]+$ ]]; then echo "$bytes" return 0 fi if [[ -b "$src" ]]; then bytes=$(blockdev --getsize64 "$src" 2>/dev/null || true) if [[ -n "$bytes" && "$bytes" =~ ^[0-9]+$ ]]; then echo "$bytes" return 0 fi fi bytes=$(stat -c%s "$src" 2>/dev/null || true) if [[ -n "$bytes" && "$bytes" =~ ^[0-9]+$ ]]; then echo "$bytes" return 0 fi echo "0" return 0 } collect_vm_disks() { DISK_COUNT=0 unset DISK_SLOTS DISK_SRCS DISK_VSIZES declare -ga DISK_SLOTS DISK_SRCS DISK_VSIZES local line slot value source src while IFS= read -r line; do if [[ "$line" =~ ^(scsi|sata|virtio|ide)[0-9]+: ]]; then slot="${line%%:*}" value="${line#*: }" [[ "$value" == *"media=cdrom"* ]] && continue [[ "$value" == *"cloudinit"* ]] && continue source="${value%%,*}" [[ -z "$source" || "$source" == "none" ]] && continue src="" if [[ "$source" == /dev/* || "$source" == /* ]]; then src="$source" elif [[ "$source" == *:* ]]; then src=$(pvesm path "$source" 2>/dev/null || true) fi if [[ -z "$src" || ! -e "$src" ]]; then continue fi DISK_SLOTS+=("$slot") DISK_SRCS+=("$src") DISK_VSIZES+=("$(get_virtual_size_bytes "$src")") DISK_COUNT=$((DISK_COUNT + 1)) fi done <<< "$VM_CONF" [[ "$DISK_COUNT" -gt 0 ]] || return 1 return 0 } check_destination_space() { local total=0 local i for i in "${DISK_VSIZES[@]}"; do [[ "$i" =~ ^[0-9]+$ ]] && total=$((total + i)) done local factor=120 [[ "$EXPORT_MODE" == "ova" ]] && factor=220 REQUIRED_BYTES=$((total * factor / 100)) AVAILABLE_BYTES=$(df -PB1 "$DEST_DIR" | awk 'NR==2{print $4}') [[ "$AVAILABLE_BYTES" =~ ^[0-9]+$ ]] || AVAILABLE_BYTES=0 if [[ "$AVAILABLE_BYTES" -lt "$REQUIRED_BYTES" ]]; then if ! dialog --backtitle "ProxMenux" --title "$(translate "Low free space warning")" --yesno \ "$(translate "Estimated required free space:") $(human_bytes "$REQUIRED_BYTES") ($REQUIRED_BYTES bytes)\n$(translate "Current free space:") $(human_bytes "$AVAILABLE_BYTES") ($AVAILABLE_BYTES bytes)\n\n$(translate "Do you want to continue anyway?")" 13 90; then return 1 fi fi return 0 } generate_ovf_descriptor() { local ovf_path="$1" local vm_name_xml os_desc_xml vm_name_xml=$(xml_escape "$VM_NAME") os_desc_xml=$(xml_escape "$VM_OS_DESC") { echo '' echo '' echo ' ' } > "$ovf_path" local idx file_id disk_id file_name file_size capacity for idx in "${!EXPORT_DISK_FILES[@]}"; do file_id="file$((idx + 1))" file_name="${EXPORT_DISK_FILES[$idx]}" file_size=$(stat -c%s "$WORK_DIR/$file_name") echo " " >> "$ovf_path" done { echo ' ' echo ' ' echo ' Virtual disk information' } >> "$ovf_path" for idx in "${!EXPORT_DISK_FILES[@]}"; do file_id="file$((idx + 1))" disk_id="vmdisk$((idx + 1))" capacity="${DISK_VSIZES[$idx]}" [[ -z "$capacity" || "$capacity" -le 0 ]] && capacity=$(stat -c%s "$WORK_DIR/${EXPORT_DISK_FILES[$idx]}") echo " " >> "$ovf_path" done { echo ' ' echo " " echo ' A virtual machine' echo " $vm_name_xml" echo ' ' echo ' Guest operating system' echo " $os_desc_xml" echo ' ' echo ' ' echo ' Virtual hardware requirements' echo ' ' echo ' Virtual Hardware Family' echo ' 0' echo ' vm' echo ' vmx-14' echo ' ' echo ' ' echo ' hertz * 10^6' echo ' Number of Virtual CPUs' echo ' Virtual CPU(s)' echo ' 1' echo " $VM_VCPUS" echo ' 3' echo ' ' echo ' ' echo ' byte * 2^20' echo ' Memory Size' echo ' Memory' echo ' 2' echo " $VM_MEMORY" echo ' 4' echo ' ' echo ' ' echo ' 0' echo ' SCSI Controller' echo ' SCSI Controller 0' echo ' 10' echo ' lsilogic' echo ' 6' echo ' ' } >> "$ovf_path" for idx in "${!EXPORT_DISK_FILES[@]}"; do disk_id="vmdisk$((idx + 1))" echo ' ' >> "$ovf_path" echo " $idx" >> "$ovf_path" echo ' Hard disk' >> "$ovf_path" echo " Hard disk $((idx + 1))" >> "$ovf_path" echo " ovf:/disk/$disk_id" >> "$ovf_path" echo " $((200 + idx + 1))" >> "$ovf_path" echo ' 10' >> "$ovf_path" echo ' 17' >> "$ovf_path" echo ' ' >> "$ovf_path" done if [[ "$NET_COUNT" -gt 0 ]]; then local n for n in $(seq 1 "$NET_COUNT"); do { echo ' ' echo ' true' echo ' VM Network' echo " Ethernet adapter $n" echo " $((300 + n))" echo ' 10' echo ' ' } >> "$ovf_path" done fi { echo ' ' echo ' ' echo '' } >> "$ovf_path" } generate_manifest() { local mf_path="$1" shift local files=("$@") : > "$mf_path" local f hash for f in "${files[@]}"; do hash=$(sha1sum "$WORK_DIR/$f" | awk '{print $1}') echo "SHA1($f)= $hash" >> "$mf_path" done } print_export_result() { local mode="$1" local path="$2" echo "" msg_title "$(translate "Export Summary")" msg_ok "$(translate "VM:") ${VMID} — ${VM_NAME}" msg_ok "$(translate "vCPUs:") ${VM_VCPUS} $(translate "Memory:") ${VM_MEMORY} MB $(translate "Disks exported:") ${DISK_COUNT}" echo "" if [[ "$mode" == "ova" ]]; then local ova_size ova_sha1 ova_size=$(stat -c%s "$path" 2>/dev/null || echo 0) ova_sha1=$(sha1sum "$path" 2>/dev/null | awk '{print $1}') msg_ok "$(translate "Format:") OVA — $(translate "single portable archive")" msg_ok "$(translate "File:") $path" msg_ok "$(translate "Size:") $(human_bytes "$ova_size") (${ova_size} $(translate "bytes"))" msg_ok "SHA1: ${ova_sha1}" else local fsz total_size=0 f msg_ok "$(translate "Format:") OVF — $(translate "descriptor + VMDK files")" msg_ok "$(translate "Directory:") $path" for f in "${EXPORT_DISK_FILES[@]}"; do fsz=$(stat -c%s "$path/$f" 2>/dev/null || echo 0) total_size=$((total_size + fsz)) msg_info2 " ${f} [$(human_bytes "$fsz")]" done msg_ok "$(translate "Total size:") $(human_bytes "$total_size")" fi echo "" msg_ok "$(translate "Compatible with:") VMware ESXi 6.7+ (vmx-14) · VMware Workstation / Fusion · VirtualBox · Proxmox VE" msg_info2 "$(translate "Not portable:") $(translate "PCI passthrough, TPM state, cloud-init configuration, Proxmox hooks")" echo "" } run_export() { show_proxmenux_logo msg_title "$(translate "Export VM to OVA or OVF")" msg_ok "$(translate "VM selected:") $VMID ($VM_NAME)" msg_ok "$(translate "Export mode:") ${EXPORT_MODE^^}" msg_ok "$(translate "Destination:") $DEST_DIR" local ts vm_safe base_name ts=$(date +%Y%m%d_%H%M%S) vm_safe=$(sanitize_name "$VM_NAME") base_name="${vm_safe}-${VMID}-${ts}" WORK_DIR=$(mktemp -d "$DEST_DIR/.ovaovf-${base_name}-XXXXXX") if [[ ! -d "$WORK_DIR" ]]; then msg_error "$(translate "Could not create temporary working directory.")" return 1 fi msg_ok "$(translate "Working directory:") $WORK_DIR" # Clean up temp dir on unexpected exit (Ctrl+C, unhandled error, etc.) trap 'rm -rf "$WORK_DIR" 2>/dev/null' EXIT declare -ga EXPORT_DISK_FILES EXPORT_DISK_FILES=() local i src dst disk_name for i in "${!DISK_SRCS[@]}"; do src="${DISK_SRCS[$i]}" disk_name="${base_name}-disk$((i + 1)).vmdk" dst="$WORK_DIR/$disk_name" echo "" msg_info "$(translate "Converting disk") $((i + 1))/$DISK_COUNT: ${DISK_SLOTS[$i]}" msg_info2 "$(translate "Source:") $src" if ! qemu-img convert -p -O vmdk -o subformat=streamOptimized "$src" "$dst"; then msg_error "$(translate "Disk conversion failed for") ${DISK_SLOTS[$i]}" return 1 fi EXPORT_DISK_FILES+=("$disk_name") msg_ok "$(translate "Converted:") $disk_name" done local ovf_file mf_file ovf_file="${base_name}.ovf" mf_file="${base_name}.mf" msg_info "$(translate "Generating OVF descriptor...")" generate_ovf_descriptor "$WORK_DIR/$ovf_file" msg_info "$(translate "Generating manifest...")" generate_manifest "$WORK_DIR/$mf_file" "$ovf_file" "${EXPORT_DISK_FILES[@]}" if [[ "$EXPORT_MODE" == "ovf" ]]; then local final_dir="$DEST_DIR/${base_name}-ovf" rm -rf "$final_dir" trap - EXIT mv "$WORK_DIR" "$final_dir" print_export_result "ovf" "$final_dir" return 0 fi local ova_path="$DEST_DIR/${base_name}.ova" msg_info "$(translate "Packaging OVA file...")" if ! tar -C "$WORK_DIR" -cf "$ova_path" "$ovf_file" "$mf_file" "${EXPORT_DISK_FILES[@]}"; then msg_error "$(translate "Failed to create OVA archive.")" return 1 fi trap - EXIT rm -rf "$WORK_DIR" print_export_result "ova" "$ova_path" return 0 } main() { require_cmd dialog || exit 1 require_cmd qm || exit 1 require_cmd pvesm || exit 1 require_cmd qemu-img || exit 1 require_cmd tar || exit 1 require_cmd sha1sum || exit 1 if ! command -v pveversion >/dev/null 2>&1; then dialog --backtitle "ProxMenux" --title "$(translate "Error")" \ --msgbox "$(translate "This script must be run on a Proxmox host.")" 8 60 exit 1 fi select_vm || exit 0 ensure_vm_stopped || exit 0 select_export_mode || exit 0 select_destination_dir || exit 0 get_vm_metadata || { dialog --backtitle "ProxMenux" --title "$(translate "Error")" \ --msgbox "$(translate "Could not read VM configuration.")" 8 60 exit 1 } collect_vm_disks || { dialog --backtitle "ProxMenux" --title "$(translate "No exportable disks")" \ --msgbox "$(translate "No exportable VM disks were found (CD-ROM/cloud-init are excluded).")" 9 80 exit 1 } check_destination_space || exit 0 if ! dialog --backtitle "ProxMenux" --title "$(translate "Confirm export")" --yesno \ "$(translate "VM:") $VMID ($VM_NAME)\n$(translate "Disks to export:") $DISK_COUNT\n$(translate "Format:") ${EXPORT_MODE^^}\n$(translate "Destination:") $DEST_DIR\n\n$(translate "Continue?")" 13 80; then exit 0 fi if run_export; then echo "" msg_success "$(translate "Press Enter to return...")" read -r exit 0 else echo "" msg_error "$(translate "Export failed.")" msg_info2 "$(translate "Temporary working directory (if present):") $WORK_DIR" msg_success "$(translate "Press Enter to return...")" read -r exit 1 fi } main "$@"