Files
ergo-proxmox-scripts/build-cloudinit-template.sh
T
ergosteurandClaude Opus 5 8b2680434d Track the Alpine cloud image prefix as a pin
Alpine renamed the prefix from nocloud_ to generic_ in 3.24, which was
hardcoded in the URL and so had to be edited by hand alongside the version
bump. As a pin it sits with the other values a release bump touches, and
update-image-pins.sh can read the real prefix off the mirror rather than
assuming one.

No change to the URL produced: still generic_ for the pinned 3.24.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:04:41 +00:00

359 lines
12 KiB
Bash
Executable File

#!/bin/bash
# 6-in-1 Proxmox cloud-init template builder.
#
# Usage:
# ./build-cloudinit-template.sh <VMID> <alpine|ubuntu|debian|fedora|almalinux|arch> [--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.24"
ALPINE_PATCH="3.24.1"
# Upstream renamed the cloud image prefix from nocloud_ to generic_ in 3.24, so
# this tracks the branch: generic_ for 3.24+, nocloud_ for 3.23 and older.
ALPINE_PREFIX="generic"
UBUNTU_CODENAME="resolute" # 26.04 LTS
UBUNTU_VER="2604"
DEBIAN_CODENAME="trixie"
DEBIAN_VER="13"
FEDORA_VER="44"
FEDORA_RELEASE="1.7" # minor release can change throughout the cycle
ALMA_VER="10"
# 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/${ALPINE_PREFIX}_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" <<EOF
- apt-get update
- apt-get install -y qemu-guest-agent curl
- systemctl enable --now qemu-guest-agent
EOF
;;
dnf)
cat >> "$SNIPPET_FILE" <<EOF
- dnf update -y
- dnf install -y qemu-guest-agent curl
- systemctl enable --now qemu-guest-agent
EOF
;;
pacman)
cat >> "$SNIPPET_FILE" <<EOF
- pacman -Syu --noconfirm
- pacman -S --noconfirm qemu-guest-agent bash-completion curl
- systemctl enable --now qemu-guest-agent
EOF
;;
apk)
cat >> "$SNIPPET_FILE" <<EOF
- apk update
- apk add qemu-guest-agent bash bash-completion curl
- rc-update add qemu-guest-agent default
- service qemu-guest-agent start
- sed -i 's|/bin/ash|/bin/bash|g' /etc/passwd
EOF
;;
esac
if [[ "$INSTALL_DOCKER" == true ]]; then
echo "Injecting Docker runtime installation into cloud-init..."
case $DISTRO_CHOICE in
alpine)
cat >> "$SNIPPET_FILE" <<EOF
- apk add docker docker-cli-compose
- rc-update add docker default
- service docker start
- addgroup $CI_USER docker
EOF
;;
arch)
cat >> "$SNIPPET_FILE" <<EOF
- pacman -S --noconfirm docker docker-compose
- systemctl enable --now docker
- usermod -aG docker $CI_USER
EOF
;;
*)
cat >> "$SNIPPET_FILE" <<EOF
- curl -fsSL https://get.docker.com -o get-docker.sh
- sh get-docker.sh
- systemctl enable --now docker
- usermod -aG docker $CI_USER
EOF
;;
esac
fi
# Always reboot at the end.
echo " - reboot" >> "$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."