From 0580a9530a9c1602bc1dee5bbfae4c339ed19c70 Mon Sep 17 00:00:00 2001 From: ergosteur <1992147+ergosteur@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:45:26 +0000 Subject: [PATCH] Replace Debian-only template builder with hardened 6-in-1 builder build-cloudinit-template.sh supersedes make-debian-cloudinit-template.sh, covering Alpine, Ubuntu, Debian, Fedora, AlmaLinux and Arch, with UEFI, vendor snippets and an optional --docker runtime. Carry forward the hardening the Debian script had gained, which the new one was missing: set -euo pipefail, root/binary/numeric-VMID checks, a VMID collision check, storage validation, an ERR trap naming the cleanup command, image caching under /var/tmp, and env-overridable STORAGE, BRIDGE, MEMORY, CORES, DISK_SIZE, SSH_KEY_FILE and CI_PASSWORD. Quote the openssl passwd substitution feeding --cipassword. A SHA-512 crypt hash contains $ and /, so unquoted it was subject to word splitting. Derive the snippet directory from SNIPPET_STORAGE via pvesm path instead of hardcoding /var/lib/vz/snippets, which silently disagreed with the storage whenever SNIPPET_STORAGE was changed. Falls back to parsing storage.cfg. Resize the imported disk with qm disk resize rather than resizing the image file. Growing the file in place would mutate the shared cache and defeat the size comparison wget -N uses to decide whether to re-download. Check the Snippets content type on the target storage up front. It is off by default, and without this the run failed much later at qm set --cicustom. Fall back to timedatectl when /etc/timezone is absent, as it is outside Debian. Co-Authored-By: Claude Opus 5 --- build-cloudinit-template.sh | 355 ++++++++++++++++++++++++++++++ make-debian-cloudinit-template.sh | 117 ---------- 2 files changed, 355 insertions(+), 117 deletions(-) create mode 100755 build-cloudinit-template.sh delete mode 100755 make-debian-cloudinit-template.sh diff --git a/build-cloudinit-template.sh b/build-cloudinit-template.sh new file mode 100755 index 0000000..d67db97 --- /dev/null +++ b/build-cloudinit-template.sh @@ -0,0 +1,355 @@ +#!/bin/bash +# 6-in-1 Proxmox cloud-init template builder. +# +# Usage: +# ./build-cloudinit-template.sh [--docker] +# +# Optional overrides (env vars): +# STORAGE Proxmox storage pool for disks (default: local-btrfs) +# SNIPPET_STORAGE Storage holding cloud-init snippets, must have the +# "Snippets" content type enabled (default: local) +# BRIDGE Network bridge (default: vmbr0) +# MEMORY RAM in MB (default: 1024) +# CORES CPU cores (default: 1) +# DISK_SIZE Disk size, e.g. 32G (default: per-distro) +# SSH_KEY_FILE Public keys to inject (default: /root/.ssh/authorized_keys) +# CI_PASSWORD Cloud-init password (default: prompted securely) +# CACHE_DIR Cloud image download cache (default: /var/tmp/pve-cloudinit-images) +# +# Example: +# STORAGE=local-lvm MEMORY=2048 ./build-cloudinit-template.sh 8000 arch --docker + +set -euo pipefail + +# Print the header comment block: everything from line 2 up to the first blank line. +usage() { + sed -n '2,/^$/p' "$0" | sed -e 's/^#//' -e 's/^ //' +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +# --- Distribution version pins (update these over time) --- +ALPINE_MAJOR="v3.23" +ALPINE_PATCH="3.23.3" +UBUNTU_CODENAME="noble" +UBUNTU_VER="2404" +DEBIAN_CODENAME="trixie" +DEBIAN_VER="13" +FEDORA_VER="41" +FEDORA_RELEASE="1.4" # minor release can change throughout the cycle +ALMA_VER="9" +# Arch is rolling, so it pulls the "latest" build natively. + +# --- Overridable configuration --- +STORAGE=${STORAGE:-local-btrfs} +SNIPPET_STORAGE=${SNIPPET_STORAGE:-local} +BRIDGE=${BRIDGE:-vmbr0} +MEMORY=${MEMORY:-1024} +CORES=${CORES:-1} +SSH_KEY_FILE=${SSH_KEY_FILE:-/root/.ssh/authorized_keys} +CACHE_DIR=${CACHE_DIR:-/var/tmp/pve-cloudinit-images} + +# --- 1. Validation and input parsing --- +SUPPORTED_DISTROS="alpine|ubuntu|debian|fedora|almalinux|arch" + +if [[ $EUID -ne 0 ]]; then + printf "Please run as root:\nsudo %s\n" "$0" >&2 + exit 1 +fi + +if [[ $# -lt 2 || $# -gt 3 ]]; then + usage >&2 + exit 1 +fi + +for bin in wget qm pvesm openssl qemu-img; do + if ! command -v "$bin" >/dev/null; then + echo "Required command '$bin' not found. Is this running on a Proxmox host?" >&2 + exit 1 + fi +done + +VMID=$1 +if [[ ! "$VMID" =~ ^[0-9]+$ ]]; then + echo "Error: a numeric VM ID is required." >&2 + exit 1 +fi + +DISTRO_CHOICE=$(echo "$2" | tr '[:upper:]' '[:lower:]') +if [[ ! "$DISTRO_CHOICE" =~ ^($SUPPORTED_DISTROS)$ ]]; then + echo "Error: unsupported OS distribution '$2'." >&2 + echo "Please choose one of: ${SUPPORTED_DISTROS//|/, }" >&2 + exit 1 +fi + +CI_USER="$DISTRO_CHOICE" +INSTALL_DOCKER=false +if [[ "${3:-}" == "--docker" ]]; then + INSTALL_DOCKER=true +elif [[ -n "${3:-}" ]]; then + echo "Error: unknown option '$3' (expected --docker)." >&2 + exit 1 +fi + +if qm status "$VMID" &>/dev/null; then + echo "Error: VM ID $VMID already exists." >&2 + exit 1 +fi + +if ! pvesm status -storage "$STORAGE" &>/dev/null; then + echo "Error: storage pool '$STORAGE' not found (check STORAGE env var)." >&2 + exit 1 +fi + +if ! pvesm status -storage "$SNIPPET_STORAGE" &>/dev/null; then + echo "Error: snippet storage '$SNIPPET_STORAGE' not found (check SNIPPET_STORAGE env var)." >&2 + exit 1 +fi + +# Snippets are a per-storage content type and are off by default — a missing one +# produces a confusing failure much later, so check it up front. +if ! pvesm status -storage "$SNIPPET_STORAGE" -content snippets &>/dev/null; then + echo "Error: storage '$SNIPPET_STORAGE' does not have the 'Snippets' content type enabled." >&2 + echo "Enable it under Datacenter > Storage > $SNIPPET_STORAGE > Content, or set SNIPPET_STORAGE." >&2 + exit 1 +fi + +# --- 2. Set distribution-specific variables --- +case $DISTRO_CHOICE in + alpine) + VM_NAME="alpine-${ALPINE_MAJOR//v/}-cloudinit" + IMAGE_URL="https://dl-cdn.alpinelinux.org/alpine/${ALPINE_MAJOR}/releases/cloud/nocloud_alpine-${ALPINE_PATCH}-x86_64-uefi-cloudinit-r0.qcow2" + DEFAULT_DISK_SIZE="8G" + PKG_MGR="apk" + ;; + ubuntu) + VM_NAME="ubuntu-${UBUNTU_VER}-cloudinit" + IMAGE_URL="https://cloud-images.ubuntu.com/${UBUNTU_CODENAME}/current/${UBUNTU_CODENAME}-server-cloudimg-amd64.img" + DEFAULT_DISK_SIZE="32G" + PKG_MGR="apt" + ;; + debian) + VM_NAME="debian-${DEBIAN_VER}-cloudinit" + IMAGE_URL="https://cloud.debian.org/images/cloud/${DEBIAN_CODENAME}/latest/debian-${DEBIAN_VER}-genericcloud-amd64.qcow2" + DEFAULT_DISK_SIZE="32G" + PKG_MGR="apt" + ;; + fedora) + VM_NAME="fedora-${FEDORA_VER}-cloudinit" + IMAGE_URL="https://download.fedoraproject.org/pub/fedora/linux/releases/${FEDORA_VER}/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-${FEDORA_VER}-${FEDORA_RELEASE}.x86_64.qcow2" + DEFAULT_DISK_SIZE="32G" + PKG_MGR="dnf" + ;; + almalinux) + VM_NAME="almalinux-${ALMA_VER}-cloudinit" + IMAGE_URL="https://repo.almalinux.org/almalinux/${ALMA_VER}/cloud/x86_64/images/AlmaLinux-${ALMA_VER}-GenericCloud-latest.x86_64.qcow2" + DEFAULT_DISK_SIZE="32G" + PKG_MGR="dnf" + ;; + arch) + VM_NAME="archlinux-cloudinit" + IMAGE_URL="https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2" + DEFAULT_DISK_SIZE="32G" + PKG_MGR="pacman" + ;; +esac + +DISK_SIZE=${DISK_SIZE:-$DEFAULT_DISK_SIZE} + +if [[ "$INSTALL_DOCKER" == true ]]; then + VM_NAME="${VM_NAME}-docker" +fi + +# Secure password prompt +CLEARTEXT_PASSWORD=${CI_PASSWORD:-} +if [[ -z "$CLEARTEXT_PASSWORD" ]]; then + while true; do + read -rs -p "Enter cloud-init password for user '$CI_USER': " PASSWORD_INPUT + echo + read -rs -p "Confirm password: " PASSWORD_CONFIRM + echo + if [[ "$PASSWORD_INPUT" != "$PASSWORD_CONFIRM" ]]; then + echo "Passwords do not match. Please try again." + elif [[ -z "$PASSWORD_INPUT" ]]; then + echo "Password cannot be empty. Please try again." + else + CLEARTEXT_PASSWORD="$PASSWORD_INPUT" + break + fi + done +fi + +# From here on a failure can leave a half-built VM behind, so say how to clean up. +trap 'echo "Failed while building VM $VMID. It may be left in a partial state — check with: qm config $VMID (clean up with: qm destroy $VMID)" >&2' ERR + +# --- 3. Generate cloud-init vendor snippet --- +SNIPPET_NAME="${DISTRO_CHOICE}-vendor.yaml" + +# Resolve the snippet directory from the storage rather than assuming /var/lib/vz, +# so SNIPPET_STORAGE and the on-disk path can never disagree. Fall back to parsing +# storage.cfg if pvesm won't resolve a path for a volume that does not exist yet. +SNIPPET_FILE=$(pvesm path "$SNIPPET_STORAGE:snippets/$SNIPPET_NAME" 2>/dev/null || true) +if [[ -z "$SNIPPET_FILE" ]]; then + STORAGE_PATH=$(awk -v s="$SNIPPET_STORAGE" ' + $1 == "dir:" && $2 == s { found = 1; next } + /^[a-z]+:/ { found = 0 } + found && $1 == "path" { print $2; exit } + ' /etc/pve/storage.cfg 2>/dev/null || true) + if [[ -n "$STORAGE_PATH" ]]; then + SNIPPET_FILE="$STORAGE_PATH/snippets/$SNIPPET_NAME" + fi +fi +if [[ -z "$SNIPPET_FILE" ]]; then + echo "Error: could not resolve the snippets path for storage '$SNIPPET_STORAGE'." >&2 + echo "Is it a directory-backed storage? Override with SNIPPET_STORAGE." >&2 + exit 1 +fi + +echo "Generating cloud-init vendor snippet for $DISTRO_CHOICE..." +mkdir -p "$(dirname "$SNIPPET_FILE")" + +{ + echo "#cloud-config" + echo "runcmd:" +} > "$SNIPPET_FILE" + +case $PKG_MGR in + apt) + cat >> "$SNIPPET_FILE" <> "$SNIPPET_FILE" <> "$SNIPPET_FILE" <> "$SNIPPET_FILE" <> "$SNIPPET_FILE" <> "$SNIPPET_FILE" <> "$SNIPPET_FILE" <> "$SNIPPET_FILE" + +# /etc/timezone is Debian-specific; fall back to timedatectl elsewhere. +if [[ -r /etc/timezone ]]; then + echo "timezone: $(cat /etc/timezone)" >> "$SNIPPET_FILE" +elif command -v timedatectl >/dev/null; then + echo "timezone: $(timedatectl show -p Timezone --value)" >> "$SNIPPET_FILE" +fi + +# --- 4. Download the cloud image (cached) --- +mkdir -p "$CACHE_DIR" +IMAGE_FILE="$CACHE_DIR/$(basename "$IMAGE_URL")" + +echo "Fetching cloud image (cached under $CACHE_DIR, re-used if unchanged)..." +wget -N -P "$CACHE_DIR" "$IMAGE_URL" + +if [[ ! -f "$IMAGE_FILE" ]]; then + echo "Error: expected image at $IMAGE_FILE after download." >&2 + exit 1 +fi + +# --- 5. Create the base VM --- +echo "Creating the base VM ($VMID)..." +qm create "$VMID" --name "$VM_NAME" --ostype l26 \ + --memory "$MEMORY" \ + --agent 1 \ + --bios ovmf --machine q35 --efidisk0 "$STORAGE:0,pre-enrolled-keys=0" \ + --cpu host --sockets 1 --cores "$CORES" \ + --vga serial0 --serial0 socket \ + --net0 "virtio,bridge=$BRIDGE" + +# --- 6. Import disk and configure hardware --- +echo "Importing disk and configuring hardware..." +# import-from handles any storage type (BTRFS, ZFS, LVM) and attaches as scsi0. +qm set "$VMID" --scsihw virtio-scsi-pci --scsi0 "$STORAGE:0,import-from=$IMAGE_FILE,discard=on" + +# Grow the imported disk in place, so the cached image stays pristine for reuse. +echo "Resizing disk to $DISK_SIZE..." +qm disk resize "$VMID" scsi0 "$DISK_SIZE" + +qm set "$VMID" --boot order=scsi0 +qm set "$VMID" --ide2 "$STORAGE:cloudinit" + +# --- 7. Configure cloud-init settings --- +echo "Applying cloud-init settings..." + +VM_TAGS="$DISTRO_CHOICE-template,cloudinit,linux,template" +if [[ "$INSTALL_DOCKER" == true ]]; then + VM_TAGS="$VM_TAGS,docker" +fi + +# Quoted: the SHA-512 crypt hash contains $ and / and must not be word-split. +CIPASSWORD=$(openssl passwd -6 "$CLEARTEXT_PASSWORD") + +qm set "$VMID" --cicustom "vendor=$SNIPPET_STORAGE:snippets/$SNIPPET_NAME" +qm set "$VMID" --tags "$VM_TAGS" +qm set "$VMID" --ciuser "$CI_USER" +qm set "$VMID" --cipassword "$CIPASSWORD" +qm set "$VMID" --ipconfig0 ip=dhcp,ip6=dhcp + +# Only inject SSH keys if the file actually exists. +if [[ -f "$SSH_KEY_FILE" ]]; then + qm set "$VMID" --sshkeys "$SSH_KEY_FILE" +else + echo "Warning: SSH key file not found at $SSH_KEY_FILE. Skipping injection." >&2 +fi + +# --- 8. Finalize template --- +qm template "$VMID" + +trap - ERR + +echo "Done! $DISTRO_CHOICE template $VMID ($VM_NAME) is ready for cloning." +echo "User '$CI_USER', disk $DISK_SIZE on storage '$STORAGE'. Cached image kept at $IMAGE_FILE." diff --git a/make-debian-cloudinit-template.sh b/make-debian-cloudinit-template.sh deleted file mode 100755 index 3765918..0000000 --- a/make-debian-cloudinit-template.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/bin/bash -# Build a Proxmox cloud-init template VM from a Debian cloud image. -# -# Usage: -# ./make-debian-cloudinit-template.sh [CODENAME] -# -# CODENAME is a Debian release codename (default: trixie). Known: -# bullseye (11), bookworm (12), trixie (13), forky (14) -# -# Optional overrides (env vars): -# IMAGE_URL Full image URL, overrides CODENAME lookup entirely -# STORAGE Proxmox storage pool for disks (default: local-zfs) -# BRIDGE Network bridge (default: vmbr0) -# MEMORY RAM in MB (default: 2048) -# CORES CPU cores (default: 4) -# NAME Template name in Proxmox (default: debian--cloudinit-) -# -# Example: -# STORAGE=local-lvm BRIDGE=vmbr1 ./make-debian-cloudinit-template.sh 9000 bookworm - -set -euo pipefail - -usage() { - grep '^#' "$0" | sed -e 's/^#//' -e 's/^ //' | head -n 20 -} - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage - exit 0 -fi - -if [[ $EUID -ne 0 ]]; then - printf "Please run as root:\nsudo %s\n" "$0" - exit 1 -fi - -for bin in wget qm pvesm; do - if ! command -v "$bin" >/dev/null; then - echo "Required command '$bin' not found. Is this running on a Proxmox host?" >&2 - exit 1 - fi -done - -ID=${1:-} -if [[ -z "$ID" || ! "$ID" =~ ^[0-9]+$ ]]; then - echo "Error: a numeric VM ID is required." >&2 - usage - exit 1 -fi - -if qm status "$ID" &>/dev/null; then - echo "Error: VM ID $ID already exists." >&2 - exit 1 -fi - -declare -A DEBIAN_VERSIONS=( - [bullseye]=11 - [bookworm]=12 - [trixie]=13 - [forky]=14 -) - -CODENAME=${2:-trixie} -STORAGE=${STORAGE:-local-zfs} -BRIDGE=${BRIDGE:-vmbr0} -MEMORY=${MEMORY:-2048} -CORES=${CORES:-4} -NAME=${NAME:-"debian-$CODENAME-cloudinit-$ID"} - -if [[ -n "${IMAGE_URL:-}" ]]; then - URL="$IMAGE_URL" -else - VERSION=${DEBIAN_VERSIONS[$CODENAME]:-} - if [[ -z "$VERSION" ]]; then - echo "Error: unknown Debian codename '$CODENAME'. Known: ${!DEBIAN_VERSIONS[*]}" >&2 - exit 1 - fi - URL="https://cloud.debian.org/images/cloud/$CODENAME/latest/debian-$VERSION-genericcloud-amd64.qcow2" -fi - -if ! pvesm status -storage "$STORAGE" &>/dev/null; then - echo "Error: storage pool '$STORAGE' not found (check STORAGE env var)." >&2 - exit 1 -fi - -trap 'echo "Failed while building VM $ID. It may be left in a partial state — check with: qm config $ID (clean up with: qm destroy $ID)" >&2' ERR - -CACHE_DIR=/var/tmp/pve-cloudinit-images -mkdir -p "$CACHE_DIR" -IMAGE_FILE="$CACHE_DIR/$(basename "$URL")" - -echo "Fetching cloud image (cached under $CACHE_DIR, re-used if unchanged)..." -wget -N -P "$CACHE_DIR" "$URL" - -qm create "$ID" --net0 "virtio,bridge=$BRIDGE" --scsihw virtio-scsi-pci --memory "$MEMORY" --cores "$CORES" --machine q35 - -if command -v virt-customize >/dev/null; then - echo "Installing qemu-guest-agent into guest image..." - virt-customize -a "$IMAGE_FILE" --install qemu-guest-agent - qm set "$ID" --agent 1 -else - echo "virt-customize not found (install libguestfs-tools for this); skipping in-guest qemu-guest-agent install." -fi - -qm importdisk "$ID" "$IMAGE_FILE" "$STORAGE" -DISK=$(qm config "$ID" | awk -F': ' '/^unused0:/ {print $2}') -if [[ -z "$DISK" ]]; then - echo "Error: could not find imported disk on VM $ID." >&2 - exit 1 -fi - -qm set "$ID" --scsi0 "$DISK" -qm set "$ID" --scsi1 "$STORAGE:cloudinit" -qm set "$ID" --name "$NAME" --boot order=scsi0 --serial0 socket --vga serial0 -qm template "$ID" - -echo "Template $ID ($NAME) created from $URL using storage '$STORAGE'."