#!/usr/bin/env bash # Copyright (c) 2021-2025 community-scripts ORG # License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE # Universal VM Template Manager - Create, Deploy, List Templates with optional Cloud-Init set -euo pipefail # ============================================================================ # OS IMAGE CATALOG # ============================================================================ declare -A OS_IMAGES=( # Debian - Cloud-Init enabled ["debian-13"]="https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2" ["debian-12"]="https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2" # Debian - NoCloud variants (without cloud-init pre-installed) ["debian-13-nocloud"]="https://cloud.debian.org/images/cloud/trixie/latest/debian-13-nocloud-amd64.qcow2" ["debian-12-nocloud"]="https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-nocloud-amd64.qcow2" # Ubuntu - Cloud-Init enabled ["ubuntu-24.04"]="https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img" ["ubuntu-22.04"]="https://cloud-images.ubuntu.com/releases/22.04/release/ubuntu-22.04-server-cloudimg-amd64.img" ["ubuntu-20.04"]="https://cloud-images.ubuntu.com/releases/20.04/release/ubuntu-20.04-server-cloudimg-amd64.img" # AlmaLinux ["alma-9"]="https://repo.almalinux.org/almalinux/9/cloud/x86_64/images/AlmaLinux-9-GenericCloud-latest.x86_64.qcow2" ["alma-8"]="https://repo.almalinux.org/almalinux/8/cloud/x86_64/images/AlmaLinux-8-GenericCloud-latest.x86_64.qcow2" # Rocky Linux ["rocky-9"]="https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2" ["rocky-8"]="https://dl.rockylinux.org/pub/rocky/8/images/x86_64/Rocky-8-GenericCloud-Base.latest.x86_64.qcow2" # Fedora ["fedora-41"]="https://download.fedoraproject.org/pub/fedora/linux/releases/41/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-41-1.4.x86_64.qcow2" ["fedora-40"]="https://download.fedoraproject.org/pub/fedora/linux/releases/40/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-40-1.14.x86_64.qcow2" # Arch Linux ["arch"]="https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2" # CentOS Stream ["centos-9"]="https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2" # Alpine Linux ["alpine-3.20"]="https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/cloud/generic_alpine-3.20.0-x86_64-bios-cloudinit-r0.qcow2" ) # ============================================================================ # CONFIGURATION # ============================================================================ # Defaults MODE="" TEMPLATE_PREFIX="template" TEMPLATE_ID_START=900 VMID="" OS_KEY="" HOSTNAME="" CORES=2 MEMORY=2048 DISK_SIZE=30 STORAGE="" BRIDGE="vmbr0" START_VM="no" MACHINE_TYPE="q35" # Cloud-Init Options ENABLE_CLOUDINIT="yes" # yes|no - Enable/disable cloud-init drive CI_USER="" CI_PASSWORD="" CI_SSH_KEY="" CI_FILE="" # Post-Install Scripts POST_INSTALL="" # none|docker|podman|portainer POST_INSTALL_TIMEOUT=300 # Timeout in seconds for post-install completion # Interactive Mode INTERACTIVE_MODE="no" # yes|no - Enable whiptail interactive mode # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;36m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' # ============================================================================ # HELPER FUNCTIONS # ============================================================================ info() { echo -e "${BLUE}ℹ${NC} $*"; } ok() { echo -e "${GREEN}✓${NC} $*"; } error() { echo -e "${RED}✗${NC} $*" >&2 exit 1 } warn() { echo -e "${YELLOW}⚠${NC} $*"; } get_next_vmid() { local start_id=${1:-100} local id=$start_id while [ -f "/etc/pve/qemu-server/${id}.conf" ] || [ -f "/etc/pve/lxc/${id}.conf" ]; do id=$((id + 1)) done echo "$id" } get_default_storage() { pvesm status -content images 2>/dev/null | awk 'NR==2 {print $1}' || echo "local-lvm" } list_storage_pools() { pvesm status -content images 2>/dev/null | awk 'NR>1 {print $1}' } get_snippet_storage() { pvesm status -content snippets 2>/dev/null | awk 'NR==2 {print $1}' || echo "local" } find_template_by_name() { local name=$1 qm list 2>/dev/null | awk -v n="$name" '$2 == n {print $1; exit}' } is_template() { local vmid=$1 qm config "$vmid" 2>/dev/null | grep -q "^template: 1" } cleanup_vm() { if [ -n "${VMID:-}" ] && qm status "$VMID" &>/dev/null; then warn "Cleanup: Removing VM $VMID" qm destroy "$VMID" &>/dev/null || true fi } # ============================================================================ # INTERACTIVE WHIPTAIL MENUS # ============================================================================ interactive_main_menu() { local choice choice=$(whiptail --title "VM Manager" --menu "Choose action:" 20 70 10 \ "1" "Create VM Template" \ "2" "Deploy VM from Template" \ "3" "List Templates" \ "4" "List Available OS Images" \ "5" "Exit" \ 3>&1 1>&2 2>&3) case $choice in 1) interactive_create_template ;; 2) interactive_deploy_vm ;; 3) list_templates; read -p "Press Enter to continue..."; interactive_main_menu ;; 4) list_os_options; read -p "Press Enter to continue..."; interactive_main_menu ;; 5) exit 0 ;; *) exit 0 ;; esac } interactive_select_os() { local menu_items=() local i=1 for key in $(echo "${!OS_IMAGES[@]}" | tr ' ' '\n' | sort); do menu_items+=("$key" "${OS_IMAGES[$key]}") done OS_KEY=$(whiptail --title "Select OS" --menu "Choose operating system:" 25 80 15 \ "${menu_items[@]}" 3>&1 1>&2 2>&3) [ -z "$OS_KEY" ] && return 1 return 0 } interactive_create_template() { clear info "Creating VM Template - Interactive Mode" echo "" # Select OS interactive_select_os || { warn "No OS selected"; interactive_main_menu; return; } # VM ID local input_vmid input_vmid=$(whiptail --title "VM ID" --inputbox "Enter VM ID (leave empty for auto):" 10 60 "" 3>&1 1>&2 2>&3) [ -n "$input_vmid" ] && VMID="$input_vmid" # CPU Cores CORES=$(whiptail --title "CPU Cores" --inputbox "Number of CPU cores:" 10 60 "$CORES" 3>&1 1>&2 2>&3) || CORES=2 # Memory MEMORY=$(whiptail --title "Memory" --inputbox "Memory in MB:" 10 60 "$MEMORY" 3>&1 1>&2 2>&3) || MEMORY=2048 # Disk Size DISK_SIZE=$(whiptail --title "Disk Size" --inputbox "Disk size in GB:" 10 60 "$DISK_SIZE" 3>&1 1>&2 2>&3) || DISK_SIZE=30 # Storage local storage_list=() while IFS= read -r stor; do storage_list+=("$stor" "") done < <(list_storage_pools) if [ ${#storage_list[@]} -gt 0 ]; then STORAGE=$(whiptail --title "Storage Pool" --menu "Select storage:" 20 70 10 \ "${storage_list[@]}" 3>&1 1>&2 2>&3) || STORAGE="" fi # Cloud-Init if whiptail --title "Cloud-Init" --yesno "Enable Cloud-Init?" 10 60; then ENABLE_CLOUDINIT="yes" # Optional credentials if whiptail --title "Cloud-Init Credentials" --yesno "Configure Cloud-Init credentials now?" 10 60; then CI_USER=$(whiptail --title "Cloud-Init User" --inputbox "Username:" 10 60 "root" 3>&1 1>&2 2>&3) || CI_USER="" CI_PASSWORD=$(whiptail --title "Cloud-Init Password" --passwordbox "Password (optional):" 10 60 3>&1 1>&2 2>&3) || CI_PASSWORD="" CI_SSH_KEY=$(whiptail --title "SSH Public Key" --inputbox "SSH Public Key (optional):" 10 60 "" 3>&1 1>&2 2>&3) || CI_SSH_KEY="" fi else ENABLE_CLOUDINIT="no" fi # Confirm if whiptail --title "Confirm" --yesno "Create template with these settings?\n\nOS: $OS_KEY\nCores: $CORES\nMemory: ${MEMORY}MB\nDisk: ${DISK_SIZE}GB\nCloud-Init: $ENABLE_CLOUDINIT" 15 60; then clear create_template echo "" read -p "Press Enter to continue..." fi interactive_main_menu } interactive_deploy_vm() { clear info "Deploy VM from Template - Interactive Mode" echo "" # Select OS (template must exist) interactive_select_os || { warn "No OS selected"; interactive_main_menu; return; } # Check if template exists local template_name="${TEMPLATE_PREFIX}-${OS_KEY}" local template_id=$(find_template_by_name "$template_name") if [ -z "$template_id" ]; then whiptail --title "Error" --msgbox "Template '$template_name' not found!\n\nCreate it first." 10 60 interactive_main_menu return fi # Hostname HOSTNAME=$(whiptail --title "Hostname" --inputbox "Enter hostname:" 10 60 "${OS_KEY}-vm" 3>&1 1>&2 2>&3) [ -z "$HOSTNAME" ] && HOSTNAME="${OS_KEY}-vm" # VM ID local input_vmid input_vmid=$(whiptail --title "VM ID" --inputbox "Enter VM ID (leave empty for auto):" 10 60 "" 3>&1 1>&2 2>&3) [ -n "$input_vmid" ] && VMID="$input_vmid" # Disk Size local template_size=$(qm config "$template_id" | grep "scsi0:" | grep -oP '\d+G' | head -1 | sed 's/G//') DISK_SIZE=$(whiptail --title "Disk Size" --inputbox "Disk size in GB:" 10 60 "${template_size:-30}" 3>&1 1>&2 2>&3) || DISK_SIZE=30 # Post-Install local post_choice post_choice=$(whiptail --title "Post-Install" --menu "Install additional software?" 20 70 10 \ "none" "No additional software" \ "docker" "Install Docker CE" \ "podman" "Install Podman" \ "portainer" "Install Docker + Portainer" \ 3>&1 1>&2 2>&3) POST_INSTALL="${post_choice:-none}" # Start VM if whiptail --title "Start VM" --yesno "Start VM after deployment?" 10 60; then START_VM="yes" else START_VM="no" fi # Confirm local confirm_msg="Deploy VM with these settings?\n\nTemplate: $template_name\nHostname: $HOSTNAME\nDisk: ${DISK_SIZE}GB" [ "$POST_INSTALL" != "none" ] && confirm_msg+="\nPost-Install: $POST_INSTALL" [ "$START_VM" = "yes" ] && confirm_msg+="\nAuto-start: Yes" if whiptail --title "Confirm" --yesno "$confirm_msg" 18 60; then clear deploy_from_template echo "" read -p "Press Enter to continue..." fi interactive_main_menu } wait_for_vm_ready() { local vmid=$1 local timeout=${2:-120} local elapsed=0 info "Waiting for VM $vmid to be ready..." while [ $elapsed -lt $timeout ]; do if qm guest exec $vmid -- test -f /usr/bin/systemctl &>/dev/null; then ok "VM is ready" return 0 fi sleep 5 elapsed=$((elapsed + 5)) done warn "VM readiness timeout after ${timeout}s" return 1 } run_post_install() { local vmid=$1 local script_type=$2 [ -z "$script_type" ] || [ "$script_type" = "none" ] && return 0 info "Running post-install: $script_type" # Wait for VM to be ready wait_for_vm_ready "$vmid" 180 || { warn "VM not ready, skipping post-install" return 1 } case "$script_type" in docker) info "Installing Docker..." qm guest exec "$vmid" -- bash -c ' curl -fsSL https://get.docker.com | sh && \ systemctl enable --now docker && \ usermod -aG docker $(whoami) 2>/dev/null || true ' || { warn "Docker installation failed" return 1 } ok "Docker installed successfully" ;; podman) info "Installing Podman..." qm guest exec "$vmid" -- bash -c ' if command -v apt-get &>/dev/null; then apt-get update && apt-get install -y podman elif command -v dnf &>/dev/null; then dnf install -y podman elif command -v yum &>/dev/null; then yum install -y podman else echo "Package manager not supported" exit 1 fi && \ systemctl enable --now podman || true ' || { warn "Podman installation failed" return 1 } ok "Podman installed successfully" ;; portainer) info "Installing Portainer (requires Docker)..." # First install Docker run_post_install "$vmid" "docker" || return 1 info "Deploying Portainer container..." qm guest exec "$vmid" -- bash -c ' docker volume create portainer_data && \ docker run -d \ -p 8000:8000 \ -p 9443:9443 \ --name portainer \ --restart=always \ -v /var/run/docker.sock:/var/run/docker.sock \ -v portainer_data:/data \ portainer/portainer-ce:latest ' || { warn "Portainer deployment failed" return 1 } ok "Portainer deployed successfully" info "Access Portainer at: https://:9443" ;; *) warn "Unknown post-install script: $script_type" return 1 ;; esac return 0 } # ============================================================================ # TEMPLATE OPERATIONS # ============================================================================ list_templates() { echo -e "\n${BOLD}${CYAN}Available VM Templates:${NC}\n" echo "┌──────┬────────────────────────────┬─────────┬────────┬──────────┬────────────┐" echo "│ VMID │ Name │ Cores │ Memory │ Disk │ Cloud-Init │" echo "├──────┼────────────────────────────┼─────────┼────────┼──────────┼────────────┤" local found=0 while IFS= read -r line; do local vmid=$(echo "$line" | awk '{print $1}') local name=$(echo "$line" | awk '{print $2}') if is_template "$vmid"; then local config=$(qm config "$vmid" 2>/dev/null) local cores=$(echo "$config" | grep "^cores:" | awk '{print $2}') local memory=$(echo "$config" | grep "^memory:" | awk '{print $2}') local disk=$(echo "$config" | grep "scsi0:" | grep -oP '\d+G' | head -1) local has_ci=$(echo "$config" | grep -q "ide2:.*cloudinit" && echo "Yes" || echo "No") printf "│ %-4s │ %-26s │ %-7s │ %-6s │ %-8s │ %-10s │\n" \ "$vmid" "$name" "${cores:-N/A}" "${memory:-N/A}MB" "${disk:-N/A}" "$has_ci" found=$((found + 1)) fi done < <(qm list 2>/dev/null | tail -n +2) echo "└──────┴────────────────────────────┴─────────┴────────┴──────────┴────────────┘" if [ $found -eq 0 ]; then echo -e "\n${YELLOW}No templates found.${NC}" echo -e "Create one with: $0 create --os \n" else echo -e "\n${GREEN}Total: $found template(s)${NC}\n" fi } list_os_options() { echo -e "\n${BOLD}${CYAN}Available OS Images:${NC}\n" local i=1 for key in $(echo "${!OS_IMAGES[@]}" | tr ' ' '\n' | sort); do printf "%2d) %-20s %s\n" $i "$key" "${OS_IMAGES[$key]}" i=$((i + 1)) done echo "" } create_template() { # Validate OS [ -z "$OS_KEY" ] && error "OS not specified. Use --os " [ -z "${OS_IMAGES[$OS_KEY]:-}" ] && error "Unknown OS: $OS_KEY (use --list-os)" local image_url="${OS_IMAGES[$OS_KEY]}" local template_name="${TEMPLATE_PREFIX}-${OS_KEY}" # Check if template already exists local existing_id=$(find_template_by_name "$template_name") if [ -n "$existing_id" ]; then warn "Template '$template_name' already exists (ID: $existing_id)" read -p "Overwrite? (y/N): " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then info "Aborted" exit 0 fi qm destroy "$existing_id" &>/dev/null || true fi # Get VM ID [ -z "$VMID" ] && VMID=$(get_next_vmid $TEMPLATE_ID_START) [ -z "$STORAGE" ] && STORAGE=$(get_default_storage) info "Creating template: $template_name (ID: $VMID)" [ "$ENABLE_CLOUDINIT" = "yes" ] && info "Cloud-Init: Enabled" || info "Cloud-Init: Disabled" # Download/cache image local cache_dir="/var/lib/vz/template/cache" local image_file="$cache_dir/$(basename "$image_url")" mkdir -p "$cache_dir" if [ ! -f "$image_file" ]; then info "Downloading image..." curl -fL --progress-bar -o "$image_file" "$image_url" || error "Download failed" ok "Image downloaded" else ok "Using cached image" fi # Create VM info "Creating VM shell" qm create "$VMID" \ --name "$template_name" \ --machine "$MACHINE_TYPE" \ --bios ovmf \ --cores "$CORES" \ --memory "$MEMORY" \ --net0 "virtio,bridge=$BRIDGE" \ --scsihw virtio-scsi-single \ --ostype l26 \ --agent enabled=1 \ >/dev/null || error "VM creation failed" ok "VM shell created" # Import disk info "Importing disk" local import_out if command -v "qm" &>/dev/null && qm disk import --help &>/dev/null 2>&1; then import_out=$(qm disk import "$VMID" "$image_file" "$STORAGE" --format qcow2 2>&1 || true) else import_out=$(qm importdisk "$VMID" "$image_file" "$STORAGE" 2>&1 || true) fi local disk_ref=$(echo "$import_out" | grep -oP "vm-$VMID-disk-\d+" | head -1) [ -z "$disk_ref" ] && disk_ref=$(pvesm list "$STORAGE" | awk -v id="$VMID" '$5 ~ ("vm-"id"-disk-") {print $5}' | sort | tail -n1) [ -z "$disk_ref" ] && error "Disk import failed" ok "Disk imported: $disk_ref" # Configure disks info "Configuring disks" if [ "$ENABLE_CLOUDINIT" = "yes" ]; then qm set "$VMID" \ --scsi0 "${STORAGE}:${disk_ref},discard=on" \ --boot order=scsi0 \ --ide2 "${STORAGE}:cloudinit" \ >/dev/null || error "Disk configuration failed" else qm set "$VMID" \ --scsi0 "${STORAGE}:${disk_ref},discard=on" \ --boot order=scsi0 \ >/dev/null || error "Disk configuration failed" fi # Resize disk qm resize "$VMID" scsi0 "${DISK_SIZE}G" >/dev/null 2>&1 || warn "Disk resize failed" ok "Disk configured (${DISK_SIZE}G)" # Cloud-Init configuration if [ "$ENABLE_CLOUDINIT" = "yes" ]; then if [ -n "$CI_USER" ] && [ -n "$CI_SSH_KEY" ]; then info "Configuring Cloud-Init credentials" qm set "$VMID" --ciuser "$CI_USER" >/dev/null qm set "$VMID" --sshkeys <(echo "$CI_SSH_KEY") >/dev/null [ -n "$CI_PASSWORD" ] && qm set "$VMID" --cipassword "$CI_PASSWORD" >/dev/null ok "Cloud-Init configured" else info "Cloud-Init drive created (configure after deployment)" fi fi # Convert to template info "Converting to template" qm template "$VMID" >/dev/null || error "Template conversion failed" ok "Template created successfully!" echo "" echo " Template ID: $VMID" echo " Template Name: $template_name" echo " OS: $OS_KEY" echo " Cloud-Init: $ENABLE_CLOUDINIT" echo "" } deploy_from_template() { local template_name="${TEMPLATE_PREFIX}-${OS_KEY}" local template_id=$(find_template_by_name "$template_name") [ -z "$template_id" ] && error "Template '$template_name' not found" if ! is_template "$template_id"; then error "VM $template_id is not a template" fi [ -z "$VMID" ] && VMID=$(get_next_vmid) [ -z "$HOSTNAME" ] && HOSTNAME="${OS_KEY}-vm-${VMID}" info "Cloning template $template_id -> VM $VMID ($HOSTNAME)" # Full clone qm clone "$template_id" "$VMID" --name "$HOSTNAME" --full 1 >/dev/null || error "Clone failed" ok "VM cloned" # Reconfigure network (remove MAC to get new one) qm set "$VMID" --delete net0 >/dev/null qm set "$VMID" --net0 "virtio,bridge=$BRIDGE" >/dev/null ok "Network reconfigured" # Resize if different from template local template_size=$(qm config "$template_id" | grep "scsi0:" | grep -oP '\d+G' | head -1) template_size=${template_size%G} if [ "$DISK_SIZE" -gt "$template_size" ]; then local diff=$((DISK_SIZE - template_size)) info "Expanding disk by ${diff}G" qm resize "$VMID" scsi0 "+${diff}G" >/dev/null 2>&1 || warn "Resize failed" fi # Start VM if requested or if post-install is needed local need_start="no" [ "$START_VM" = "yes" ] && need_start="yes" [ -n "$POST_INSTALL" ] && [ "$POST_INSTALL" != "none" ] && need_start="yes" if [ "$need_start" = "yes" ]; then info "Starting VM" qm start "$VMID" || { warn "Start failed"; } ok "VM started" fi # Execute post-install scripts if specified if [ -n "$POST_INSTALL" ] && [ "$POST_INSTALL" != "none" ]; then if run_post_install "$VMID" "$POST_INSTALL"; then ok "Post-install completed: $POST_INSTALL" else warn "Post-install had issues, but VM is deployed" fi fi ok "VM deployed successfully!" echo "" echo " VM ID: $VMID" echo " Hostname: $HOSTNAME" echo " Template: $template_name (ID: $template_id)" [ -n "$POST_INSTALL" ] && [ "$POST_INSTALL" != "none" ] && echo " Post-Install: $POST_INSTALL" echo "" } # ============================================================================ # USAGE # ============================================================================ usage() { cat <:9443 ${BOLD}NoCloud Images:${NC} NoCloud variants (e.g., debian-12-nocloud) are minimal images without cloud-init pre-installed. Use --no-cloudinit with these. EOF exit 0 } # ============================================================================ # ARGUMENT PARSING # ============================================================================ # Check if run without arguments or with --interactive if [ $# -eq 0 ] || [ "${1:-}" = "--interactive" ] || [ "${1:-}" = "-i" ]; then command -v whiptail >/dev/null 2>&1 || error "whiptail not found. Install it or use CLI mode." interactive_main_menu exit 0 fi MODE="$1" shift while [ $# -gt 0 ]; do case "$1" in --os) OS_KEY="$2" shift 2 ;; --vmid) VMID="$2" shift 2 ;; --hostname) HOSTNAME="$2" shift 2 ;; --cores) CORES="$2" shift 2 ;; --memory) MEMORY="$2" shift 2 ;; --disk) DISK_SIZE="$2" shift 2 ;; --storage) STORAGE="$2" shift 2 ;; --bridge) BRIDGE="$2" shift 2 ;; --start) START_VM="yes" shift ;; --no-cloudinit) ENABLE_CLOUDINIT="no" shift ;; --ci-user) CI_USER="$2" shift 2 ;; --ci-password) CI_PASSWORD="$2" shift 2 ;; --ci-ssh-key) CI_SSH_KEY="$2" shift 2 ;; --post-install) POST_INSTALL="$2" shift 2 ;; -h | --help) usage ;; *) error "Unknown option: $1 (use --help)" ;; esac done # ============================================================================ # CHECKS # ============================================================================ [ "$(id -u)" -ne 0 ] && error "Root privileges required" command -v qm >/dev/null 2>&1 || error "qm not found - Is Proxmox VE installed?" command -v pvesm >/dev/null 2>&1 || error "pvesm not found" # ============================================================================ # MAIN # ============================================================================ trap cleanup_vm EXIT case "$MODE" in create) create_template ;; deploy) deploy_from_template ;; list) list_templates ;; list-os) list_os_options ;; *) error "Unknown command: $MODE (use --help)" ;; esac