ProxmoxVED/vm/vm-manager.sh
CanbiZ f77c19be52 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.
2025-12-03 18:04:12 +01:00

638 lines
19 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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