diff --git a/vm/vm-manager.sh b/vm/vm-manager.sh new file mode 100644 index 000000000..7043a2116 --- /dev/null +++ b/vm/vm-manager.sh @@ -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://: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 < [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://: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