mirror of
https://github.com/ergosteur/ergo-proxmox-scripts.git
synced 2026-10-28 05:00:35 -04:00
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>
369 lines
12 KiB
Bash
Executable File
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."
|