#!/usr/bin/env bash
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
die() { echo -e "${RED}Error:${NC} $*" >&2; exit 1; }
info() { echo -e "${GREEN}==>${NC} $*"; }
warn() { echo -e "${YELLOW}==>${NC} $*"; }
need_cmd() { command -v "$1" >/dev/null 2>&1 || die "Required command '$1' not found."; }
# Pre-flight
need_cmd qm
need_cmd wget
need_cmd qemu-img
need_cmd pvesm
need_cmd zfs
pveversion -v >/dev/null 2>&1 || die "Run on a Proxmox VE host."
# Helper: numbered picker
pick_from_list() {
local prompt="$1"; shift
local -a items=("$@")
local count="${#items[@]}"
[[ $count -gt 0 ]] || die "No items to pick."
if [[ $count -eq 1 ]]; then
echo "${items[0]}"
return 0
fi
echo "$prompt"
local i=1
for it in "${items[@]}"; do
echo " [$i] $it"
((i++))
done
local choice
while true; do
read -rp "Select [1-$count]: " choice
[[ "$choice" =~ ^[0-9]+$ ]] || { echo "Enter a number."; continue; }
(( choice>=1 && choice<=count )) || { echo "Out of range."; continue; }
echo "${items[$((choice-1))]}"
return 0
done
}
confirm_or_exit() {
local prompt="$1"
local ans
read -rp "$prompt [y/N]: " ans
[[ "$ans" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 1; }
}
# Gather storages
mapfile -t ALL_STORAGES < <(pvesm status | awk 'NR>1 {print $1}' | sed '/^$/d')
[[ ${#ALL_STORAGES[@]} -gt 0 ]] || die "No storages found via pvesm status."
# Parse storage.cfg
STCFG="/etc/pve/storage.cfg"
[[ -r "$STCFG" ]] || die "Cannot read $STCFG"
declare -A ST_TYPE ST_CONTENT ST_MOUNT ST_POOL
current=""
while IFS= read -r line; do
[[ -z "$line" ]] && continue
if [[ "$line" =~ ^(dir|zfspool|lvmthin|rbd|zfs|nfs|cifs|pbs|lvm):[[:space:]]+(.+)$ ]]; then
current="${BASH_REMATCH[2]}"
ST_TYPE["$current"]="${BASH_REMATCH[1]}"
ST_CONTENT["$current"]=""
ST_MOUNT["$current"]=""
ST_POOL["$current"]=""
continue
fi
[[ -z "$current" ]] && continue
key=$(awk '{print $1}' <<<"$line")
val=$(awk '{$1=""; sub(/^ /,""); print}' <<<"$line")
case "$key" in
content) ST_CONTENT["$current"]="$val" ;;
mountpoint) ST_MOUNT["$current"]="$val" ;;
pool) ST_POOL["$current"]="$val" ;;
esac
done < "$STCFG"
# Filter storages
declare -a IMG_STORAGES=()
for s in "${ALL_STORAGES[@]}"; do
if grep -qw images <<<"${ST_CONTENT[$s]:-}"; then
IMG_STORAGES+=("$s")
fi
done
[[ ${#IMG_STORAGES[@]} -gt 0 ]] || die "No storages with 'images' content found."
declare -a SNIPPET_STORAGES=()
for s in "${ALL_STORAGES[@]}"; do
if grep -qw snippets <<<"${ST_CONTENT[$s]:-}"; then
SNIPPET_STORAGES+=("$s")
fi
done
[[ ${#SNIPPET_STORAGES[@]} -gt 0 ]] || die "No storages with 'snippets' content found. Enable snippets on a storage."
# Prompts
read -rp "Template VMID (e.g. 9000): " VMID
[[ "$VMID" =~ ^[0-9]+$ ]] || die "VMID must be numeric."
read -rp "Template name (default: ubuntu-24.04-cloud): " VMNAME
VMNAME=${VMNAME:-ubuntu-24.04-cloud}
STORAGE="$(pick_from_list "Select storage for VM disks (needs 'images'):" "${IMG_STORAGES[@]}")"
info "Selected image storage: $STORAGE"
if [[ ${#SNIPPET_STORAGES[@]} -eq 1 ]]; then
SNIPPETS_ID="${SNIPPET_STORAGES[0]}"
info "Snippets storage auto-selected: $SNIPPETS_ID"
else
SNIPPETS_ID="$(pick_from_list "Select storage for snippets:" "${SNIPPET_STORAGES[@]}")"
info "Selected snippets storage: $SNIPPETS_ID"
fi
SNIPPETS_BASE="/mnt/pve/${SNIPPETS_ID}/snippets"
[[ -d "$SNIPPETS_BASE" ]] || die "Snippets path '$SNIPPETS_BASE' not found or not mounted."
read -rp "Bridge name (default vmbr0): " BRIDGE
BRIDGE=${BRIDGE:-vmbr0}
read -rp "Default cloud user (e.g. dev): " CIUSER
[[ -n "$CIUSER" ]] || die "User is required."
read -srp "Password for user '$CIUSER': " CIPASS; echo
[[ -n "$CIPASS" ]] || die "Password is required."
read -rp "Add SSH public key as well? [y/N]: " ADDKEY
ADDKEY=${ADDKEY:-N}
SSHKEY_PATH=""
if [[ "$ADDKEY" =~ ^[Yy]$ ]]; then
read -rp "Path to public key (e.g. ~/.ssh/id_rsa.pub): " SSHKEY_PATH
SSHKEY_PATH="${SSHKEY_PATH/#\~/$HOME}"
[[ -f "$SSHKEY_PATH" ]] || die "Public key not found at: $SSHKEY_PATH"
fi
read -rp "vCPUs (default 2): " CORES; CORES=${CORES:-2}
read -rp "RAM MB (default 2048): " MEM; MEM=${MEM:-2048}
read -rp "Disk size (default 20G): " DISKSIZE; DISKSIZE=${DISKSIZE:-20G}
# Networking choice
read -rp "Use DHCP? [Y/n]: " USE_DHCP
USE_DHCP=${USE_DHCP:-Y}
DNS_LIST=""
if [[ "$USE_DHCP" =~ ^[Yy]$ ]]; then
IPCONFIG0="ip=dhcp"
else
read -rp "Static IPv4 (CIDR), e.g. 192.168.1.50/24: " IPADDR
read -rp "Gateway IPv4, e.g. 192.168.1.1: " GW
read -rp "DNS server(s) (space/comma-separated), e.g. 1.1.1.1 8.8.8.8: " DNS_LIST
[[ "$IPADDR" =~ /[0-9]+$ ]] || die "Invalid CIDR for IP."
[[ -n "$GW" ]] || die "Gateway required."
IPCONFIG0="ip=${IPADDR},gw=${GW}"
fi
# Print summary and confirm
echo
echo "Summary:"
echo " Template VMID: $VMID"
echo " Name: $VMNAME"
echo " Image storage: $STORAGE"
echo " Snippets storage: $SNIPPETS_ID"
echo " Bridge: $BRIDGE"
echo " Cloud user: $CIUSER"
echo " SSH password: (hidden)"
if [[ "$ADDKEY" =~ ^[Yy]$ ]]; then
echo " SSH public key: $SSHKEY_PATH"
else
echo " SSH public key: none"
fi
echo " vCPUs: $CORES"
echo " RAM: ${MEM}MB"
echo " Disk size: $DISKSIZE"
echo " Network: $IPCONFIG0"
[[ -n "$DNS_LIST" ]] && echo " DNS: $DNS_LIST"
confirm_or_exit "Proceed with template creation?"
# Image download dir: ../images
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
IMAGES_DIR="${SCRIPT_DIR}/../images"
mkdir -p "$IMAGES_DIR" || die "Cannot create $IMAGES_DIR"
IMG_NAME="ubuntu-24.04-server-cloudimg-amd64.img"
IMG_URL="https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
IMG_PATH="${IMAGES_DIR}/${IMG_NAME}"
if [[ ! -f "$IMG_PATH" ]]; then
info "Downloading Ubuntu 24.04 image -> $IMG_PATH"
wget -O "$IMG_PATH" "$IMG_URL" || die "Download failed."
else
info "Using existing image at $IMG_PATH"
fi
# Sanity check
qemu-img info --output=json "$IMG_PATH" >/dev/null 2>&1 || warn "qemu-img could not parse $IMG_PATH; continuing."
# Vendor-data snippet (enable password auth)
VENDOR_SNIPPET="${SNIPPETS_BASE}/ci-vendor-sshpass.yaml"
info "Writing vendor-data snippet: $VENDOR_SNIPPET"
cat >"$VENDOR_SNIPPET" <<'EOF'
#cloud-config
ssh_pwauth: true
EOF
chmod 0644 "$VENDOR_SNIPPET"
# Create/reuse VM
if qm status "$VMID" >/dev/null 2>&1; then
warn "VMID $VMID already exists. Will reconfigure it as template."
else
info "Creating VM $VMID ($VMNAME)"
qm create "$VMID" --name "$VMNAME" --memory "$MEM" --cores "$CORES" --sockets 1 --cpu host --net0 "virtio,bridge=${BRIDGE}" \
|| die "qm create failed."
fi
# Import disk if missing
if ! pvesm list "$STORAGE" | grep -q "vm-${VMID}-disk-0"; then
info "Importing disk into $STORAGE"
qm importdisk "$VMID" "$IMG_PATH" "$STORAGE" || die "qm importdisk failed."
else
info "Disk vm-${VMID}-disk-0 already exists on $STORAGE"
fi
# Attach disk and config
info "Configuring hardware"
qm set "$VMID" --scsihw virtio-scsi-pci --scsi0 "${STORAGE}:vm-${VMID}-disk-0" || die "attach scsi0 failed."
qm set "$VMID" --scsi0 "${STORAGE}:vm-${VMID}-disk-0,ssd=1,discard=on" || true
qm set "$VMID" --boot order=scsi0 || true
qm set "$VMID" --machine q35 || true
qm set "$VMID" --serial0 socket --vga serial0 || true
qm set "$VMID" --agent enabled=1,fstrim_cloned_disks=1 || true
# Resize
info "Resizing disk to ${DISKSIZE}"
qm resize "$VMID" scsi0 "$DISKSIZE" || warn "qm resize failed; continuing."
# Refresh cloud-init drive
info "Refreshing cloud-init drive"
if qm config "$VMID" | grep -q '^ide2: .*cloudinit'; then
POOL="${ST_POOL[$STORAGE]}"
qm stop "$VMID" >/dev/null 2>&1 || true
if [[ -n "${POOL:-}" ]] && zfs list "${POOL}/vm-${VMID}-cloudinit" >/dev/null 2>&1; then
zfs destroy "${POOL}/vm-${VMID}-cloudinit" || warn "Could not destroy ${POOL}/vm-${VMID}-cloudinit; will reuse."
fi
fi
qm set "$VMID" --ide2 "${STORAGE}:cloudinit" || die "Adding cloud-init drive failed."
# Cloud-init settings
info "Applying cloud-init defaults"
qm set "$VMID" --ciuser "$CIUSER" --cipassword "$CIPASS" --ipconfig0 "$IPCONFIG0" || die "Setting ciuser/cipassword/ip failed."
if [[ "$ADDKEY" =~ ^[Yy]$ ]]; then
info "Adding SSH public key from $SSHKEY_PATH"
qm set "$VMID" --sshkeys "$SSHKEY_PATH" || warn "Could not set sshkeys."
fi
# If static and DNS specified, add resolv.conf at boot via vendor data (optional)
if [[ ! "$USE_DHCP" =~ ^[Yy]$ ]] && [[ -n "$DNS_LIST" ]]; then
VENDOR_SNIPPET="${SNIPPETS_BASE}/ci-vendor-sshpass-dns.yaml"
info "Writing vendor-data with ssh_pwauth + resolv.conf"
cat >"$VENDOR_SNIPPET" <<EOF
#cloud-config
ssh_pwauth: true
write_files:
- path: /etc/resolv.conf
permissions: '0644'
owner: root:root
content: |
$(for d in ${DNS_LIST//,/ }; do echo " nameserver $d"; done)
EOF
chmod 0644 "$VENDOR_SNIPPET"
fi
# Attach vendor-data snippet
info "Attaching vendor-data snippet"
qm set "$VMID" --cicustom "vendor=${SNIPPETS_ID}:snippets/$(basename "$VENDOR_SNIPPET")" \
|| die "Setting cicustom vendor failed."
# Best-effort verification
info "Verifying cloud-init data"
qm cloudinit dump "$VMID" user || warn "Could not dump user-data."
qm cloudinit dump "$VMID" meta || warn "Could not dump meta-data."
# Convert to template
info "Converting to template"
qm template "$VMID" || die "Failed to convert to template."
echo
info "Template created successfully."
echo " Template VMID: $VMID"
echo " Name: $VMNAME"
echo " Image storage: $STORAGE"
echo " Snippets storage: $SNIPPETS_ID"
echo " Cloud user: $CIUSER"
echo " SSH password auth: enabled (vendor-data)"
echo " Image file: $IMG_PATH"
echo " Network: $IPCONFIG0"
[[ -n "$DNS_LIST" ]] && echo " DNS: $DNS_LIST"
echo
echo "Clone example:"
echo " qm clone $VMID <NEW_VMID> --name \"<NEW_NAME>\" --full 1"
echo " qm set <NEW_VMID> --ipconfig0 \"$IPCONFIG0\""
echo " qm start <NEW_VMID>"
echo
echo -e "${GREEN}SSH:${NC} ssh ${CIUSER}@<vm-ip> (password you set)"