Add universal VM template manager script

Introduces vm-manager.sh, a comprehensive Bash script for Proxmox VE to create, deploy, and list VM templates with optional cloud-init and post-install automation. Supports multiple Linux distributions, customizable resources, and post-install options for Docker, Podman, and Portainer.
This commit is contained in:
CanbiZ 2025-12-03 18:04:12 +01:00
parent 62ac9e9470
commit f77c19be52

637
vm/vm-manager.sh Normal file
View File

@ -0,0 +1,637 @@
#!/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
# 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
}
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://<vm-ip>: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 <os-key>\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 <os-key>"
[ -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 <<EOF
${BOLD}Usage:${NC} $0 <COMMAND> [OPTIONS]
${BOLD}Commands:${NC}
create Create a new VM template
deploy Deploy VM from template
list List all templates
list-os Show available OS images
${BOLD}Options:${NC}
--os KEY OS from catalog (e.g. debian-12, ubuntu-24.04)
Use -nocloud suffix for images without cloud-init
--vmid ID VM/Template ID (auto-assigned if not specified)
--hostname NAME Hostname for deployed VM
--cores NUM CPU cores (default: $CORES)
--memory MB RAM in MB (default: $MEMORY)
--disk GB Disk size in GB (default: $DISK_SIZE)
--storage NAME Storage pool
--bridge NAME Network bridge (default: $BRIDGE)
--start Start VM after deployment
--no-cloudinit Disable cloud-init drive (default: enabled)
${BOLD}Cloud-Init:${NC}
--ci-user USER Cloud-Init username
--ci-password PASS Cloud-Init password
--ci-ssh-key KEY SSH public key
${BOLD}Post-Install:${NC}
--post-install PKG Install software after first boot
Options: docker, podman, portainer
Note: Requires cloud-init + SSH access
${BOLD}Examples:${NC}
${BOLD}# Create templates${NC}
$0 create --os debian-12
$0 create --os debian-12-nocloud --no-cloudinit
$0 create --os ubuntu-24.04 --cores 4 --memory 4096
$0 create --os debian-12 --ci-user admin --ci-ssh-key "ssh-rsa AAA..."
${BOLD}# Deploy VMs${NC}
$0 deploy --os debian-12 --hostname webserver --start
$0 deploy --os ubuntu-24.04 --hostname docker-host --post-install docker
$0 deploy --os debian-12 --hostname portainer --post-install portainer --start
$0 deploy --os rocky-9 --hostname podman-host --post-install podman --disk 100
${BOLD}# List resources${NC}
$0 list
$0 list-os
${BOLD}Post-Install Details:${NC}
docker - Installs Docker CE via get.docker.com
podman - Installs Podman via system package manager
portainer - Installs Docker + Portainer CE container
Access at https://<vm-ip>: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
# ============================================================================
[ $# -eq 0 ] && usage
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