diff --git a/proxmox-cloudimg/README.md b/proxmox-cloudimg/README.md new file mode 100644 index 0000000..c0b2d07 --- /dev/null +++ b/proxmox-cloudimg/README.md @@ -0,0 +1,188 @@ +# Proxmox Cloud Image Manager + +Download, customize, and create VM templates from cloud images for popular Linux distributions. Enables fast VM deployment with cloud-init — just like a real cloud provider. + +## Requirements + +- **Proxmox VE host** (script auto-elevates with `sudo` if needed) +- Packages: `curl`, `qemu-utils`, `libguestfs-tools` (optional, for qemu-guest-agent injection) + +## Usage + +``` +Usage: pve-cloudimg.sh [options] [args] + +Commands: + list [distro] List available distros, cached images, and VM templates + download [ver] Download image into cache only (no VM, no modifications). + Use "all" to download every supported distro. + create [ver] Download cloud image (if needed) and create VM template + update [ver] Re-download and re-create a template (remove + create) + remove [ver] Remove cached image and VM template for a distro + +Options: + -d, --dir DIR Image cache directory (default: /cloud-images/; set \$PVE_CLOUDIMG_DIR to change default) + --storage STORAGE Proxmox storage for VM disks (default: main-storage) + --bridge BRIDGE Network bridge (default: vmbr0) + --vlan VLAN VLAN tag for the network interface + --bios BIOS Firmware: seabios (default) or ovmf (UEFI) + --vm-id VMID VM ID for the template (default: auto from 9000) + --cores CORES CPU cores (default: 2) + --memory MEMORY Memory in MB (default: 2048) + --disk-size SIZE Disk size in GB (default: 10) + --user USER Cloud-init default user (default: distro-specific) + --ssh-key FILE SSH public key file to inject via cloud-init + --no-agent Skip installing qemu-guest-agent + --packages PKGS Extra packages to install (comma/space-separated, passed to virt-customize) + -f, --force Re-download image even if already cached + -y, --yes Skip confirmation prompts +``` + +### Examples + +```bash +# List available distros, cached images, and templates +./pve-cloudimg.sh list + +# Pre-cache an image (download only, no modifications) +./pve-cloudimg.sh download debian + +# Pre-cache every supported distro +./pve-cloudimg.sh download all + +# Create a template from the default distro version (Ubuntu 26.04) +./pve-cloudimg.sh create ubuntu + +# Create from a specific version with custom settings +./pve-cloudimg.sh create ubuntu 24.04 --storage local-btrfs --disk-size 64 --ssh-key ~/.ssh/id_rsa.pub + +# Create with custom user and resources +./pve-cloudimg.sh create debian --user admin --cores 4 --memory 8192 --bridge vmbr1 + +# Create on a specific VLAN +./pve-cloudimg.sh create ubuntu --vlan 100 + +# Install extra packages alongside the guest agent +./pve-cloudimg.sh create ubuntu --packages "htop nfs-common" + +# Live dangerously — Arch Linux rolling release +./pve-cloudimg.sh create arch + +# Force re-download even if cached (adds --force to any command) +./pve-cloudimg.sh download ubuntu --force +./pve-cloudimg.sh create ubuntu --force + +# Update to the latest image (re-download and re-create template) +./pve-cloudimg.sh update ubuntu + +# Replace an existing template with a completely different distro +./pve-cloudimg.sh remove ubuntu +./pve-cloudimg.sh create debian + +# Remove a template and its cached image +./pve-cloudimg.sh remove ubuntu 24.04 +``` + +## Supported Distributions + +| Distro | Default | Versions | Default User | +|--------|---------|----------|-------------| +| Ubuntu | 26.04 | 26.04, 24.04 | `ubuntu` | +| Debian | 13 | 13, 12 | `debian` | +| Arch Linux | rolling | rolling | `arch` | + +## How It Works + +1. **Download** (download command) — Fetches the official cloud image from the distro's mirrors and caches it locally. No modifications. +2. **Resize** (create command only) — Expands the disk to the requested size on a working copy (cloud-init's `growpart` resizes the partition on first boot) +3. **First-boot script** (create command only) — Writes a systemd oneshot service via `virt-customize` that installs packages (qemu-guest-agent + any extras) on first boot of each cloned VM +4. **Create VM** (create command only) — Configures CPU, memory, VirtIO SCSI controller, serial console, and QEMU guest agent +5. **Cloud-Init** (create command only) — Sets default user, optional SSH key, and DHCP networking +6. **Template** (create command only) — Converts the VM to a Proxmox template ready for cloning + +Use `download` to just fetch and cache the image without any modifications. Use `create` for the full pipeline (resize + firstboot installer + import + template). + +## Deploying a VM from a Template + +```bash +# Clone the template — repeat for as many VMs as you need +qm clone --name my-vm --full + +# Or with a specific target storage +qm clone --name my-vm --full --storage local-btrfs +``` + +## Customizing Cloud-Init Per-VM + +After cloning, edit the clone's cloud-init config: + +```bash +qm set --ciuser myuser --sshkeys ~/.ssh/id_rsa.pub +qm set --ipconfig0 ip=192.168.1.100/24,gw=192.168.1.1 +qm set --nameserver 1.1.1.1 +qm set --searchdomain example.com +qm start +``` + +## QEMU Guest Agent + +Rather than installing packages inside a chroot during image preparation (which breaks on many distros due to Landlock, missing dnf, incompatible arch detection, etc.), the `create` command writes a first-boot installer into the working copy via `virt-customize --firstboot-command`. This creates a systemd oneshot service that runs on the first boot of each cloned VM: + +| Distro family | Runs on first boot | +|---------------|-------------------| +| Ubuntu, Debian | `apt-get update && apt-get install -y qemu-guest-agent` | +| Arch Linux | `pacman -S --noconfirm qemu-guest-agent` | + +The installer runs in the actual VM environment with full kernel support, network access, and proper repos — no chroot limitations. + +The `download` command does **not** apply any first-boot scripts — it only fetches and caches the raw cloud image. + +To skip agent installation: `--no-agent` + +## Installing Extra Packages + +Two ways to add packages beyond `qemu-guest-agent`: + +**Per-run** with `--packages`: +```bash +./pve-cloudimg.sh create ubuntu --packages "htop nfs-common" +``` + +**Per-distro** by adding entries right under each `DISTROS` definition in the script: +```bash +DISTROS["ubuntu:26.04"]="..." +DISTRO_PACKAGES["ubuntu:26.04"]="htop nfs-common" +``` + +Packages with different names across distros get their own `DISTRO_PACKAGES` entry. + +## Adding a New Distribution + +Edit `pve-cloudimg.sh` and add entries near the top of the file. Each distro gets one or more `DISTROS` lines, with an optional `DISTRO_PACKAGES` line directly below: + +```bash +# Image definition +DISTROS["mydistro:1.0"]="https://example.com/image.qcow2|filename.qcow2|default-user|qcow2" +# Extra packages (optional, right below its DISTROS entry) +DISTRO_PACKAGES["mydistro:1.0"]="my-package other-package" +``` + +The `DISTROS` format is: `url|filename|default_user|image_format` + +Then add a default version so `create mydistro` (without a version) works: + +```bash +DISTRO_DEFAULT_VERSION[mydistro]="1.0" +``` + +## Notes + +- Run on the Proxmox host — the script auto-elevates with `sudo` if not run as root +- Images are cached in `/cloud-images/` by default — override with `-d` or `$PVE_CLOUDIMG_DIR` +- If you add the script to PATH (e.g. `/usr/local/bin/`), set `PVE_CLOUDIMG_DIR=/var/cache/proxmox-cloudimg` in your shell rc so it doesn't try to write into `/usr/local/bin/cloud-images/` +- The script auto-increments VM IDs starting at 9000 +- Cloud images from official distro mirrors — always up to date +- Use `-f`/`--force` to re-download an image even if already cached (e.g., to refresh a stale image) +- Resized disks use cloud-init's built-in `growpart` for automatic partition resizing on first boot +- Templates, clones, and cached images are independent — removing a template or cached image **does not** affect running VMs cloned from it +- To replace a template with a different distro, `remove` the old one then `create` the new one — they get separate VM IDs diff --git a/proxmox-cloudimg/pve-cloudimg.sh b/proxmox-cloudimg/pve-cloudimg.sh new file mode 100755 index 0000000..1fe9456 --- /dev/null +++ b/proxmox-cloudimg/pve-cloudimg.sh @@ -0,0 +1,692 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SCRIPT_NAME=$(basename "$0") + +# Default images directory — override with PVE_CLOUDIMG_DIR env var or -d flag +DEFAULT_IMAGES_DIR="${PVE_CLOUDIMG_DIR:-$SCRIPT_DIR/cloud-images}" +IMAGES_DIR="$DEFAULT_IMAGES_DIR" + +# Auto-sudo if not root +if [[ "$(id -u)" -ne 0 ]]; then + echo "[*] Elevating to root via sudo..." >&2 + exec sudo "$0" "$@" +fi + +# Default settings +PVE_STORAGE="main-storage" +PVE_BRIDGE="vmbr0" +PVE_VLAN="" +PVE_BIOS="seabios" +VM_CORES=2 +VM_MEMORY=2048 +VM_DISK_SIZE=10 +VM_ID="" +CLOUD_USER="" +SSH_KEY_FILE="" +INSTALL_AGENT=1 +EXTRA_PACKAGES="" +FORCE_RELOAD=0 +FORCE_YES=0 + +# Distro definitions (distro:version => URL, filename, user, format) +# Format: url|filename|default_user|image_format +declare -A DISTROS +declare -A DISTRO_PACKAGES + +# Each distro gets: most current version + one back. +# Each DISTROS entry can be followed by a DISTRO_PACKAGES entry for extra packages (space-separated). + +# Ubuntu LTS — current 26.04 (resolute), previous 24.04 (noble) +DISTROS["ubuntu:26.04"]="https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img|resolute-server-cloudimg-amd64.img|ubuntu|img" +DISTRO_PACKAGES["ubuntu:26.04"]="" +# --- +DISTROS["ubuntu:24.04"]="https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img|noble-server-cloudimg-amd64.img|ubuntu|img" +DISTRO_PACKAGES["ubuntu:24.04"]="" + +# Debian — current stable 13 (trixie), previous 12 (bookworm) +DISTROS["debian:13"]="https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2|debian-13-genericcloud-amd64.qcow2|debian|qcow2" +DISTRO_PACKAGES["debian:13"]="" +# --- +DISTROS["debian:12"]="https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2|debian-12-genericcloud-amd64.qcow2|debian|qcow2" +DISTRO_PACKAGES["debian:12"]="" + +# Arch Linux — rolling release +DISTROS["arch:rolling"]="https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2|Arch-Linux-x86_64-cloudimg.qcow2|arch|qcow2" +DISTRO_PACKAGES["arch:rolling"]="" + +declare -A DISTRO_DEFAULT_VERSION=( + [ubuntu]="26.04" + [debian]="13" + [arch]="rolling" +) + +# Proxmox template name prefix +TEMPLATE_PREFIX="cloud" + +log() { echo "[*] $*" >&2; } +warn() { echo "[!] $*" >&2; } +die() { echo "[FATAL] $*" >&2; exit 1; } + +usage() { + cat < [args] + +Commands: + list [distro] List available distros, cached images, and VM templates + download [ver] Download image into cache only (no modifications) + Use "all" to download every supported distro + create [ver] Download cloud image (if needed) and create VM template + update [ver] Re-download and re-create a template (remove + create) + remove [ver] Remove cached image and VM template for a distro + +Options: + -d, --dir DIR Image cache directory (default: script-dir/cloud-images; set \$PVE_CLOUDIMG_DIR to change default) + --storage STORAGE Proxmox storage for VM disks (default: $PVE_STORAGE) + --bridge BRIDGE Network bridge (default: $PVE_BRIDGE) + --vlan VLAN VLAN tag for the network interface + --bios BIOS Firmware: seabios (default) or ovmf (UEFI) + --vm-id VMID VM ID for the template (default: auto from 9000) + --cores CORES CPU cores (default: $VM_CORES) + --memory MEMORY Memory in MB (default: $VM_MEMORY) + --disk-size SIZE Disk size in GB (default: $VM_DISK_SIZE) + --user USER Cloud-init default user (default: distro-specific) + --ssh-key FILE SSH public key file to inject via cloud-init + --no-agent Skip installing qemu-guest-agent + --packages PKGS Extra packages to install (comma/space-separated, passed to virt-customize) + -f, --force Re-download image even if already cached + -y, --yes Skip confirmation prompts + +Examples: + $SCRIPT_NAME list + $SCRIPT_NAME list ubuntu + $SCRIPT_NAME create ubuntu 24.04 + $SCRIPT_NAME create ubuntu 24.04 --storage local-btrfs --disk-size 64 + $SCRIPT_NAME create debian --user admin --ssh-key ~/.ssh/id_rsa.pub + $SCRIPT_NAME remove ubuntu 24.04 +EOF + exit 1 +} + +# ---- Distro helpers ---- + +get_distro_field() { + local distro=$1 version=$2 field=$3 + local key="${distro}:${version}" + local data="${DISTROS[$key]:-}" + if [[ -z "$data" ]]; then + return 1 + fi + case "$field" in + url) echo "${data%%|*}" ;; + file) echo "${data#*|}" | cut -d'|' -f1 ;; + user) echo "${data#*|}" | cut -d'|' -f2 ;; + format) echo "${data#*|}" | cut -d'|' -f3 ;; + *) return 1 ;; + esac +} + +validate_distro_version() { + local distro=$1 version=$2 + local key="${distro}:${version}" + if [[ -z "${DISTROS[$key]:-}" ]]; then + return 1 + fi + return 0 +} + +list_known_versions() { + local distro=$1 + local versions=() + for key in "${!DISTROS[@]}"; do + if [[ "$key" == "${distro}:"* ]]; then + versions+=("${key#*:}") + fi + done + echo "${versions[@]}" +} + +list_known_distros() { + local distros=() + for key in "${!DISTROS[@]}"; do + local d="${key%%:*}" + local seen=0 + for seen_d in "${distros[@]}"; do + if [[ "$seen_d" == "$d" ]]; then seen=1; break; fi + done + if [[ $seen -eq 0 ]]; then + distros+=("$d") + fi + done + echo "${distros[@]}" +} + +get_default_version() { + local distro=$1 + echo "${DISTRO_DEFAULT_VERSION[$distro]:-}" +} + +image_cache_dir() { + local distro=$1 version=$2 + echo "$IMAGES_DIR/${distro}-${version}" +} + +template_name() { + local distro=$1 version=$2 + # Replace dots with dashes for VM name + local ver_safe="${version//./-}" + echo "${TEMPLATE_PREFIX}-${distro}-${ver_safe}" +} + +# ---- Proxmox helpers ---- + +check_proxmox() { + if ! command -v qm &>/dev/null; then + die "Proxmox VE not found — this script must be run on a PVE host" + fi +} + +check_storage() { + if ! pvesm status 2>/dev/null | awk -v s="$PVE_STORAGE" 'NR>1 && $1==s {f=1} END{exit !f}'; then + die "Storage '$PVE_STORAGE' not found — available: $(pvesm status 2>/dev/null | awk 'NR>1{print $1}' | tr '\n' ' ')" + fi + log "Storage '$PVE_STORAGE' is ready" +} + +check_virt_customize() { + if ! command -v virt-customize &>/dev/null; then + warn "virt-customize not found — install libguestfs-tools to enable guest agent injection" + warn " apt-get update && apt-get install -y libguestfs-tools" + return 1 + fi + return 0 +} + +next_available_vmid() { + local start=${1:-9000} + while qm status "$start" &>/dev/null; do + start=$((start + 1)) + done + echo "$start" +} + +template_exists() { + local name=$1 + qm list 2>/dev/null | awk -v name="$name" '$2 == name && $3 == "template" {print $1}' | head -1 +} + +# ---- Commands ---- + +list_templates() { + local prefix="${1:-}" + for conf in /etc/pve/nodes/*/qemu-server/*.conf; do + [[ -f "$conf" ]] || continue + grep -q "^template: 1" "$conf" 2>/dev/null || continue + local vmid name + vmid=$(basename "$conf" .conf) + name=$(grep "^name:" "$conf" 2>/dev/null | sed 's/^name: //') + if [[ -z "$prefix" ]] || [[ "$name" == "${prefix}"* ]]; then + echo " VM $vmid $name" + fi + done +} + +cmd_list() { + local filter="${1:-}" + + if [[ -n "$filter" ]]; then + if [[ -z "${DISTRO_DEFAULT_VERSION[$filter]:-}" ]]; then + warn "Unknown distro: $filter" + return 1 + fi + local versions + versions=$(list_known_versions "$filter") + echo "Distribution: $filter" + echo " Known versions: $versions" + echo " Default version: $(get_default_version "$filter")" + echo "" + echo " Cached images:" + if [[ -d "$IMAGES_DIR" ]]; then + local found=0 + for dir in "$IMAGES_DIR/${filter}-"*/; do + if [[ -d "$dir" ]] && (compgen -G "$dir"*.img &>/dev/null || compgen -G "$dir"*.qcow2 &>/dev/null || compgen -G "$dir"*.raw &>/dev/null); then + local dirname size + dirname=$(basename "$dir") + size=$(du -sh "$dir" 2>/dev/null | cut -f1) + echo " $dirname ($size)" + found=1 + fi + done + [[ $found -eq 0 ]] && echo " (none)" + else + echo " (none)" + fi + echo "" + echo " Templates:" + local tmpl + tmpl=$(list_templates "${TEMPLATE_PREFIX}-${filter}") + echo "${tmpl:- (none)}" + return + fi + + echo "Available distributions:" + local distros + distros=$(list_known_distros) + for d in $distros; do + local versions + versions=$(list_known_versions "$d") + local default + default=$(get_default_version "$d") + echo " $d (default: $default, versions: $versions)" + done + echo "" + + echo "Cached images:" + if [[ -d "$IMAGES_DIR" ]]; then + local found=0 + for dir in "$IMAGES_DIR"/*/; do + local dirname + dirname=$(basename "$dir") + local has_files=0 + for ext in img qcow2 raw; do + if compgen -G "$dir"*."$ext" &>/dev/null; then + has_files=1; break + fi + done + if [[ $has_files -eq 1 ]]; then + local size + size=$(du -sh "$dir" 2>/dev/null | cut -f1) + echo " $dirname ($size)" + found=1 + fi + done + [[ $found -eq 0 ]] && echo " (none)" + else + echo " (none)" + fi + echo "" + + echo "Proxmox templates:" + local tmpl + tmpl=$(list_templates) + echo "${tmpl:- (none)}" +} + +# ---- Shared image preparation ---- + +# Resolve distro metadata, download to cache, optionally customize. +# Returns: || +prepare_image() { + local distro=$1 version=${2:-} + + if [[ -z "$version" ]]; then + version=$(get_default_version "$distro") + if [[ -z "$version" ]]; then + die "Unknown distro '$distro'. Available: $(list_known_distros)" + fi + log "Using default version: $distro $version" + fi + + if ! validate_distro_version "$distro" "$version"; then + die "Unknown distro/version: $distro $version" + fi + + local image_file + image_file=$(get_distro_field "$distro" "$version" "file") + local image_url + image_url=$(get_distro_field "$distro" "$version" "url") + local default_user + default_user=$(get_distro_field "$distro" "$version" "user") + + local cache_dir + cache_dir=$(image_cache_dir "$distro" "$version") + local cache_path="$cache_dir/$image_file" + local tname + tname=$(template_name "$distro" "$version") + local ci_user="${CLOUD_USER:-$default_user}" + + log "Distribution: $distro $version" + log "Cloud-init user: $ci_user" + + # Download image if not cached (or force re-download) + if [[ -f "$cache_path" ]]; then + if [[ "$FORCE_RELOAD" -eq 1 ]]; then + log "Forcing re-download: removing cached image..." + rm -f "$cache_path" + else + log "Image already cached: $cache_path" + fi + fi + + if [[ ! -f "$cache_path" ]]; then + log "Downloading $distro $version..." + mkdir -p "$cache_dir" + log " URL: $image_url" + curl -fL -o "$cache_path" "$image_url" || { + rm -f "$cache_path" + die "Download failed" + } + log "Downloaded: $(du -h "$cache_path" | cut -f1)" + fi + + echo "${cache_path}|${ci_user}|${tname}|${image_file}" +} + +cmd_download() { + local distro=$1 version=${2:-} + + # Special case: download --all or download all + if [[ "$distro" == "all" ]] || [[ "$distro" == "--all" ]]; then + log "Downloading all supported distro versions..." + local all_keys=() + for key in "${!DISTROS[@]}"; do + all_keys+=("$key") + done + # Sort for consistent order + IFS=$'\n' all_keys=($(sort <<<"${all_keys[*]}")); unset IFS + for key in "${all_keys[@]}"; do + local d="${key%%:*}" v="${key#*:}" + echo "" + log "=== $d $v ===" + cmd_download "$d" "$v" + done + echo "" + log "All images downloaded." + return + fi + + local result + result=$(prepare_image "$distro" "$version") + local cache_path ci_user tname image_file + cache_path=$(echo "$result" | cut -d'|' -f1) + ci_user=$(echo "$result" | cut -d'|' -f2) + tname=$(echo "$result" | cut -d'|' -f3) + image_file=$(echo "$result" | cut -d'|' -f4) + + echo "" + log "Image ready at: $cache_path" + echo " Distro: $distro $version" + echo " Size: $(du -h "$cache_path" | cut -f1)" + echo " Cloud-init: user=$ci_user" + echo "" + echo "To create a VM template from this image:" + echo " $SCRIPT_NAME create $distro $version" +} + +cmd_create() { + local distro=$1 version=${2:-} + + local result + result=$(prepare_image "$distro" "$version") + local cache_path ci_user tname image_file + cache_path=$(echo "$result" | cut -d'|' -f1) + ci_user=$(echo "$result" | cut -d'|' -f2) + tname=$(echo "$result" | cut -d'|' -f3) + image_file=$(echo "$result" | cut -d'|' -f4) + + echo "" + log "Template name: $tname" + + # Check for existing template + local existing_vmid + existing_vmid=$(template_exists "$tname") + if [[ -n "$existing_vmid" ]]; then + if [[ "$FORCE_YES" -ne 1 ]]; then + warn "Template '$tname' already exists as VM $existing_vmid" + read -r -p "Overwrite? This will destroy the existing template. [y/N] " reply + if [[ ! "$reply" =~ ^[Yy]$ ]]; then + die "Aborted." + fi + fi + log "Removing existing template VM $existing_vmid..." + qm stop "$existing_vmid" &>/dev/null || true + qm destroy "$existing_vmid" --purge + fi + + # Resolve VM ID + local vmid="$VM_ID" + if [[ -z "$vmid" ]]; then + vmid=$(next_available_vmid 9000) + fi + + # Build network config + local netdev="virtio,bridge=${PVE_BRIDGE}" + if [[ -n "$PVE_VLAN" ]]; then + netdev+=",tag=${PVE_VLAN}" + fi + + # Create VM + local machine_args=() + if [[ "$PVE_BIOS" == "ovmf" ]]; then + machine_args=(--machine q35 --efidisk0 "${PVE_STORAGE}:0,format=raw,efitype=4m") + fi + + log "Creating VM $vmid..." + qm create "$vmid" \ + --name "$tname" \ + --memory "$VM_MEMORY" \ + --cores "$VM_CORES" \ + --net0 "$netdev" \ + --serial0 socket \ + --vga serial0 \ + --agent enabled=1 \ + "${machine_args[@]}" \ + --description "Cloud image template: $distro $version (created $(date -u +%Y-%m-%d))" + + # Build package list for first-boot install + local PKGLIST="" + [[ "$INSTALL_AGENT" -eq 1 ]] && PKGLIST="qemu-guest-agent" + local distro_pkgs="${DISTRO_PACKAGES[${distro}:${version}]:-}" + if [[ -n "$distro_pkgs" ]]; then PKGLIST="$PKGLIST $distro_pkgs"; fi + if [[ -n "$EXTRA_PACKAGES" ]]; then PKGLIST="$PKGLIST $EXTRA_PACKAGES"; fi + + # Create a working copy: resize + firstboot installer, then import + local import_image="$cache_path" + local workdir="" + if [[ "$VM_DISK_SIZE" -gt 0 ]] || [[ -n "$PKGLIST" ]]; then + workdir=$(mktemp -d) + import_image="$workdir/$image_file" + cp "$cache_path" "$import_image" + + if [[ "$VM_DISK_SIZE" -gt 0 ]]; then + log "Resizing disk to ${VM_DISK_SIZE}G..." + qemu-img resize "$import_image" "${VM_DISK_SIZE}G" >&2 + fi + + if [[ -n "$PKGLIST" ]] && check_virt_customize; then + log "Scheduling first-boot install:${PKGLIST}" + virt-customize -a "$import_image" --firstboot-command " + set -eux + command -v apt-get && apt-get update && apt-get install -y ${PKGLIST} && { systemctl enable --now qemu-guest-agent 2>/dev/null || true; } && exit 0 + command -v pacman && pacman -S --noconfirm ${PKGLIST} && { systemctl enable --now qemu-guest-agent 2>/dev/null || true; } && exit 0 + " >&2 + fi + fi + + # Import disk + check_storage + log "Importing disk to $PVE_STORAGE..." + qm importdisk "$vmid" "$import_image" "$PVE_STORAGE" + + # Read the exact volume reference from the VM config (handles all storage types) + local volume_ref + volume_ref=$(qm config "$vmid" | awk '/^unused/ {print $2; exit}') + if [[ -z "$volume_ref" ]]; then + die "Disk import succeeded but no unused disk found in VM $vmid config" + fi + log "Imported volume: $volume_ref" + + # Attach disk (VirtIO SCSI — required by images lacking virtio-blk driver) + qm set "$vmid" \ + --scsihw virtio-scsi-pci \ + --scsi0 "$volume_ref,discard=on,ssd=1" \ + --ide2 "$PVE_STORAGE:cloudinit" \ + --boot order=scsi0 + + # Configure cloud-init + log "Configuring cloud-init..." + qm set "$vmid" --ciuser "$ci_user" + + if [[ -n "$SSH_KEY_FILE" ]]; then + if [[ -f "$SSH_KEY_FILE" ]]; then + qm set "$vmid" --sshkeys "$SSH_KEY_FILE" + else + warn "SSH key file not found: $SSH_KEY_FILE" + fi + fi + + qm set "$vmid" --ipconfig0 ip=dhcp + + # Convert to template + log "Converting VM $vmid to template..." + qm template "$vmid" + + # Clean up working copy + if [[ -n "$workdir" ]]; then + rm -rf "$workdir" + fi + + echo "" + log "Template created successfully!" + echo " VM ID: $vmid" + echo " Name: $tname" + echo " Distro: $distro $version" + echo " User: $ci_user" + echo " Disk: ${VM_DISK_SIZE}G on $PVE_STORAGE" + echo " Memory: ${VM_MEMORY}MB" + echo " Cores: $VM_CORES" + echo "" + echo "To clone: qm clone $vmid --name " + echo "To create from CLI: qm clone $vmid 100 --name my-vm --full" +} + +cmd_remove() { + local distro=$1 version=${2:-} + + if [[ -z "$version" ]]; then + version=$(get_default_version "$distro") + if [[ -z "$version" ]]; then + die "Unknown distro '$distro'" + fi + log "Using default version: $distro $version" + fi + + if ! validate_distro_version "$distro" "$version"; then + die "Unknown distro/version: $distro $version" + fi + + local tname + tname=$(template_name "$distro" "$version") + + # Remove VM template + local existing_vmid + existing_vmid=$(template_exists "$tname") + if [[ -n "$existing_vmid" ]]; then + if [[ "$FORCE_YES" -ne 1 ]]; then + warn "Template '$tname' exists as VM $existing_vmid" + read -r -p "Destroy this template? [y/N] " reply + if [[ ! "$reply" =~ ^[Yy]$ ]]; then + log "Skipping template removal." + else + log "Destroying template VM $existing_vmid..." + qm stop "$existing_vmid" &>/dev/null || true + qm destroy "$existing_vmid" --purge + fi + else + log "Destroying template VM $existing_vmid..." + qm stop "$existing_vmid" &>/dev/null || true + qm destroy "$existing_vmid" --purge + fi + else + log "No template found for $distro $version" + fi + + # Remove cached image + local cache_dir + cache_dir=$(image_cache_dir "$distro" "$version") + if [[ -d "$cache_dir" ]]; then + if [[ "$FORCE_YES" -ne 1 ]]; then + read -r -p "Remove cached image at $cache_dir? [y/N] " reply2 + if [[ "$reply2" =~ ^[Yy]$ ]]; then + rm -rf "$cache_dir" + log "Removed: $cache_dir" + else + log "Keeping cache." + fi + else + rm -rf "$cache_dir" + log "Removed: $cache_dir" + fi + else + log "No cached image for $distro $version" + fi + + log "Done." +} + +# ---- Main ---- + +# Gather all args, parse options anywhere (before or after command) +ARGS=() +COMMAND="" +while [[ $# -gt 0 ]]; do + case "$1" in + -d|--dir) IMAGES_DIR="$2"; shift 2 ;; + --storage) PVE_STORAGE="$2"; shift 2 ;; + --bridge) PVE_BRIDGE="$2"; shift 2 ;; + --vlan) PVE_VLAN="$2"; shift 2 ;; + --bios) PVE_BIOS="$2"; shift 2 ;; + --vm-id) VM_ID="$2"; shift 2 ;; + --cores) VM_CORES="$2"; shift 2 ;; + --memory) VM_MEMORY="$2"; shift 2 ;; + --disk-size) VM_DISK_SIZE="$2"; shift 2 ;; + --user) CLOUD_USER="$2"; shift 2 ;; + --ssh-key) SSH_KEY_FILE="$2"; shift 2 ;; + --no-agent) INSTALL_AGENT=0; shift ;; + --packages) EXTRA_PACKAGES="$2"; shift 2 ;; + -f|--force) FORCE_RELOAD=1; shift ;; + -y|--yes) FORCE_YES=1; shift ;; + --) shift; break ;; + -*) usage ;; + *) + if [[ -z "$COMMAND" ]]; then + COMMAND="$1" + else + ARGS+=("$1") + fi + shift + ;; + esac +done +# Append any remaining args after -- +ARGS+=("$@") + +[[ -z "$COMMAND" ]] && usage +set -- "${ARGS[@]}" + +case "$COMMAND" in + list) + cmd_list "$@" + ;; + download) + [[ $# -lt 1 ]] && usage + cmd_download "$@" + ;; + create|update) + check_proxmox + [[ $# -lt 1 ]] && usage + if [[ "$COMMAND" == "update" ]]; then + cmd_remove "$@" || true + fi + cmd_create "$@" + ;; + remove) + check_proxmox + [[ $# -lt 1 ]] && usage + cmd_remove "$@" + ;; + *) + usage + ;; +esac