Files
ergo-proxmox-scripts/build-cloudinit-template.sh
T
ergosteurandClaude Opus 5 1576323820 Document what --docker does
The usage line listed --docker but nothing said what it did, so the only way to
find out was to read the runcmd generation.

Two things are worth stating beyond "installs Docker". The install runs on
first boot of each clone rather than during the build, because the snippet is a
runcmd and the template is never booted, so every clone fetches Docker itself
and nothing is baked into the image. And the non-Alpine, non-Arch path uses the
convenience script from get.docker.com, so those guests get Docker CE from
Docker's repo rather than the distro package.

Also records that the cloud-init user lands in the docker group, which is
root-equivalent on that guest, and that the flag is positional.

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

369 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]
#
# --docker adds a Docker install to the cloud-init snippet, names the template
# with a -docker suffix and tags it "docker". Alpine and Arch use their own
# packages; the rest use Docker's convenience script from get.docker.com, so
# they get Docker CE rather than the distro build. The cloud-init user is added
# to the docker group, which is root-equivalent on that guest.
#
# The install runs on first boot of each clone, not while building: the snippet
# is a runcmd and the template is never booted. So every clone fetches Docker
# itself, and the template stays generic. It must be the third argument.
#
# 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."