#!/usr/bin/env bash set -euo pipefail VMID=9000 NAME=debian-13-genericcloud IMAGE_URL=https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2 IMAGE_FILE= CACHE_DIR=/var/lib/vz/template/qcow2 STORAGE=local-lvm CLOUDINIT_STORAGE= BRIDGE=vmbr0 MEMORY=2048 CORES=2 CPU=host DISK_SIZE=32G MACHINE=q35 BIOS=ovmf SCSI_HW=virtio-scsi-pci NET_MODEL=virtio PBS_STORAGE= DRY_RUN=0 ASSUME_YES=0 INTERACTIVE=1 VMID_SET=0 NAME_SET=0 IMAGE_URL_SET=0 IMAGE_FILE_SET=0 CACHE_DIR_SET=0 STORAGE_SET=0 CLOUDINIT_STORAGE_SET=0 BRIDGE_SET=0 MEMORY_SET=0 CORES_SET=0 CPU_SET=0 DISK_SIZE_SET=0 PBS_STORAGE_SET=0 usage() { cat <<'USAGE' Create a Proxmox VM template from the Debian 13 GenericCloud image. Run this on a Proxmox node as root. Usage: scripts/create-proxmox-template.sh [options] Options: --vmid ID Template VMID. Default: 9000 --name NAME Template name. Default: debian-13-genericcloud --storage STORAGE Disk storage. Default: local-lvm --cloudinit-storage NAME Cloud-Init storage. Default: same as --storage --bridge BRIDGE Network bridge. Default: vmbr0 --memory MB Memory in MB. Default: 2048 --cores N vCPU count. Default: 2 --cpu TYPE CPU type. Default: host --disk-size SIZE Resize imported disk to this size. Default: 32G --image-url URL Debian GenericCloud qcow2 URL. --image-file PATH Use an existing local qcow2 instead of downloading. --cache-dir PATH Download cache directory. Default: /var/lib/vz/template/qcow2 --pbs-storage NAME PBS storage/datastore ID. Enables main disk backup flag when set. --dry-run Print commands without executing them. --yes Accept defaults for any unanswered prompts. --non-interactive Do not prompt; use defaults and provided flags. -h, --help Show this help. The template is configured with: - Q35 machine type - OVMF / UEFI - VirtIO SCSI - VirtIO network - QEMU guest agent - Cloud-Init drive - serial0 socket - vga serial0 for Proxmox xterm.js console support USAGE } prompt_value() { local label=$1 local default_value=$2 local value if [ "$ASSUME_YES" -eq 1 ] || [ "$INTERACTIVE" -eq 0 ] || [ ! -t 0 ]; then printf '%s\n' "$default_value" return 0 fi printf '%s [%s]: ' "$label" "$default_value" >&2 read -r value if [ -z "$value" ]; then printf '%s\n' "$default_value" else printf '%s\n' "$value" fi } prompt_optional_value() { local label=$1 local value if [ "$ASSUME_YES" -eq 1 ] || [ "$INTERACTIVE" -eq 0 ] || [ ! -t 0 ]; then printf '\n' return 0 fi printf '%s [leave empty]: ' "$label" >&2 read -r value printf '%s\n' "$value" } prompt_confirm() { local label=$1 local value if [ "$ASSUME_YES" -eq 1 ] || [ "$INTERACTIVE" -eq 0 ] || [ ! -t 0 ]; then return 0 fi printf '%s [Y/n]: ' "$label" >&2 read -r value case "$value" in ""|y|Y|yes|YES) return 0 ;; *) return 1 ;; esac } log() { printf '%s\n' "$*" } die() { printf 'ERROR: %s\n' "$*" >&2 exit 1 } run() { if [ "$DRY_RUN" -eq 1 ]; then printf '+' printf ' %q' "$@" printf '\n' return 0 fi "$@" } need_command() { command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" } download_image() { local url=$1 local dest=$2 if [ -s "$dest" ]; then log "Using cached image: $dest" return 0 fi run mkdir -p "$(dirname "$dest")" if command -v curl >/dev/null 2>&1; then run curl -fL --retry 3 --retry-delay 2 -o "$dest" "$url" elif command -v wget >/dev/null 2>&1; then run wget -O "$dest" "$url" else die "curl or wget is required to download the image" fi } parse_args() { while [ "$#" -gt 0 ]; do case "$1" in --vmid) VMID=${2:?missing value for --vmid} VMID_SET=1 shift 2 ;; --name) NAME=${2:?missing value for --name} NAME_SET=1 shift 2 ;; --storage) STORAGE=${2:?missing value for --storage} STORAGE_SET=1 shift 2 ;; --cloudinit-storage) CLOUDINIT_STORAGE=${2:?missing value for --cloudinit-storage} CLOUDINIT_STORAGE_SET=1 shift 2 ;; --bridge) BRIDGE=${2:?missing value for --bridge} BRIDGE_SET=1 shift 2 ;; --memory) MEMORY=${2:?missing value for --memory} MEMORY_SET=1 shift 2 ;; --cores) CORES=${2:?missing value for --cores} CORES_SET=1 shift 2 ;; --cpu) CPU=${2:?missing value for --cpu} CPU_SET=1 shift 2 ;; --disk-size) DISK_SIZE=${2:?missing value for --disk-size} DISK_SIZE_SET=1 shift 2 ;; --image-url) IMAGE_URL=${2:?missing value for --image-url} IMAGE_URL_SET=1 shift 2 ;; --image-file) IMAGE_FILE=${2:?missing value for --image-file} IMAGE_FILE_SET=1 shift 2 ;; --cache-dir) CACHE_DIR=${2:?missing value for --cache-dir} CACHE_DIR_SET=1 shift 2 ;; --pbs-storage) PBS_STORAGE=${2:?missing value for --pbs-storage} PBS_STORAGE_SET=1 shift 2 ;; --dry-run) DRY_RUN=1 shift ;; --yes) ASSUME_YES=1 shift ;; --non-interactive) INTERACTIVE=0 shift ;; -h|--help) usage exit 0 ;; *) die "unknown option: $1" ;; esac done } collect_interactive_config() { if [ "$VMID_SET" -eq 0 ]; then VMID=$(prompt_value "Template VMID" "$VMID") fi if [ "$NAME_SET" -eq 0 ]; then NAME=$(prompt_value "Template name" "$NAME") fi if [ "$STORAGE_SET" -eq 0 ]; then STORAGE=$(prompt_value "Disk storage" "$STORAGE") fi if [ "$CLOUDINIT_STORAGE_SET" -eq 0 ]; then CLOUDINIT_STORAGE=$(prompt_value "Cloud-Init storage" "${CLOUDINIT_STORAGE:-$STORAGE}") fi if [ "$BRIDGE_SET" -eq 0 ]; then BRIDGE=$(prompt_value "Network bridge" "$BRIDGE") fi if [ "$MEMORY_SET" -eq 0 ]; then MEMORY=$(prompt_value "Memory MB" "$MEMORY") fi if [ "$CORES_SET" -eq 0 ]; then CORES=$(prompt_value "vCPU cores" "$CORES") fi if [ "$CPU_SET" -eq 0 ]; then CPU=$(prompt_value "CPU type" "$CPU") fi if [ "$DISK_SIZE_SET" -eq 0 ]; then DISK_SIZE=$(prompt_value "Disk size after resize" "$DISK_SIZE") fi if [ "$CACHE_DIR_SET" -eq 0 ]; then CACHE_DIR=$(prompt_value "Image cache directory" "$CACHE_DIR") fi if [ "$IMAGE_FILE_SET" -eq 0 ]; then IMAGE_FILE=$(prompt_optional_value "Local qcow2 image file") fi if [ -z "$IMAGE_FILE" ] && [ "$IMAGE_URL_SET" -eq 0 ]; then IMAGE_URL=$(prompt_value "Debian GenericCloud image URL" "$IMAGE_URL") fi if [ "$PBS_STORAGE_SET" -eq 0 ]; then PBS_STORAGE=$(prompt_optional_value "PBS storage/datastore ID for backup flag") fi } print_summary() { cat </dev/null 2>&1; then die "VMID $VMID already exists; choose another --vmid or remove it manually" fi local image_path if [ -n "$IMAGE_FILE" ]; then image_path=$IMAGE_FILE [ "$DRY_RUN" -eq 1 ] || [ -f "$image_path" ] || die "image file not found: $image_path" else image_path="$CACHE_DIR/$(basename "$IMAGE_URL")" download_image "$IMAGE_URL" "$image_path" fi run qm create "$VMID" \ --name "$NAME" \ --ostype l26 \ --machine "$MACHINE" \ --bios "$BIOS" \ --memory "$MEMORY" \ --cores "$CORES" \ --cpu "$CPU" \ --agent enabled=1 \ --scsihw "$SCSI_HW" \ --net0 "$NET_MODEL,bridge=$BRIDGE" \ --serial0 socket \ --vga serial0 if [ "$BIOS" = "ovmf" ]; then run qm set "$VMID" --efidisk0 "$STORAGE:0,pre-enrolled-keys=0" fi run qm importdisk "$VMID" "$image_path" "$STORAGE" local imported_disk if [ "$DRY_RUN" -eq 1 ]; then imported_disk="$STORAGE:vm-$VMID-disk-0" log "Dry-run assumption: imported disk will be $imported_disk" else imported_disk=$(qm config "$VMID" | awk -F': ' '/^unused[0-9]+:/{print $2; exit}') [ -n "$imported_disk" ] || die "could not find imported unused disk in qm config for VMID $VMID" fi local disk_backup=0 if [ -n "$PBS_STORAGE" ]; then disk_backup=1 fi run qm set "$VMID" --scsi0 "$imported_disk,discard=on,ssd=1,backup=$disk_backup" run qm set "$VMID" --ide2 "$CLOUDINIT_STORAGE:cloudinit" run qm set "$VMID" --boot order=scsi0 run qm set "$VMID" --ipconfig0 ip=dhcp if [ -n "$DISK_SIZE" ]; then run qm resize "$VMID" scsi0 "$DISK_SIZE" fi run qm template "$VMID" log "Created Proxmox template $NAME ($VMID)" } main "$@"