added proxmox cloud image script
This commit is contained in:
@@ -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] <command> [args]
|
||||
|
||||
Commands:
|
||||
list [distro] List available distros, cached images, and VM templates
|
||||
download <distro> [ver] Download image into cache only (no VM, no modifications).
|
||||
Use "all" to download every supported distro.
|
||||
create <distro> [ver] Download cloud image (if needed) and create VM template
|
||||
update <distro> [ver] Re-download and re-create a template (remove + create)
|
||||
remove <distro> [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: 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 <template-vmid> <new-vmid> --name my-vm --full
|
||||
|
||||
# Or with a specific target storage
|
||||
qm clone <template-vmid> <new-vmid> --name my-vm --full --storage local-btrfs
|
||||
```
|
||||
|
||||
## Customizing Cloud-Init Per-VM
|
||||
|
||||
After cloning, edit the clone's cloud-init config:
|
||||
|
||||
```bash
|
||||
qm set <new-vmid> --ciuser myuser --sshkeys ~/.ssh/id_rsa.pub
|
||||
qm set <new-vmid> --ipconfig0 ip=192.168.1.100/24,gw=192.168.1.1
|
||||
qm set <new-vmid> --nameserver 1.1.1.1
|
||||
qm set <new-vmid> --searchdomain example.com
|
||||
qm start <new-vmid>
|
||||
```
|
||||
|
||||
## 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 `<script-dir>/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
|
||||
Executable
+692
@@ -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 <<EOF
|
||||
Usage: $SCRIPT_NAME [options] <command> [args]
|
||||
|
||||
Commands:
|
||||
list [distro] List available distros, cached images, and VM templates
|
||||
download <distro> [ver] Download image into cache only (no modifications)
|
||||
Use "all" to download every supported distro
|
||||
create <distro> [ver] Download cloud image (if needed) and create VM template
|
||||
update <distro> [ver] Re-download and re-create a template (remove + create)
|
||||
remove <distro> [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: <cache_path>|<ci_user>|<tname>
|
||||
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 <new-vmid> --name <vm-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
|
||||
Reference in New Issue
Block a user