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'."