2366 lines
75 KiB
Bash
Executable File
2366 lines
75 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# incusos-proxmox - Declarative IncusOS deployment on Proxmox VE
|
|
#
|
|
# Reads a YAML configuration file defining VMs and deploys them on a Proxmox
|
|
# host with IncusOS-correct settings (UEFI, TPM 2.0, VirtIO-SCSI, etc.).
|
|
# Generates per-VM seed images, uploads ISOs, creates VMs, and boots them
|
|
# through automated installation.
|
|
#
|
|
# Usage: incusos-proxmox [OPTIONS] CONFIG_FILE
|
|
# Run 'incusos-proxmox --help' for full usage information.
|
|
|
|
set -euo pipefail
|
|
|
|
readonly VERSION="0.1.0"
|
|
readonly SCRIPT_NAME="incusos-proxmox"
|
|
readonly CDN_INDEX="https://images.linuxcontainers.org/os/index.json"
|
|
readonly CDN_BASE="https://images.linuxcontainers.org/os"
|
|
readonly MIN_DISK_GIB=50
|
|
readonly MIN_MEMORY_MIB=4096
|
|
readonly MANAGED_MARKER="[incusos-lab:managed]"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Color and formatting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
setup_colors() {
|
|
if [[ -t 1 ]] && [[ "${NO_COLOR:-}" != "1" ]] && [[ "${TERM:-}" != "dumb" ]]; then
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[0;33m'
|
|
BLUE='\033[0;34m'
|
|
CYAN='\033[0;36m'
|
|
BOLD='\033[1m'
|
|
DIM='\033[2m'
|
|
RESET='\033[0m'
|
|
else
|
|
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' DIM='' RESET=''
|
|
fi
|
|
}
|
|
|
|
info() { [[ "$QUIET" == true ]] && return; echo -e "${BLUE}[info]${RESET} $*"; }
|
|
success() { [[ "$QUIET" == true ]] && return; echo -e "${GREEN}[ok]${RESET} $*"; }
|
|
warn() { echo -e "${YELLOW}[warn]${RESET} $*" >&2; }
|
|
error() { echo -e "${RED}[error]${RESET} $*" >&2; }
|
|
step() { [[ "$QUIET" == true ]] && return; echo -e "${CYAN}[step]${RESET} ${BOLD}$*${RESET}"; }
|
|
detail() { [[ "$QUIET" == true ]] && return; echo -e "${DIM} $*${RESET}"; }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Help
|
|
# ---------------------------------------------------------------------------
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
${BOLD}${SCRIPT_NAME}${RESET} v${VERSION} - Declarative IncusOS deployment on Proxmox VE
|
|
|
|
${BOLD}USAGE${RESET}
|
|
${SCRIPT_NAME} [OPTIONS] CONFIG_FILE
|
|
|
|
${BOLD}DESCRIPTION${RESET}
|
|
Reads a YAML configuration file defining VMs and deploys them on a Proxmox
|
|
host with IncusOS-correct settings. Handles seed generation, ISO upload, VM
|
|
creation, and automated installation.
|
|
|
|
IncusOS requires specific Proxmox VM settings:
|
|
- UEFI firmware without pre-enrolled Secure Boot keys
|
|
- TPM 2.0 virtual device
|
|
- VirtIO-SCSI controller (not VirtIO-blk)
|
|
- CPU type 'host' (x86_64_v3 instruction set requirement)
|
|
- Minimum 50 GiB disk, minimum 4 GiB RAM
|
|
|
|
This script ensures all settings are correct, preventing install failures.
|
|
|
|
${BOLD}OPTIONS${RESET}
|
|
${BOLD}-p, --phase${RESET} PHASE Run a specific phase:
|
|
${CYAN}validate${RESET} -- parse config, check connectivity
|
|
${CYAN}prepare${RESET} -- download ISO, generate seeds
|
|
${CYAN}upload${RESET} -- upload ISO and seeds to Proxmox
|
|
${CYAN}create${RESET} -- create VMs with correct settings
|
|
${CYAN}install${RESET} -- start VMs and monitor installation
|
|
${CYAN}all${RESET} -- run all phases (default)
|
|
${BOLD}-m, --method${RESET} METHOD Connection method: ${CYAN}ssh${RESET} (default), ${CYAN}api${RESET}
|
|
${BOLD}-H, --host${RESET} HOST Override Proxmox host from config
|
|
${BOLD}-i, --iso${RESET} FILE Use local ISO file (skip CDN download)
|
|
${BOLD}-c, --cert${RESET} FILE Client certificate PEM file
|
|
${BOLD}--no-cert${RESET} Skip certificate injection
|
|
${BOLD}--vm${RESET} NAME Operate on a single VM only
|
|
${BOLD}-y, --yes${RESET} Skip confirmation prompts
|
|
${BOLD}--cleanup${RESET} Destroy VMs defined in config (managed only)
|
|
${BOLD}--force-cleanup${RESET} Destroy VMs even without management marker
|
|
${BOLD}--dry-run${RESET} Preview actions without executing
|
|
${BOLD}-q, --quiet${RESET} Suppress informational output
|
|
${BOLD}-h, --help${RESET} Show this help message
|
|
${BOLD}-V, --version${RESET} Show version
|
|
|
|
${BOLD}CONFIG FILE${RESET}
|
|
YAML file defining the Proxmox connection and VM specifications.
|
|
See examples/proxmox-lab.yaml for a full example.
|
|
|
|
${BOLD}proxmox:${RESET}
|
|
host: 192.168.1.10 # Proxmox host (required)
|
|
node: pve # Node name (default: pve)
|
|
storage: local-lvm # VM disk storage (default: local-lvm)
|
|
iso_storage: local # ISO storage (default: local)
|
|
bridge: vmbr0 # Network bridge (default: vmbr0)
|
|
method: ssh # ssh or api (default: ssh)
|
|
ssh_user: root # SSH user (default: root)
|
|
|
|
${BOLD}defaults:${RESET}
|
|
cores: 4 # Default CPU cores
|
|
memory: 8192 # Default RAM in MiB (min: 4096)
|
|
disk: 64 # Default disk in GiB (min: 50)
|
|
channel: stable # Update channel
|
|
start_vmid: 200 # Auto-assign VMIDs from here
|
|
|
|
${BOLD}vms:${RESET}
|
|
- name: my-vm # VM name (required)
|
|
app: incus # incus or operations-center (required)
|
|
apply_defaults: true # Apply IncusOS defaults
|
|
cores: 4 # Override default cores
|
|
memory: 8192 # Override default memory
|
|
disk: 64 # Override default disk
|
|
vmid: 200 # Explicit VMID (optional)
|
|
|
|
${BOLD}EXAMPLES${RESET}
|
|
# Preview a full deployment
|
|
${SCRIPT_NAME} --dry-run examples/proxmox-lab.yaml
|
|
|
|
# Validate config and check Proxmox connectivity
|
|
${SCRIPT_NAME} --phase validate examples/proxmox-lab.yaml
|
|
|
|
# Deploy everything (with confirmation prompts)
|
|
${SCRIPT_NAME} examples/proxmox-lab.yaml
|
|
|
|
# Deploy without prompts
|
|
${SCRIPT_NAME} --yes examples/proxmox-lab.yaml
|
|
|
|
# Deploy a single VM from the config
|
|
${SCRIPT_NAME} --vm ops-center examples/proxmox-lab.yaml
|
|
|
|
# Use a local ISO instead of downloading
|
|
${SCRIPT_NAME} --iso IncusOS_20250201.iso examples/proxmox-lab.yaml
|
|
|
|
# Destroy all VMs defined in config
|
|
${SCRIPT_NAME} --cleanup examples/proxmox-lab.yaml
|
|
|
|
# Use API method with token
|
|
export PROXMOX_TOKEN_SECRET="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
|
${SCRIPT_NAME} --method api examples/proxmox-lab.yaml
|
|
|
|
${BOLD}PROXMOX SETUP${RESET}
|
|
${BOLD}SSH method (default):${RESET}
|
|
Requires key-based SSH authentication to the Proxmox host.
|
|
Test with: ssh root@<host> pvesh get /version
|
|
|
|
${BOLD}API method:${RESET}
|
|
Requires an API token. Set up minimal privileges:
|
|
|
|
pveum role add IncusOSDeploy --privs "VM.Allocate VM.Config.Disk \\
|
|
VM.Config.CPU VM.Config.Memory VM.Config.Network VM.Config.CDROM \\
|
|
VM.Config.Options VM.Config.HWType VM.PowerMgmt VM.Audit \\
|
|
Datastore.AllocateSpace Datastore.AllocateTemplate \\
|
|
Datastore.Audit SDN.Use"
|
|
pveum user add automation@pve
|
|
pveum aclmod / -user automation@pve -role IncusOSDeploy
|
|
pveum user token add automation@pve deploy --privsep 0
|
|
|
|
Set the token secret:
|
|
export PROXMOX_TOKEN_SECRET="<secret-from-above>"
|
|
|
|
And in your config file:
|
|
proxmox:
|
|
method: api
|
|
api_token_id: automation@pve!deploy
|
|
|
|
${BOLD}PREREQUISITES${RESET}
|
|
- python3 with PyYAML (python3-yaml / py3-yaml)
|
|
- jq (for JSON field access; falls back to python3)
|
|
- ssh + scp (for SSH method)
|
|
- curl (for API method and CDN downloads)
|
|
- incusos-seed sibling script (auto-detected)
|
|
- Bash 4+ (associative arrays)
|
|
|
|
EOF
|
|
exit 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Argument parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Defaults
|
|
PHASE="all"
|
|
METHOD=""
|
|
HOST_OVERRIDE=""
|
|
LOCAL_ISO=""
|
|
CERT_FILE=""
|
|
NO_CERT=false
|
|
VM_FILTER=""
|
|
YES=false
|
|
CLEANUP=false
|
|
FORCE_CLEANUP=false
|
|
DRY_RUN=false
|
|
QUIET=false
|
|
CONFIG_FILE=""
|
|
|
|
parse_args() {
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-p|--phase)
|
|
PHASE="${2:?'--phase requires a value'}"
|
|
shift 2
|
|
;;
|
|
-m|--method)
|
|
METHOD="${2:?'--method requires a value'}"
|
|
shift 2
|
|
;;
|
|
-H|--host)
|
|
HOST_OVERRIDE="${2:?'--host requires a value'}"
|
|
shift 2
|
|
;;
|
|
-i|--iso)
|
|
LOCAL_ISO="${2:?'--iso requires a file path'}"
|
|
shift 2
|
|
;;
|
|
-c|--cert)
|
|
CERT_FILE="${2:?'--cert requires a file path'}"
|
|
shift 2
|
|
;;
|
|
--no-cert)
|
|
NO_CERT=true
|
|
shift
|
|
;;
|
|
--vm)
|
|
VM_FILTER="${2:?'--vm requires a name'}"
|
|
shift 2
|
|
;;
|
|
-y|--yes)
|
|
YES=true
|
|
shift
|
|
;;
|
|
--cleanup)
|
|
CLEANUP=true
|
|
shift
|
|
;;
|
|
--force-cleanup)
|
|
CLEANUP=true
|
|
FORCE_CLEANUP=true
|
|
shift
|
|
;;
|
|
--dry-run)
|
|
DRY_RUN=true
|
|
shift
|
|
;;
|
|
-q|--quiet)
|
|
QUIET=true
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
;;
|
|
-V|--version)
|
|
echo "${SCRIPT_NAME} v${VERSION}"
|
|
exit 0
|
|
;;
|
|
-*)
|
|
error "Unknown option: $1"
|
|
echo "Run '${SCRIPT_NAME} --help' for usage information." >&2
|
|
exit 1
|
|
;;
|
|
*)
|
|
if [[ -z "$CONFIG_FILE" ]]; then
|
|
CONFIG_FILE="$1"
|
|
else
|
|
error "Unexpected argument: $1 (config file already set to ${CONFIG_FILE})"
|
|
echo "Run '${SCRIPT_NAME} --help' for usage information." >&2
|
|
exit 1
|
|
fi
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
validate_args() {
|
|
if [[ -z "$CONFIG_FILE" ]]; then
|
|
error "No configuration file specified"
|
|
echo "Usage: ${SCRIPT_NAME} [OPTIONS] CONFIG_FILE" >&2
|
|
echo "Run '${SCRIPT_NAME} --help' for usage information." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ ! -f "$CONFIG_FILE" ]]; then
|
|
error "Configuration file not found: ${CONFIG_FILE}"
|
|
exit 1
|
|
fi
|
|
|
|
case "$PHASE" in
|
|
validate|prepare|upload|create|install|all) ;;
|
|
*)
|
|
error "Invalid phase: ${PHASE}"
|
|
error "Supported: validate, prepare, upload, create, install, all"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
if [[ -n "$METHOD" ]]; then
|
|
case "$METHOD" in
|
|
ssh|api) ;;
|
|
*)
|
|
error "Invalid method: ${METHOD}"
|
|
error "Supported: ssh, api"
|
|
exit 1
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
if [[ -n "$CERT_FILE" ]] && [[ "$NO_CERT" == true ]]; then
|
|
error "Cannot use both --cert and --no-cert"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -n "$CERT_FILE" ]] && [[ ! -f "$CERT_FILE" ]]; then
|
|
error "Certificate file not found: ${CERT_FILE}"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -n "$LOCAL_ISO" ]] && [[ ! -f "$LOCAL_ISO" ]]; then
|
|
error "ISO file not found: ${LOCAL_ISO}"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dependency checks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SEED_TOOL=""
|
|
|
|
check_dependencies() {
|
|
step "Checking dependencies"
|
|
|
|
local missing=false
|
|
|
|
# python3
|
|
if ! command -v python3 &>/dev/null; then
|
|
error "python3 is required but not found"
|
|
error "Install Python 3 for your platform (https://www.python.org/)"
|
|
missing=true
|
|
else
|
|
detail "python3: $(command -v python3)"
|
|
fi
|
|
|
|
# python3-yaml (optional -- basic YAML fallback built-in)
|
|
if command -v python3 &>/dev/null; then
|
|
if python3 -c "import yaml" 2>/dev/null; then
|
|
detail "PyYAML: available"
|
|
else
|
|
detail "PyYAML: not found (using built-in basic parser)"
|
|
fi
|
|
fi
|
|
|
|
# jq (optional, python3 fallback)
|
|
if command -v jq &>/dev/null; then
|
|
detail "jq: $(command -v jq)"
|
|
else
|
|
detail "jq: not found (using python3 fallback)"
|
|
fi
|
|
|
|
# curl
|
|
if ! command -v curl &>/dev/null; then
|
|
error "curl is required but not found"
|
|
missing=true
|
|
else
|
|
detail "curl: $(command -v curl)"
|
|
fi
|
|
|
|
# ssh/scp (for SSH method)
|
|
if [[ "${PVE_METHOD:-ssh}" == "ssh" ]]; then
|
|
if ! command -v ssh &>/dev/null; then
|
|
error "ssh is required for SSH method but not found"
|
|
missing=true
|
|
fi
|
|
if ! command -v scp &>/dev/null; then
|
|
error "scp is required for SSH method but not found"
|
|
missing=true
|
|
fi
|
|
fi
|
|
|
|
# incusos-seed sibling script
|
|
local script_dir
|
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
SEED_TOOL="${script_dir}/incusos-seed"
|
|
if [[ ! -x "$SEED_TOOL" ]]; then
|
|
error "incusos-seed not found at: ${SEED_TOOL}"
|
|
error "The incusos-seed script must be in the same directory as ${SCRIPT_NAME}"
|
|
missing=true
|
|
else
|
|
detail "incusos-seed: ${SEED_TOOL}"
|
|
fi
|
|
|
|
# bash version
|
|
if [[ "${BASH_VERSINFO[0]}" -lt 4 ]]; then
|
|
error "Bash 4+ is required (found ${BASH_VERSION})"
|
|
missing=true
|
|
else
|
|
detail "bash: ${BASH_VERSION}"
|
|
fi
|
|
|
|
if [[ "$missing" == true ]]; then
|
|
error "Missing dependencies -- cannot continue"
|
|
exit 1
|
|
fi
|
|
|
|
success "All dependencies satisfied"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Temporary directory management
|
|
# ---------------------------------------------------------------------------
|
|
|
|
WORKDIR=""
|
|
|
|
setup_workdir() {
|
|
WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/${SCRIPT_NAME}-XXXXXX")
|
|
trap 'rm -rf "$WORKDIR"' EXIT
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# YAML parsing via python3
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Convert YAML config to JSON, stored in a file for repeated access
|
|
CONFIG_JSON=""
|
|
|
|
parse_config() {
|
|
step "Parsing configuration: ${CONFIG_FILE}"
|
|
|
|
CONFIG_JSON="${WORKDIR}/config.json"
|
|
|
|
# Try PyYAML first, fall back to a basic pure-Python parser
|
|
if ! python3 -c "
|
|
import json, sys, re
|
|
|
|
def _parse_value(v):
|
|
v = v.strip()
|
|
# Strip inline comments (but not inside quotes)
|
|
if not v.startswith(('\"', \"'\")):
|
|
m = re.match(r'^(.*?)\s+#.*$', v)
|
|
if m:
|
|
v = m.group(1).rstrip()
|
|
if not v:
|
|
return None
|
|
if (v.startswith('\"') and v.endswith('\"')) or (v.startswith(\"'\") and v.endswith(\"'\")):
|
|
return v[1:-1]
|
|
if v == 'true':
|
|
return True
|
|
if v == 'false':
|
|
return False
|
|
if v == 'null' or v == '~':
|
|
return None
|
|
try:
|
|
return int(v)
|
|
except ValueError:
|
|
pass
|
|
try:
|
|
return float(v)
|
|
except ValueError:
|
|
pass
|
|
return v
|
|
|
|
def parse_yaml_basic(path):
|
|
\"\"\"Minimal YAML parser for simple config files.
|
|
Handles: mappings, lists of mappings, lists of scalars, inline comments.
|
|
Does NOT handle: anchors, multi-line strings, flow style, merge keys.\"\"\"
|
|
with open(path) as f:
|
|
lines = f.readlines()
|
|
|
|
# Pre-process: strip comments and blank lines, track indent + content
|
|
processed = []
|
|
for raw in lines:
|
|
s = raw.rstrip()
|
|
if not s or s.lstrip().startswith('#'):
|
|
continue
|
|
indent = len(s) - len(s.lstrip())
|
|
content = s.lstrip()
|
|
processed.append((indent, content))
|
|
|
|
def parse_block(idx, base_indent):
|
|
\"\"\"Parse a block at the given indentation level, return (result, next_idx).\"\"\"
|
|
if idx >= len(processed):
|
|
return None, idx
|
|
|
|
indent, content = processed[idx]
|
|
|
|
# Detect if this block is a list or mapping
|
|
if content.startswith('- '):
|
|
return parse_list(idx, indent)
|
|
else:
|
|
return parse_mapping(idx, indent)
|
|
|
|
def parse_mapping(idx, base_indent):
|
|
result = {}
|
|
while idx < len(processed):
|
|
indent, content = processed[idx]
|
|
if indent < base_indent:
|
|
break
|
|
if indent > base_indent:
|
|
break
|
|
if content.startswith('- '):
|
|
break
|
|
|
|
# Must be key: value
|
|
m = re.match(r'^([^:]+?):\s*(.*?)$', content)
|
|
if not m:
|
|
idx += 1
|
|
continue
|
|
|
|
key = m.group(1).strip()
|
|
val_str = m.group(2).strip()
|
|
|
|
# Strip inline comment from value
|
|
if val_str and not val_str.startswith(('\"', \"'\")):
|
|
cm = re.match(r'^(.*?)\s+#.*$', val_str)
|
|
if cm:
|
|
val_str = cm.group(1).rstrip()
|
|
|
|
if val_str and not val_str.startswith('#'):
|
|
result[key] = _parse_value(val_str)
|
|
idx += 1
|
|
else:
|
|
# Value is a nested block
|
|
idx += 1
|
|
if idx < len(processed) and processed[idx][0] > base_indent:
|
|
child_indent = processed[idx][0]
|
|
child, idx = parse_block(idx, child_indent)
|
|
result[key] = child if child is not None else {}
|
|
else:
|
|
result[key] = {}
|
|
|
|
return result, idx
|
|
|
|
def parse_list(idx, base_indent):
|
|
result = []
|
|
while idx < len(processed):
|
|
indent, content = processed[idx]
|
|
if indent < base_indent:
|
|
break
|
|
if indent > base_indent:
|
|
break
|
|
if not content.startswith('- '):
|
|
break
|
|
|
|
item_str = content[2:].strip()
|
|
# Check if it's a mapping item: '- key: value'
|
|
m = re.match(r'^([^:]+?):\s*(.*?)$', item_str)
|
|
if m:
|
|
# It's a mapping -- parse this item and continuation lines
|
|
obj = {}
|
|
key = m.group(1).strip()
|
|
val_str = m.group(2).strip()
|
|
if val_str and not val_str.startswith('#'):
|
|
if not val_str.startswith(('\"', \"'\")):
|
|
cm = re.match(r'^(.*?)\s+#.*$', val_str)
|
|
if cm:
|
|
val_str = cm.group(1).rstrip()
|
|
obj[key] = _parse_value(val_str)
|
|
else:
|
|
idx += 1
|
|
if idx < len(processed) and processed[idx][0] > indent:
|
|
child_indent = processed[idx][0]
|
|
child, idx = parse_block(idx, child_indent)
|
|
obj[key] = child if child is not None else {}
|
|
else:
|
|
obj[key] = {}
|
|
# Parse remaining keys at item indent + 2
|
|
item_indent = indent + 2
|
|
while idx < len(processed) and processed[idx][0] == item_indent:
|
|
ic = processed[idx][1]
|
|
if ic.startswith('- '):
|
|
break
|
|
im = re.match(r'^([^:]+?):\s*(.*?)$', ic)
|
|
if im:
|
|
ik = im.group(1).strip()
|
|
iv = im.group(2).strip()
|
|
if iv and not iv.startswith('#'):
|
|
if not iv.startswith(('\"', \"'\")):
|
|
icm = re.match(r'^(.*?)\s+#.*$', iv)
|
|
if icm:
|
|
iv = icm.group(1).rstrip()
|
|
obj[ik] = _parse_value(iv)
|
|
idx += 1
|
|
else:
|
|
idx += 1
|
|
if idx < len(processed) and processed[idx][0] > item_indent:
|
|
child_indent = processed[idx][0]
|
|
child, idx = parse_block(idx, child_indent)
|
|
obj[ik] = child if child is not None else {}
|
|
else:
|
|
obj[ik] = {}
|
|
else:
|
|
idx += 1
|
|
result.append(obj)
|
|
continue
|
|
|
|
idx += 1
|
|
# Parse remaining keys of this list item
|
|
item_indent = indent + 2
|
|
while idx < len(processed) and processed[idx][0] >= item_indent:
|
|
if processed[idx][0] == item_indent:
|
|
ic = processed[idx][1]
|
|
if ic.startswith('- '):
|
|
break
|
|
im = re.match(r'^([^:]+?):\s*(.*?)$', ic)
|
|
if im:
|
|
ik = im.group(1).strip()
|
|
iv = im.group(2).strip()
|
|
if iv and not iv.startswith('#'):
|
|
if not iv.startswith(('\"', \"'\")):
|
|
icm = re.match(r'^(.*?)\s+#.*$', iv)
|
|
if icm:
|
|
iv = icm.group(1).rstrip()
|
|
obj[ik] = _parse_value(iv)
|
|
idx += 1
|
|
else:
|
|
idx += 1
|
|
if idx < len(processed) and processed[idx][0] > item_indent:
|
|
child_indent = processed[idx][0]
|
|
child, idx = parse_block(idx, child_indent)
|
|
obj[ik] = child if child is not None else {}
|
|
else:
|
|
obj[ik] = {}
|
|
else:
|
|
idx += 1
|
|
else:
|
|
# Deeper nesting handled by recursive calls above
|
|
break
|
|
result.append(obj)
|
|
else:
|
|
# Scalar list item
|
|
result.append(_parse_value(item_str))
|
|
idx += 1
|
|
|
|
return result, idx
|
|
|
|
result, _ = parse_block(0, 0)
|
|
return result if result is not None else {}
|
|
|
|
try:
|
|
import yaml
|
|
with open(sys.argv[1]) as f:
|
|
data = yaml.safe_load(f)
|
|
except ImportError:
|
|
data = parse_yaml_basic(sys.argv[1])
|
|
|
|
json.dump(data, sys.stdout, indent=2)
|
|
" "$CONFIG_FILE" > "$CONFIG_JSON" 2>/dev/null; then
|
|
error "Failed to parse YAML configuration: ${CONFIG_FILE}"
|
|
error "Check that the file is valid YAML"
|
|
error "For complex YAML features, install PyYAML: pip3 install pyyaml"
|
|
exit 1
|
|
fi
|
|
|
|
success "Configuration parsed"
|
|
}
|
|
|
|
# JSON field accessor -- uses jq if available, falls back to python3
|
|
json_get() {
|
|
local file="$1"
|
|
local query="$2"
|
|
local default="${3:-}"
|
|
|
|
local result
|
|
if command -v jq &>/dev/null; then
|
|
result=$(jq -r "$query // empty" "$file" 2>/dev/null) || result=""
|
|
else
|
|
result=$(python3 -c "
|
|
import json, sys
|
|
data = json.load(open(sys.argv[1]))
|
|
path = sys.argv[2]
|
|
# Simple jq-like path traversal for common patterns
|
|
if path.startswith('.'):
|
|
path = path[1:]
|
|
parts = path.replace('[]','').split('.')
|
|
obj = data
|
|
for p in parts:
|
|
if p == '':
|
|
continue
|
|
if isinstance(obj, dict):
|
|
obj = obj.get(p)
|
|
elif isinstance(obj, list) and p.isdigit():
|
|
obj = obj[int(p)]
|
|
else:
|
|
obj = None
|
|
if obj is None:
|
|
break
|
|
if obj is None:
|
|
print('')
|
|
elif isinstance(obj, bool):
|
|
print('true' if obj else 'false')
|
|
elif isinstance(obj, (dict, list)):
|
|
json.dump(obj, sys.stdout)
|
|
else:
|
|
print(obj)
|
|
" "$file" "$query" 2>/dev/null) || result=""
|
|
fi
|
|
|
|
if [[ -z "$result" ]] && [[ -n "$default" ]]; then
|
|
echo "$default"
|
|
else
|
|
echo "$result"
|
|
fi
|
|
}
|
|
|
|
# Get the number of VMs in config
|
|
vm_count() {
|
|
if command -v jq &>/dev/null; then
|
|
jq -r '.vms | length' "$CONFIG_JSON" 2>/dev/null || echo "0"
|
|
else
|
|
python3 -c "
|
|
import json
|
|
data = json.load(open('$CONFIG_JSON'))
|
|
print(len(data.get('vms', [])))
|
|
" 2>/dev/null || echo "0"
|
|
fi
|
|
}
|
|
|
|
# Get a field from a specific VM by index
|
|
vm_get() {
|
|
local index="$1"
|
|
local field="$2"
|
|
local default="${3:-}"
|
|
|
|
local result
|
|
if command -v jq &>/dev/null; then
|
|
result=$(jq -r ".vms[${index}].${field} // empty" "$CONFIG_JSON" 2>/dev/null) || result=""
|
|
else
|
|
result=$(python3 -c "
|
|
import json
|
|
data = json.load(open('$CONFIG_JSON'))
|
|
vm = data.get('vms', [])[${index}]
|
|
val = vm.get('${field}')
|
|
if val is None:
|
|
print('')
|
|
elif isinstance(val, bool):
|
|
print('true' if val else 'false')
|
|
else:
|
|
print(val)
|
|
" 2>/dev/null) || result=""
|
|
fi
|
|
|
|
if [[ -z "$result" ]] && [[ -n "$default" ]]; then
|
|
echo "$default"
|
|
else
|
|
echo "$result"
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Proxmox connection settings
|
|
PVE_HOST=""
|
|
PVE_NODE=""
|
|
PVE_STORAGE=""
|
|
PVE_ISO_STORAGE=""
|
|
PVE_BRIDGE=""
|
|
PVE_METHOD=""
|
|
PVE_SSH_USER=""
|
|
PVE_API_TOKEN_ID=""
|
|
|
|
# Default VM settings
|
|
DEF_CORES=""
|
|
DEF_MEMORY=""
|
|
DEF_DISK=""
|
|
DEF_CHANNEL=""
|
|
DEF_START_VMID=""
|
|
|
|
# VM arrays (indexed by position)
|
|
declare -a VM_NAMES=()
|
|
declare -a VM_APPS=()
|
|
declare -a VM_APPLY_DEFAULTS=()
|
|
declare -a VM_CORES=()
|
|
declare -a VM_MEMORY=()
|
|
declare -a VM_DISK=()
|
|
declare -a VM_VMIDS=()
|
|
declare -a VM_CLUSTERS=()
|
|
declare -a VM_CLUSTER_ROLES=()
|
|
|
|
load_config() {
|
|
step "Loading configuration values"
|
|
|
|
# Proxmox settings
|
|
PVE_HOST=$(json_get "$CONFIG_JSON" ".proxmox.host" "")
|
|
PVE_NODE=$(json_get "$CONFIG_JSON" ".proxmox.node" "pve")
|
|
PVE_STORAGE=$(json_get "$CONFIG_JSON" ".proxmox.storage" "local-lvm")
|
|
PVE_ISO_STORAGE=$(json_get "$CONFIG_JSON" ".proxmox.iso_storage" "local")
|
|
PVE_BRIDGE=$(json_get "$CONFIG_JSON" ".proxmox.bridge" "vmbr0")
|
|
PVE_SSH_USER=$(json_get "$CONFIG_JSON" ".proxmox.ssh_user" "root")
|
|
PVE_API_TOKEN_ID=$(json_get "$CONFIG_JSON" ".proxmox.api_token_id" "")
|
|
|
|
# Method: CLI override > config > default
|
|
if [[ -z "$PVE_METHOD" ]]; then
|
|
PVE_METHOD=$(json_get "$CONFIG_JSON" ".proxmox.method" "ssh")
|
|
fi
|
|
# CLI override for method
|
|
if [[ -n "$METHOD" ]]; then
|
|
PVE_METHOD="$METHOD"
|
|
fi
|
|
|
|
# Host override
|
|
if [[ -n "$HOST_OVERRIDE" ]]; then
|
|
PVE_HOST="$HOST_OVERRIDE"
|
|
fi
|
|
|
|
# Default VM settings
|
|
DEF_CORES=$(json_get "$CONFIG_JSON" ".defaults.cores" "4")
|
|
DEF_MEMORY=$(json_get "$CONFIG_JSON" ".defaults.memory" "8192")
|
|
DEF_DISK=$(json_get "$CONFIG_JSON" ".defaults.disk" "64")
|
|
DEF_CHANNEL=$(json_get "$CONFIG_JSON" ".defaults.channel" "stable")
|
|
DEF_START_VMID=$(json_get "$CONFIG_JSON" ".defaults.start_vmid" "200")
|
|
|
|
# Load VMs
|
|
local count
|
|
count=$(vm_count)
|
|
|
|
local i=0
|
|
while [[ $i -lt $count ]]; do
|
|
VM_NAMES+=("$(vm_get "$i" "name" "")")
|
|
VM_APPS+=("$(vm_get "$i" "app" "incus")")
|
|
VM_APPLY_DEFAULTS+=("$(vm_get "$i" "apply_defaults" "false")")
|
|
VM_CORES+=("$(vm_get "$i" "cores" "$DEF_CORES")")
|
|
VM_MEMORY+=("$(vm_get "$i" "memory" "$DEF_MEMORY")")
|
|
VM_DISK+=("$(vm_get "$i" "disk" "$DEF_DISK")")
|
|
VM_VMIDS+=("$(vm_get "$i" "vmid" "")")
|
|
VM_CLUSTERS+=("$(vm_get "$i" "cluster" "")")
|
|
VM_CLUSTER_ROLES+=("$(vm_get "$i" "cluster_role" "")")
|
|
|
|
i=$((i + 1))
|
|
done
|
|
|
|
success "Loaded ${count} VM definition(s)"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
validate_config() {
|
|
step "Validating configuration"
|
|
|
|
local errors=0
|
|
|
|
# Required: Proxmox host
|
|
if [[ -z "$PVE_HOST" ]]; then
|
|
error "proxmox.host is required"
|
|
errors=$((errors + 1))
|
|
fi
|
|
|
|
# Method validation
|
|
case "$PVE_METHOD" in
|
|
ssh|api) ;;
|
|
*)
|
|
error "Invalid proxmox.method: ${PVE_METHOD} (must be ssh or api)"
|
|
errors=$((errors + 1))
|
|
;;
|
|
esac
|
|
|
|
# API method requires token
|
|
if [[ "$PVE_METHOD" == "api" ]]; then
|
|
if [[ -z "$PVE_API_TOKEN_ID" ]]; then
|
|
error "proxmox.api_token_id is required for API method"
|
|
error "Example: automation@pve!deploy"
|
|
errors=$((errors + 1))
|
|
fi
|
|
if [[ -z "${PROXMOX_TOKEN_SECRET:-}" ]]; then
|
|
error "PROXMOX_TOKEN_SECRET environment variable is required for API method"
|
|
error "Set it with: export PROXMOX_TOKEN_SECRET=\"<your-token-secret>\""
|
|
errors=$((errors + 1))
|
|
fi
|
|
fi
|
|
|
|
# Must have at least one VM
|
|
local count=${#VM_NAMES[@]}
|
|
if [[ $count -eq 0 ]]; then
|
|
error "No VMs defined in configuration"
|
|
errors=$((errors + 1))
|
|
fi
|
|
|
|
# Validate each VM
|
|
local seen_names=""
|
|
local cluster_inits=""
|
|
local i=0
|
|
while [[ $i -lt $count ]]; do
|
|
local name="${VM_NAMES[$i]}"
|
|
local app="${VM_APPS[$i]}"
|
|
local memory="${VM_MEMORY[$i]}"
|
|
local disk="${VM_DISK[$i]}"
|
|
local cluster="${VM_CLUSTERS[$i]}"
|
|
local role="${VM_CLUSTER_ROLES[$i]}"
|
|
|
|
# Name required
|
|
if [[ -z "$name" ]]; then
|
|
error "VM #$((i + 1)): name is required"
|
|
errors=$((errors + 1))
|
|
i=$((i + 1))
|
|
continue
|
|
fi
|
|
|
|
# Unique names
|
|
if echo "$seen_names" | grep -qw "$name"; then
|
|
error "VM '${name}': duplicate VM name"
|
|
errors=$((errors + 1))
|
|
fi
|
|
seen_names="${seen_names} ${name}"
|
|
|
|
# Valid app
|
|
case "$app" in
|
|
incus|operations-center) ;;
|
|
*)
|
|
error "VM '${name}': invalid app '${app}' (must be incus or operations-center)"
|
|
errors=$((errors + 1))
|
|
;;
|
|
esac
|
|
|
|
# Minimum memory
|
|
if [[ "$memory" -lt "$MIN_MEMORY_MIB" ]] 2>/dev/null; then
|
|
error "VM '${name}': memory ${memory} MiB is below minimum ${MIN_MEMORY_MIB} MiB"
|
|
errors=$((errors + 1))
|
|
fi
|
|
|
|
# Minimum disk
|
|
if [[ "$disk" -lt "$MIN_DISK_GIB" ]] 2>/dev/null; then
|
|
error "VM '${name}': disk ${disk} GiB is below minimum ${MIN_DISK_GIB} GiB"
|
|
errors=$((errors + 1))
|
|
fi
|
|
|
|
# Cluster role validation
|
|
if [[ -n "$role" ]] && [[ -z "$cluster" ]]; then
|
|
error "VM '${name}': cluster_role '${role}' set but no cluster name specified"
|
|
errors=$((errors + 1))
|
|
fi
|
|
if [[ -n "$role" ]]; then
|
|
case "$role" in
|
|
init|join) ;;
|
|
*)
|
|
error "VM '${name}': invalid cluster_role '${role}' (must be init or join)"
|
|
errors=$((errors + 1))
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
# Track cluster init nodes
|
|
if [[ -n "$cluster" ]] && [[ "$role" == "init" ]]; then
|
|
if echo "$cluster_inits" | grep -qw "$cluster"; then
|
|
error "Cluster '${cluster}': multiple init nodes defined (only one allowed)"
|
|
errors=$((errors + 1))
|
|
fi
|
|
cluster_inits="${cluster_inits} ${cluster}"
|
|
fi
|
|
|
|
i=$((i + 1))
|
|
done
|
|
|
|
# VM filter validation
|
|
if [[ -n "$VM_FILTER" ]]; then
|
|
local found=false
|
|
for name in "${VM_NAMES[@]}"; do
|
|
if [[ "$name" == "$VM_FILTER" ]]; then
|
|
found=true
|
|
break
|
|
fi
|
|
done
|
|
if [[ "$found" != true ]]; then
|
|
error "VM '${VM_FILTER}' not found in configuration"
|
|
error "Available VMs: ${VM_NAMES[*]}"
|
|
errors=$((errors + 1))
|
|
fi
|
|
fi
|
|
|
|
if [[ $errors -gt 0 ]]; then
|
|
error "${errors} validation error(s) found"
|
|
exit 1
|
|
fi
|
|
|
|
success "Configuration valid"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Proxmox transport layer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Execute a command on the Proxmox host
|
|
pve_run() {
|
|
local cmd="$1"
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
detail "[dry-run] ${PVE_METHOD}: ${cmd}"
|
|
return 0
|
|
fi
|
|
|
|
case "$PVE_METHOD" in
|
|
ssh)
|
|
ssh -o BatchMode=yes -o ConnectTimeout=10 \
|
|
"${PVE_SSH_USER}@${PVE_HOST}" "$cmd"
|
|
;;
|
|
api)
|
|
error "API execution of arbitrary commands is not supported"
|
|
error "Use specific API functions instead"
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Execute a Proxmox API call (API method)
|
|
pve_api() {
|
|
local method="$1"
|
|
local endpoint="$2"
|
|
local data="${3:-}"
|
|
|
|
local url="https://${PVE_HOST}:8006/api2/json${endpoint}"
|
|
local auth="Authorization: PVEAPIToken=${PVE_API_TOKEN_ID}=${PROXMOX_TOKEN_SECRET:-}"
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
detail "[dry-run] API ${method} ${endpoint}"
|
|
if [[ -n "$data" ]]; then
|
|
detail "[dry-run] data: ${data}"
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
local curl_args=(-fsSk -H "$auth")
|
|
|
|
case "$method" in
|
|
GET)
|
|
curl "${curl_args[@]}" "$url"
|
|
;;
|
|
POST)
|
|
if [[ -n "$data" ]]; then
|
|
curl "${curl_args[@]}" -X POST -d "$data" "$url"
|
|
else
|
|
curl "${curl_args[@]}" -X POST "$url"
|
|
fi
|
|
;;
|
|
PUT)
|
|
if [[ -n "$data" ]]; then
|
|
curl "${curl_args[@]}" -X PUT -d "$data" "$url"
|
|
else
|
|
curl "${curl_args[@]}" -X PUT "$url"
|
|
fi
|
|
;;
|
|
DELETE)
|
|
curl "${curl_args[@]}" -X DELETE "$url"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Upload a file to Proxmox ISO storage
|
|
pve_upload() {
|
|
local local_path="$1"
|
|
local remote_filename="$2"
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
detail "[dry-run] upload: ${local_path} -> ${PVE_ISO_STORAGE}:iso/${remote_filename}"
|
|
return 0
|
|
fi
|
|
|
|
case "$PVE_METHOD" in
|
|
ssh)
|
|
local iso_dir
|
|
# Query the actual path of the ISO storage
|
|
iso_dir=$(pve_run "pvesm path ${PVE_ISO_STORAGE}:iso/test 2>/dev/null" | sed 's|/test$||') || true
|
|
if [[ -z "$iso_dir" ]]; then
|
|
# Common default paths
|
|
iso_dir="/var/lib/vz/template/iso"
|
|
fi
|
|
scp -o BatchMode=yes -o ConnectTimeout=10 \
|
|
"$local_path" "${PVE_SSH_USER}@${PVE_HOST}:${iso_dir}/${remote_filename}"
|
|
;;
|
|
api)
|
|
curl -fsSk \
|
|
-H "Authorization: PVEAPIToken=${PVE_API_TOKEN_ID}=${PROXMOX_TOKEN_SECRET:-}" \
|
|
-X POST \
|
|
-F "content=iso" \
|
|
-F "filename=${remote_filename}" \
|
|
-F "file=@${local_path}" \
|
|
"https://${PVE_HOST}:8006/api2/json/nodes/${PVE_NODE}/storage/${PVE_ISO_STORAGE}/upload"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Check if an ISO exists on Proxmox storage
|
|
pve_iso_exists() {
|
|
local filename="$1"
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
return 1
|
|
fi
|
|
|
|
case "$PVE_METHOD" in
|
|
ssh)
|
|
pve_run "pvesm list ${PVE_ISO_STORAGE} --content iso 2>/dev/null" | grep -q "$filename"
|
|
;;
|
|
api)
|
|
pve_api GET "/nodes/${PVE_NODE}/storage/${PVE_ISO_STORAGE}/content?content=iso" | \
|
|
grep -q "$filename"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Get list of existing VMIDs
|
|
pve_list_vmids() {
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
echo ""
|
|
return 0
|
|
fi
|
|
|
|
case "$PVE_METHOD" in
|
|
ssh)
|
|
pve_run "qm list 2>/dev/null" | awk 'NR>1 {print $1}' || true
|
|
;;
|
|
api)
|
|
pve_api GET "/nodes/${PVE_NODE}/qemu" 2>/dev/null | \
|
|
python3 -c "
|
|
import json, sys
|
|
data = json.load(sys.stdin)
|
|
for vm in data.get('data', []):
|
|
print(vm.get('vmid', ''))
|
|
" 2>/dev/null || true
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Create a VM via qm create
|
|
pve_create_vm() {
|
|
local vmid="$1"
|
|
local name="$2"
|
|
local cores="$3"
|
|
local memory="$4"
|
|
local disk_gib="$5"
|
|
local iso_filename="$6"
|
|
local seed_filename="$7"
|
|
|
|
local description
|
|
description=$(build_vm_description)
|
|
|
|
local cmd="qm create ${vmid}"
|
|
cmd+=" --name ${name}"
|
|
cmd+=" --description '${description}'"
|
|
cmd+=" --ostype l26"
|
|
cmd+=" --bios ovmf"
|
|
cmd+=" --machine q35"
|
|
cmd+=" --efidisk0 ${PVE_STORAGE}:0,efitype=4m,pre-enrolled-keys=0"
|
|
cmd+=" --tpmstate0 ${PVE_STORAGE}:1,version=v2.0"
|
|
cmd+=" --cpu host"
|
|
cmd+=" --cores ${cores}"
|
|
cmd+=" --memory ${memory}"
|
|
cmd+=" --balloon 0"
|
|
cmd+=" --scsihw virtio-scsi-pci"
|
|
cmd+=" --scsi0 ${PVE_STORAGE}:${disk_gib},iothread=1"
|
|
cmd+=" --ide2 ${PVE_ISO_STORAGE}:iso/${iso_filename},media=cdrom"
|
|
cmd+=" --ide3 ${PVE_ISO_STORAGE}:iso/${seed_filename},media=cdrom"
|
|
cmd+=" --net0 virtio,bridge=${PVE_BRIDGE}"
|
|
cmd+=" --boot order=ide2\\;scsi0"
|
|
cmd+=" --agent 1"
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
detail "[dry-run] ${cmd}"
|
|
return 0
|
|
fi
|
|
|
|
case "$PVE_METHOD" in
|
|
ssh)
|
|
pve_run "$cmd"
|
|
;;
|
|
api)
|
|
# URL-encode the description for the API
|
|
local url_encoded_desc
|
|
url_encoded_desc=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${description}'))" 2>/dev/null)
|
|
|
|
# Build API parameters
|
|
local params=""
|
|
params+="vmid=${vmid}"
|
|
params+="&name=${name}"
|
|
params+="&description=${url_encoded_desc}"
|
|
params+="&ostype=l26"
|
|
params+="&bios=ovmf"
|
|
params+="&machine=q35"
|
|
params+="&efidisk0=${PVE_STORAGE}:0,efitype=4m,pre-enrolled-keys=0"
|
|
params+="&tpmstate0=${PVE_STORAGE}:1,version=v2.0"
|
|
params+="&cpu=host"
|
|
params+="&cores=${cores}"
|
|
params+="&memory=${memory}"
|
|
params+="&balloon=0"
|
|
params+="&scsihw=virtio-scsi-pci"
|
|
params+="&scsi0=${PVE_STORAGE}:${disk_gib},iothread=1"
|
|
params+="&ide2=${PVE_ISO_STORAGE}:iso/${iso_filename},media=cdrom"
|
|
params+="&ide3=${PVE_ISO_STORAGE}:iso/${seed_filename},media=cdrom"
|
|
params+="&net0=virtio,bridge=${PVE_BRIDGE}"
|
|
params+="&boot=order%3Dide2%3Bscsi0"
|
|
params+="&agent=1"
|
|
|
|
pve_api POST "/nodes/${PVE_NODE}/qemu" "$params"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Start a VM
|
|
pve_start_vm() {
|
|
local vmid="$1"
|
|
|
|
case "$PVE_METHOD" in
|
|
ssh) pve_run "qm start ${vmid}" ;;
|
|
api) pve_api POST "/nodes/${PVE_NODE}/qemu/${vmid}/status/start" ;;
|
|
esac
|
|
}
|
|
|
|
# Stop a VM
|
|
pve_stop_vm() {
|
|
local vmid="$1"
|
|
|
|
case "$PVE_METHOD" in
|
|
ssh) pve_run "qm stop ${vmid}" ;;
|
|
api) pve_api POST "/nodes/${PVE_NODE}/qemu/${vmid}/status/stop" ;;
|
|
esac
|
|
}
|
|
|
|
# Destroy a VM
|
|
pve_destroy_vm() {
|
|
local vmid="$1"
|
|
|
|
case "$PVE_METHOD" in
|
|
ssh) pve_run "qm destroy ${vmid} --purge" ;;
|
|
api) pve_api DELETE "/nodes/${PVE_NODE}/qemu/${vmid}?purge=1" ;;
|
|
esac
|
|
}
|
|
|
|
# Set VM configuration
|
|
pve_set_vm() {
|
|
local vmid="$1"
|
|
shift
|
|
local opts="$*"
|
|
|
|
case "$PVE_METHOD" in
|
|
ssh) pve_run "qm set ${vmid} ${opts}" ;;
|
|
api)
|
|
# Convert CLI-style opts to API params
|
|
local params
|
|
params=$(echo "$opts" | sed 's/--//g; s/ /\&/g; s/\&\([a-z]\)/\&\1/g')
|
|
pve_api PUT "/nodes/${PVE_NODE}/qemu/${vmid}/config" "$params"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Get VM status
|
|
pve_vm_status() {
|
|
local vmid="$1"
|
|
|
|
case "$PVE_METHOD" in
|
|
ssh)
|
|
pve_run "qm status ${vmid}" | awk '{print $2}'
|
|
;;
|
|
api)
|
|
pve_api GET "/nodes/${PVE_NODE}/qemu/${vmid}/status/current" | \
|
|
python3 -c "import json,sys; print(json.load(sys.stdin).get('data',{}).get('status','unknown'))" 2>/dev/null || echo "unknown"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Check if a VMID exists
|
|
pve_vm_exists() {
|
|
local vmid="$1"
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
return 1
|
|
fi
|
|
|
|
case "$PVE_METHOD" in
|
|
ssh)
|
|
pve_run "qm status ${vmid}" &>/dev/null
|
|
;;
|
|
api)
|
|
pve_api GET "/nodes/${PVE_NODE}/qemu/${vmid}/status/current" &>/dev/null
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Get VM's IP address (best-effort via QEMU guest agent)
|
|
pve_vm_ip() {
|
|
local vmid="$1"
|
|
|
|
case "$PVE_METHOD" in
|
|
ssh)
|
|
pve_run "qm guest cmd ${vmid} network-get-interfaces 2>/dev/null" | \
|
|
python3 -c "
|
|
import json, sys
|
|
try:
|
|
data = json.load(sys.stdin)
|
|
for iface in data:
|
|
if iface.get('name') == 'lo':
|
|
continue
|
|
for addr in iface.get('ip-addresses', []):
|
|
if addr.get('ip-address-type') == 'ipv4':
|
|
print(addr['ip-address'])
|
|
sys.exit(0)
|
|
except:
|
|
pass
|
|
" 2>/dev/null || true
|
|
;;
|
|
api)
|
|
pve_api GET "/nodes/${PVE_NODE}/qemu/${vmid}/agent/network-get-interfaces" 2>/dev/null | \
|
|
python3 -c "
|
|
import json, sys
|
|
try:
|
|
data = json.load(sys.stdin).get('data', {}).get('result', [])
|
|
for iface in data:
|
|
if iface.get('name') == 'lo':
|
|
continue
|
|
for addr in iface.get('ip-addresses', []):
|
|
if addr.get('ip-address-type') == 'ipv4':
|
|
print(addr['ip-address'])
|
|
sys.exit(0)
|
|
except:
|
|
pass
|
|
" 2>/dev/null || true
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# VM management marker
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Build the description string for a managed VM
|
|
build_vm_description() {
|
|
local config_basename
|
|
config_basename=$(basename "$CONFIG_FILE")
|
|
local timestamp
|
|
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
echo "${MANAGED_MARKER} config=${config_basename} created=${timestamp} by=${SCRIPT_NAME}/${VERSION}"
|
|
}
|
|
|
|
# Check if a VM has our management marker in its description
|
|
pve_vm_is_managed() {
|
|
local vmid="$1"
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
return 1
|
|
fi
|
|
|
|
local config_output=""
|
|
case "$PVE_METHOD" in
|
|
ssh)
|
|
config_output=$(pve_run "qm config ${vmid}" 2>/dev/null) || return 1
|
|
;;
|
|
api)
|
|
config_output=$(pve_api GET "/nodes/${PVE_NODE}/qemu/${vmid}/config" 2>/dev/null) || return 1
|
|
;;
|
|
esac
|
|
|
|
echo "$config_output" | grep -qF "$MANAGED_MARKER"
|
|
}
|
|
|
|
# List all VMs as "vmid name" pairs (one per line)
|
|
pve_list_vms() {
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
echo ""
|
|
return 0
|
|
fi
|
|
|
|
case "$PVE_METHOD" in
|
|
ssh)
|
|
pve_run "qm list 2>/dev/null" | awk 'NR>1 {print $1, $2}' || true
|
|
;;
|
|
api)
|
|
pve_api GET "/nodes/${PVE_NODE}/qemu" 2>/dev/null | \
|
|
python3 -c "
|
|
import json, sys
|
|
data = json.load(sys.stdin)
|
|
for vm in data.get('data', []):
|
|
print('{} {}'.format(vm.get('vmid', ''), vm.get('name', '')))
|
|
" 2>/dev/null || true
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pre-flight checks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
preflight_checks() {
|
|
step "Running pre-flight checks"
|
|
|
|
local errors=0
|
|
local warnings=0
|
|
|
|
# 1. Check that storage pools exist on Proxmox
|
|
if [[ "$DRY_RUN" != true ]]; then
|
|
case "$PVE_METHOD" in
|
|
ssh)
|
|
if ! pve_run "pvesm status 2>/dev/null" | awk 'NR>1 {print \$1}' | grep -qw "$PVE_STORAGE"; then
|
|
error "Storage pool '${PVE_STORAGE}' not found on Proxmox"
|
|
error "Available pools: $(pve_run "pvesm status 2>/dev/null" | awk 'NR>1 {print \$1}' | tr '\n' ' ')"
|
|
error "Fix: update 'proxmox.storage' in your config file"
|
|
errors=$((errors + 1))
|
|
fi
|
|
if ! pve_run "pvesm status 2>/dev/null" | awk 'NR>1 {print \$1}' | grep -qw "$PVE_ISO_STORAGE"; then
|
|
error "ISO storage '${PVE_ISO_STORAGE}' not found on Proxmox"
|
|
error "Available pools: $(pve_run "pvesm status 2>/dev/null" | awk 'NR>1 {print \$1}' | tr '\n' ' ')"
|
|
error "Fix: update 'proxmox.iso_storage' in your config file"
|
|
errors=$((errors + 1))
|
|
fi
|
|
;;
|
|
api)
|
|
local storage_list
|
|
storage_list=$(pve_api GET "/nodes/${PVE_NODE}/storage" 2>/dev/null | \
|
|
python3 -c "
|
|
import json, sys
|
|
data = json.load(sys.stdin)
|
|
for s in data.get('data', []):
|
|
print(s.get('storage', ''))
|
|
" 2>/dev/null) || storage_list=""
|
|
if ! echo "$storage_list" | grep -qw "$PVE_STORAGE"; then
|
|
error "Storage pool '${PVE_STORAGE}' not found on Proxmox"
|
|
error "Available pools: $(echo "$storage_list" | tr '\n' ' ')"
|
|
error "Fix: update 'proxmox.storage' in your config file"
|
|
errors=$((errors + 1))
|
|
fi
|
|
if ! echo "$storage_list" | grep -qw "$PVE_ISO_STORAGE"; then
|
|
error "ISO storage '${PVE_ISO_STORAGE}' not found on Proxmox"
|
|
error "Available pools: $(echo "$storage_list" | tr '\n' ' ')"
|
|
error "Fix: update 'proxmox.iso_storage' in your config file"
|
|
errors=$((errors + 1))
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
# 2. Network bridge check (SSH only, warn-level)
|
|
if [[ "$PVE_METHOD" == "ssh" ]]; then
|
|
if ! pve_run "ip link show ${PVE_BRIDGE} 2>/dev/null" &>/dev/null; then
|
|
warn "Network bridge '${PVE_BRIDGE}' not found on Proxmox"
|
|
warn "VMs may fail to get network connectivity"
|
|
warn "Fix: update 'proxmox.bridge' in your config file"
|
|
warnings=$((warnings + 1))
|
|
fi
|
|
fi
|
|
|
|
# 3. ISO availability: check if already on Proxmox or CDN reachable
|
|
if [[ -z "$LOCAL_ISO" ]]; then
|
|
local any_iso_present=false
|
|
local iso_list=""
|
|
case "$PVE_METHOD" in
|
|
ssh)
|
|
iso_list=$(pve_run "pvesm list ${PVE_ISO_STORAGE} --content iso 2>/dev/null" 2>/dev/null) || iso_list=""
|
|
;;
|
|
api)
|
|
iso_list=$(pve_api GET "/nodes/${PVE_NODE}/storage/${PVE_ISO_STORAGE}/content?content=iso" 2>/dev/null) || iso_list=""
|
|
;;
|
|
esac
|
|
if echo "$iso_list" | grep -q "IncusOS_"; then
|
|
any_iso_present=true
|
|
fi
|
|
if [[ "$any_iso_present" != true ]]; then
|
|
# No ISO on Proxmox -- check if CDN is reachable
|
|
if ! curl -fsSL --connect-timeout 5 -o /dev/null "$CDN_INDEX" 2>/dev/null; then
|
|
error "No IncusOS ISO found on Proxmox and CDN is unreachable: ${CDN_INDEX}"
|
|
error "Fix: provide a local ISO with --iso FILE, or check internet connectivity"
|
|
errors=$((errors + 1))
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# 4. FAT tools for SEED_DATA generation
|
|
if ! command -v mcopy &>/dev/null; then
|
|
error "mcopy (mtools) is required for SEED_DATA generation but not found"
|
|
error "Install mtools for your platform (e.g. mtools package)"
|
|
errors=$((errors + 1))
|
|
fi
|
|
if ! command -v mkfs.fat &>/dev/null; then
|
|
error "mkfs.fat (dosfstools) is required for SEED_DATA generation but not found"
|
|
error "Install dosfstools for your platform (e.g. dosfstools package)"
|
|
errors=$((errors + 1))
|
|
fi
|
|
|
|
if [[ $errors -gt 0 ]]; then
|
|
error "${errors} pre-flight check(s) failed"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ $warnings -gt 0 ]]; then
|
|
success "Pre-flight checks passed with ${warnings} warning(s)"
|
|
else
|
|
success "Pre-flight checks passed"
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Connectivity check
|
|
# ---------------------------------------------------------------------------
|
|
|
|
check_connectivity() {
|
|
step "Checking Proxmox connectivity (${PVE_METHOD})"
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
detail "[dry-run] Would check connectivity to ${PVE_HOST}"
|
|
success "Connectivity check skipped (dry-run)"
|
|
return 0
|
|
fi
|
|
|
|
case "$PVE_METHOD" in
|
|
ssh)
|
|
if ! ssh -o BatchMode=yes -o ConnectTimeout=10 \
|
|
"${PVE_SSH_USER}@${PVE_HOST}" "pvesh get /version --output-format json" &>/dev/null; then
|
|
error "Cannot connect to Proxmox host via SSH"
|
|
error "Ensure key-based SSH auth is set up:"
|
|
error " ssh-copy-id ${PVE_SSH_USER}@${PVE_HOST}"
|
|
error "Test with: ssh ${PVE_SSH_USER}@${PVE_HOST} pvesh get /version"
|
|
exit 1
|
|
fi
|
|
local pve_version
|
|
pve_version=$(ssh -o BatchMode=yes "${PVE_SSH_USER}@${PVE_HOST}" \
|
|
"pvesh get /version --output-format json" 2>/dev/null | \
|
|
python3 -c "import json,sys; print(json.load(sys.stdin).get('version','unknown'))" 2>/dev/null) || pve_version="unknown"
|
|
success "Connected to ${PVE_HOST} (Proxmox VE ${pve_version})"
|
|
;;
|
|
api)
|
|
local response
|
|
if ! response=$(pve_api GET "/version" 2>/dev/null); then
|
|
error "Cannot connect to Proxmox API at https://${PVE_HOST}:8006"
|
|
error "Check that:"
|
|
error " 1. The host is reachable"
|
|
error " 2. The API token ID is correct: ${PVE_API_TOKEN_ID}"
|
|
error " 3. PROXMOX_TOKEN_SECRET is set"
|
|
exit 1
|
|
fi
|
|
local pve_version
|
|
pve_version=$(echo "$response" | \
|
|
python3 -c "import json,sys; print(json.load(sys.stdin).get('data',{}).get('version','unknown'))" 2>/dev/null) || pve_version="unknown"
|
|
success "Connected to ${PVE_HOST} (Proxmox VE ${pve_version})"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# VMID allocation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
allocate_vmids() {
|
|
step "Allocating VMIDs"
|
|
|
|
# Get existing VMs as "vmid name" pairs
|
|
local vm_list
|
|
vm_list=$(pve_list_vms)
|
|
|
|
# Build lookup: existing IDs and name-to-VMID map
|
|
local existing_ids=""
|
|
declare -A existing_name_to_vmid
|
|
if [[ -n "$vm_list" ]]; then
|
|
while IFS=' ' read -r eid ename; do
|
|
[[ -z "$eid" ]] && continue
|
|
existing_ids="${existing_ids} ${eid}"
|
|
if [[ -n "$ename" ]]; then
|
|
existing_name_to_vmid["$ename"]="$eid"
|
|
fi
|
|
done <<< "$vm_list"
|
|
fi
|
|
|
|
local next_id="$DEF_START_VMID"
|
|
local count=${#VM_NAMES[@]}
|
|
local errors=0
|
|
local i=0
|
|
|
|
while [[ $i -lt $count ]]; do
|
|
local name="${VM_NAMES[$i]}"
|
|
|
|
# Skip if filtered
|
|
if [[ -n "$VM_FILTER" ]] && [[ "$name" != "$VM_FILTER" ]]; then
|
|
i=$((i + 1))
|
|
continue
|
|
fi
|
|
|
|
# Check for name collision with existing Proxmox VMs
|
|
if [[ -n "${existing_name_to_vmid[$name]+x}" ]]; then
|
|
local collision_vmid="${existing_name_to_vmid[$name]}"
|
|
if pve_vm_is_managed "$collision_vmid"; then
|
|
warn "VM '${name}' already exists at VMID ${collision_vmid} (managed by ${SCRIPT_NAME})"
|
|
warn "Use --cleanup to redeploy, or remove it manually"
|
|
# Assign the existing VMID so cleanup/skip works correctly
|
|
VM_VMIDS[$i]="$collision_vmid"
|
|
i=$((i + 1))
|
|
continue
|
|
else
|
|
error "VM name '${name}' already exists at VMID ${collision_vmid} but is NOT managed by ${SCRIPT_NAME}"
|
|
error "This VM was not created by this tool and will not be modified"
|
|
error "Fix: rename the VM in your config file, or manually remove VMID ${collision_vmid}:"
|
|
error " ssh ${PVE_SSH_USER}@${PVE_HOST} qm destroy ${collision_vmid} --purge"
|
|
errors=$((errors + 1))
|
|
i=$((i + 1))
|
|
continue
|
|
fi
|
|
fi
|
|
|
|
if [[ -n "${VM_VMIDS[$i]}" ]]; then
|
|
# Explicit VMID
|
|
detail "VM '${name}': VMID ${VM_VMIDS[$i]} (explicit)"
|
|
else
|
|
# Auto-assign, skipping existing
|
|
while echo "$existing_ids" | grep -qw "$next_id" 2>/dev/null; do
|
|
next_id=$((next_id + 1))
|
|
done
|
|
VM_VMIDS[$i]="$next_id"
|
|
detail "VM '${name}': VMID ${next_id} (auto-assigned)"
|
|
next_id=$((next_id + 1))
|
|
fi
|
|
|
|
i=$((i + 1))
|
|
done
|
|
|
|
if [[ $errors -gt 0 ]]; then
|
|
error "${errors} name collision(s) with unmanaged VMs -- cannot continue"
|
|
exit 1
|
|
fi
|
|
|
|
success "VMIDs allocated"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Certificate detection (reused pattern from sibling scripts)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
CERT_FILE_RESOLVED=""
|
|
|
|
detect_client_cert() {
|
|
if [[ "$NO_CERT" == true ]]; then
|
|
info "Skipping certificate injection (--no-cert)"
|
|
return
|
|
fi
|
|
|
|
if [[ -n "$CERT_FILE" ]]; then
|
|
if [[ ! -f "$CERT_FILE" ]]; then
|
|
error "Certificate file not found: ${CERT_FILE}"
|
|
exit 1
|
|
fi
|
|
if ! grep -q "BEGIN CERTIFICATE" "$CERT_FILE"; then
|
|
error "File does not appear to be a PEM certificate: ${CERT_FILE}"
|
|
exit 1
|
|
fi
|
|
CERT_FILE_RESOLVED="$CERT_FILE"
|
|
success "Certificate: ${CERT_FILE_RESOLVED}"
|
|
return
|
|
fi
|
|
|
|
step "Auto-detecting client certificate"
|
|
|
|
local cert_paths=(
|
|
"${HOME}/.config/incus/client.crt"
|
|
"${HOME}/snap/incus/common/config/client.crt"
|
|
"${HOME}/.config/lxc/client.crt"
|
|
)
|
|
|
|
for path in "${cert_paths[@]}"; do
|
|
if [[ -f "$path" ]] && grep -q "BEGIN CERTIFICATE" "$path"; then
|
|
CERT_FILE_RESOLVED="$path"
|
|
success "Certificate: ${CERT_FILE_RESOLVED}"
|
|
return
|
|
fi
|
|
done
|
|
|
|
# Try CLI fallback
|
|
if command -v incus &>/dev/null; then
|
|
local tmpfile="${WORKDIR}/client.crt"
|
|
if incus remote get-client-certificate > "$tmpfile" 2>/dev/null; then
|
|
CERT_FILE_RESOLVED="$tmpfile"
|
|
success "Certificate obtained via Incus CLI"
|
|
return
|
|
fi
|
|
fi
|
|
|
|
# Check if any VM is ops-center (which requires a cert)
|
|
local needs_cert=false
|
|
for app in "${VM_APPS[@]}"; do
|
|
if [[ "$app" == "operations-center" ]]; then
|
|
needs_cert=true
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [[ "$needs_cert" == true ]]; then
|
|
error "No client certificate found"
|
|
error "Operations Center requires at least one trusted certificate for access"
|
|
error ""
|
|
error "Options:"
|
|
error " --cert FILE Provide a certificate file directly"
|
|
error " Generate one: incus remote list (creates ~/.config/incus/client.crt)"
|
|
error " --no-cert Skip certificate injection (you will be locked out!)"
|
|
exit 1
|
|
fi
|
|
|
|
warn "No client certificate found -- VMs will require manual trust setup"
|
|
warn "Use --cert FILE to provide a certificate, or --no-cert to suppress this warning"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CDN operations (reused from incusos-iso)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
LATEST_VERSION=""
|
|
|
|
fetch_latest_version() {
|
|
local channel="${DEF_CHANNEL:-stable}"
|
|
|
|
step "Fetching latest IncusOS version (channel: ${channel})"
|
|
|
|
local index_file="${WORKDIR}/cdn-index.json"
|
|
|
|
if ! curl -fsSL "$CDN_INDEX" -o "$index_file" 2>/dev/null; then
|
|
error "Failed to fetch CDN index from ${CDN_INDEX}"
|
|
error "Check your internet connection and try again"
|
|
exit 1
|
|
fi
|
|
|
|
if command -v jq &>/dev/null; then
|
|
LATEST_VERSION=$(jq -r \
|
|
--arg ch "$channel" \
|
|
'[.updates[] | select(.channels[] == $ch)] | sort_by(.version) | last | .version // empty' \
|
|
"$index_file")
|
|
else
|
|
LATEST_VERSION=$(python3 -c "
|
|
import json
|
|
data = json.load(open('${index_file}'))
|
|
versions = [u['version'] for u in data.get('updates', []) if '${channel}' in u.get('channels', [])]
|
|
if versions:
|
|
print(sorted(versions)[-1])
|
|
" 2>/dev/null)
|
|
fi
|
|
|
|
if [[ -z "$LATEST_VERSION" ]]; then
|
|
error "No version found for channel '${channel}'"
|
|
exit 1
|
|
fi
|
|
|
|
success "Latest IncusOS version: ${LATEST_VERSION}"
|
|
}
|
|
|
|
download_iso() {
|
|
local version="$1"
|
|
local output_path="$2"
|
|
|
|
local url="${CDN_BASE}/${version}/x86_64/IncusOS_${version}.iso.gz"
|
|
local gz_path="${output_path}.gz"
|
|
|
|
step "Downloading IncusOS ISO"
|
|
detail "URL: ${url}"
|
|
|
|
if ! curl -fL --progress-bar "$url" -o "$gz_path"; then
|
|
error "Failed to download ISO from ${url}"
|
|
rm -f "$gz_path"
|
|
exit 1
|
|
fi
|
|
|
|
step "Decompressing ISO"
|
|
if ! gzip -d "$gz_path"; then
|
|
error "Failed to decompress ISO"
|
|
rm -f "$gz_path"
|
|
exit 1
|
|
fi
|
|
|
|
success "ISO ready: ${output_path}"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Deployment plan summary
|
|
# ---------------------------------------------------------------------------
|
|
|
|
print_plan() {
|
|
local count=${#VM_NAMES[@]}
|
|
|
|
echo ""
|
|
echo -e "${BOLD}Deployment Plan${RESET}"
|
|
echo -e " Proxmox host: ${CYAN}${PVE_HOST}${RESET} (${PVE_METHOD})"
|
|
echo -e " Node: ${PVE_NODE}"
|
|
echo -e " Storage: ${PVE_STORAGE} (disk), ${PVE_ISO_STORAGE} (iso)"
|
|
echo -e " Bridge: ${PVE_BRIDGE}"
|
|
if [[ -n "$CERT_FILE_RESOLVED" ]]; then
|
|
echo -e " Certificate: ${GREEN}${CERT_FILE_RESOLVED}${RESET}"
|
|
elif [[ "$NO_CERT" == true ]]; then
|
|
echo -e " Certificate: ${DIM}none (--no-cert)${RESET}"
|
|
else
|
|
echo -e " Certificate: ${YELLOW}not found${RESET}"
|
|
fi
|
|
echo ""
|
|
echo -e " ${BOLD}VMs (${count}):${RESET}"
|
|
|
|
local i=0
|
|
while [[ $i -lt $count ]]; do
|
|
local name="${VM_NAMES[$i]}"
|
|
local skip=""
|
|
|
|
if [[ -n "$VM_FILTER" ]] && [[ "$name" != "$VM_FILTER" ]]; then
|
|
skip=" ${DIM}(skipped)${RESET}"
|
|
fi
|
|
|
|
echo -e " ${CYAN}${name}${RESET}${skip}"
|
|
echo -e " VMID: ${VM_VMIDS[$i]} App: ${VM_APPS[$i]} Defaults: ${VM_APPLY_DEFAULTS[$i]}"
|
|
echo -e " CPU: ${VM_CORES[$i]} cores RAM: ${VM_MEMORY[$i]} MiB Disk: ${VM_DISK[$i]} GiB"
|
|
|
|
if [[ -n "${VM_CLUSTERS[$i]}" ]]; then
|
|
echo -e " Cluster: ${VM_CLUSTERS[$i]} (${VM_CLUSTER_ROLES[$i]})"
|
|
fi
|
|
|
|
i=$((i + 1))
|
|
done
|
|
echo ""
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Confirmation prompt
|
|
# ---------------------------------------------------------------------------
|
|
|
|
confirm() {
|
|
local message="$1"
|
|
|
|
if [[ "$YES" == true ]] || [[ "$DRY_RUN" == true ]]; then
|
|
return 0
|
|
fi
|
|
|
|
echo -n -e "${BOLD}${message} [y/N] ${RESET}"
|
|
local reply
|
|
read -r reply
|
|
if [[ ! "$reply" =~ ^[Yy]$ ]]; then
|
|
info "Aborted"
|
|
exit 0
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 1: Validate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
phase_validate() {
|
|
step "Phase 1: Validate"
|
|
echo ""
|
|
|
|
parse_config
|
|
load_config
|
|
validate_config
|
|
check_connectivity
|
|
allocate_vmids
|
|
preflight_checks
|
|
detect_client_cert
|
|
print_plan
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 2: Prepare (download ISO + generate seeds)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
ISO_FILENAME=""
|
|
|
|
phase_prepare() {
|
|
step "Phase 2: Prepare"
|
|
echo ""
|
|
|
|
# Determine ISO
|
|
if [[ -n "$LOCAL_ISO" ]]; then
|
|
ISO_FILENAME=$(basename "$LOCAL_ISO")
|
|
info "Using local ISO: ${LOCAL_ISO}"
|
|
if [[ "$DRY_RUN" != true ]]; then
|
|
cp "$LOCAL_ISO" "${WORKDIR}/${ISO_FILENAME}"
|
|
fi
|
|
elif [[ "$DRY_RUN" == true ]]; then
|
|
ISO_FILENAME="IncusOS_latest.iso"
|
|
detail "[dry-run] Would fetch latest version from CDN and download ISO"
|
|
else
|
|
# Check if ISO already exists on Proxmox
|
|
fetch_latest_version
|
|
ISO_FILENAME="IncusOS_${LATEST_VERSION}.iso"
|
|
|
|
if pve_iso_exists "$ISO_FILENAME"; then
|
|
success "ISO already on Proxmox: ${ISO_FILENAME} (reusing from previous deployment)"
|
|
else
|
|
download_iso "$LATEST_VERSION" "${WORKDIR}/${ISO_FILENAME}"
|
|
fi
|
|
fi
|
|
|
|
# Generate per-VM seed images
|
|
step "Generating seed images"
|
|
|
|
local count=${#VM_NAMES[@]}
|
|
local i=0
|
|
while [[ $i -lt $count ]]; do
|
|
local name="${VM_NAMES[$i]}"
|
|
|
|
# Skip if filtered
|
|
if [[ -n "$VM_FILTER" ]] && [[ "$name" != "$VM_FILTER" ]]; then
|
|
i=$((i + 1))
|
|
continue
|
|
fi
|
|
|
|
local app="${VM_APPS[$i]}"
|
|
local apply_defaults="${VM_APPLY_DEFAULTS[$i]}"
|
|
local seed_output="${WORKDIR}/seed-${name}.img"
|
|
|
|
detail "Generating seed for '${name}' (${app}, defaults=${apply_defaults})"
|
|
|
|
# Build incusos-seed command
|
|
local seed_cmd=("$SEED_TOOL")
|
|
seed_cmd+=(--format fat)
|
|
seed_cmd+=(--app "$app")
|
|
seed_cmd+=(--output "$seed_output")
|
|
seed_cmd+=(--disk-target "scsi-*")
|
|
seed_cmd+=(--force-install)
|
|
seed_cmd+=(--force-reboot)
|
|
seed_cmd+=(--hostname "$name")
|
|
seed_cmd+=(--quiet)
|
|
|
|
if [[ "$apply_defaults" == "true" ]]; then
|
|
seed_cmd+=(--defaults)
|
|
fi
|
|
|
|
if [[ -n "$CERT_FILE_RESOLVED" ]]; then
|
|
seed_cmd+=(--cert "$CERT_FILE_RESOLVED")
|
|
elif [[ "$NO_CERT" == true ]]; then
|
|
seed_cmd+=(--no-cert)
|
|
fi
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
detail "[dry-run] ${seed_cmd[*]}"
|
|
else
|
|
if ! "${seed_cmd[@]}"; then
|
|
error "Failed to generate seed for VM '${name}'"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
i=$((i + 1))
|
|
done
|
|
|
|
success "Seed images generated"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 3: Upload
|
|
# ---------------------------------------------------------------------------
|
|
|
|
phase_upload() {
|
|
step "Phase 3: Upload"
|
|
echo ""
|
|
|
|
# Upload ISO (if we downloaded it)
|
|
local iso_path="${WORKDIR}/${ISO_FILENAME}"
|
|
if [[ -f "$iso_path" ]]; then
|
|
if pve_iso_exists "$ISO_FILENAME"; then
|
|
success "ISO already on Proxmox: ${ISO_FILENAME}"
|
|
else
|
|
step "Uploading ISO: ${ISO_FILENAME}"
|
|
if ! pve_upload "$iso_path" "$ISO_FILENAME"; then
|
|
error "Failed to upload ISO to Proxmox"
|
|
exit 1
|
|
fi
|
|
success "ISO uploaded: ${ISO_FILENAME}"
|
|
fi
|
|
else
|
|
detail "ISO already on Proxmox, skipping upload"
|
|
fi
|
|
|
|
# Upload seed images
|
|
local count=${#VM_NAMES[@]}
|
|
local i=0
|
|
while [[ $i -lt $count ]]; do
|
|
local name="${VM_NAMES[$i]}"
|
|
|
|
if [[ -n "$VM_FILTER" ]] && [[ "$name" != "$VM_FILTER" ]]; then
|
|
i=$((i + 1))
|
|
continue
|
|
fi
|
|
|
|
local seed_file="seed-${name}.img"
|
|
local seed_path="${WORKDIR}/${seed_file}"
|
|
|
|
if [[ -f "$seed_path" ]] || [[ "$DRY_RUN" == true ]]; then
|
|
step "Uploading seed: ${seed_file}"
|
|
if ! pve_upload "$seed_path" "$seed_file"; then
|
|
error "Failed to upload seed for VM '${name}'"
|
|
exit 1
|
|
fi
|
|
success "Seed uploaded: ${seed_file}"
|
|
fi
|
|
|
|
i=$((i + 1))
|
|
done
|
|
|
|
success "All files uploaded"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 4: Create VMs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
phase_create() {
|
|
step "Phase 4: Create VMs"
|
|
echo ""
|
|
|
|
local count=${#VM_NAMES[@]}
|
|
local i=0
|
|
while [[ $i -lt $count ]]; do
|
|
local name="${VM_NAMES[$i]}"
|
|
|
|
if [[ -n "$VM_FILTER" ]] && [[ "$name" != "$VM_FILTER" ]]; then
|
|
i=$((i + 1))
|
|
continue
|
|
fi
|
|
|
|
local vmid="${VM_VMIDS[$i]}"
|
|
local cores="${VM_CORES[$i]}"
|
|
local memory="${VM_MEMORY[$i]}"
|
|
local disk="${VM_DISK[$i]}"
|
|
local seed_file="seed-${name}.img"
|
|
|
|
step "Creating VM '${name}' (VMID ${vmid})"
|
|
|
|
# Check if VM already exists
|
|
if pve_vm_exists "$vmid"; then
|
|
if pve_vm_is_managed "$vmid"; then
|
|
warn "VM ${vmid} ('${name}') already exists (managed) -- skipping"
|
|
warn "Use --cleanup to redeploy"
|
|
else
|
|
warn "VM ${vmid} ('${name}') already exists (NOT managed by ${SCRIPT_NAME}) -- skipping"
|
|
warn "Choose a different VMID or remove it manually:"
|
|
warn " ssh ${PVE_SSH_USER}@${PVE_HOST} qm destroy ${vmid} --purge"
|
|
fi
|
|
i=$((i + 1))
|
|
continue
|
|
fi
|
|
|
|
detail "cores=${cores} memory=${memory}MiB disk=${disk}GiB"
|
|
detail "bios=ovmf machine=q35 cpu=host scsihw=virtio-scsi-pci"
|
|
detail "efidisk: pre-enrolled-keys=0, tpm: v2.0, balloon: 0"
|
|
detail "iso=${ISO_FILENAME} seed=${seed_file}"
|
|
|
|
if ! pve_create_vm "$vmid" "$name" "$cores" "$memory" "$disk" \
|
|
"$ISO_FILENAME" "$seed_file"; then
|
|
error "Failed to create VM '${name}' (VMID ${vmid})"
|
|
exit 1
|
|
fi
|
|
|
|
success "VM '${name}' created (VMID ${vmid})"
|
|
|
|
i=$((i + 1))
|
|
done
|
|
|
|
success "All VMs created"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 5: Install
|
|
# ---------------------------------------------------------------------------
|
|
|
|
phase_install() {
|
|
step "Phase 5: Install"
|
|
echo ""
|
|
|
|
local count=${#VM_NAMES[@]}
|
|
local i=0
|
|
while [[ $i -lt $count ]]; do
|
|
local name="${VM_NAMES[$i]}"
|
|
|
|
if [[ -n "$VM_FILTER" ]] && [[ "$name" != "$VM_FILTER" ]]; then
|
|
i=$((i + 1))
|
|
continue
|
|
fi
|
|
|
|
local vmid="${VM_VMIDS[$i]}"
|
|
|
|
step "Starting VM '${name}' (VMID ${vmid})"
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
detail "[dry-run] Would start VM, swap boot order, wait for install, clean up"
|
|
i=$((i + 1))
|
|
continue
|
|
fi
|
|
|
|
# 1. Start VM (boots from ISO, reads SEED_DATA, installs)
|
|
if ! pve_start_vm "$vmid"; then
|
|
error "Failed to start VM '${name}' (VMID ${vmid})"
|
|
i=$((i + 1))
|
|
continue
|
|
fi
|
|
success "VM '${name}' started"
|
|
|
|
# 2. Immediately swap boot order (takes effect on next reboot)
|
|
detail "Swapping boot order to disk-first"
|
|
if ! pve_set_vm "$vmid" "--boot order=scsi0\\;ide2"; then
|
|
warn "Failed to swap boot order for '${name}' -- manual intervention may be needed"
|
|
fi
|
|
|
|
# 3. Wait for installation + reboot cycle
|
|
step "Waiting for '${name}' to complete installation"
|
|
detail "IncusOS installs from ISO, then reboots to disk automatically"
|
|
detail "Polling every 15s (timeout: 10 minutes)"
|
|
|
|
local timeout=600
|
|
local interval=15
|
|
local elapsed=0
|
|
local booted=false
|
|
|
|
# Wait for the VM to go through: running -> (install) -> stopped -> running
|
|
# The force_reboot in seed causes an automatic reboot after install.
|
|
# We detect the reboot by looking for the VM to be "running" after
|
|
# having been "stopped" (or we just wait for it to stabilize).
|
|
local saw_stop=false
|
|
|
|
while [[ $elapsed -lt $timeout ]]; do
|
|
sleep "$interval"
|
|
elapsed=$((elapsed + interval))
|
|
|
|
local status
|
|
status=$(pve_vm_status "$vmid") || status="unknown"
|
|
|
|
case "$status" in
|
|
stopped)
|
|
if [[ "$saw_stop" == false ]]; then
|
|
detail "VM stopped (installation complete, awaiting reboot) [${elapsed}s]"
|
|
saw_stop=true
|
|
# The VM should auto-reboot via force_reboot, but if it
|
|
# doesn't after 30s, start it manually
|
|
sleep 30
|
|
elapsed=$((elapsed + 30))
|
|
local recheck
|
|
recheck=$(pve_vm_status "$vmid") || recheck="unknown"
|
|
if [[ "$recheck" == "stopped" ]]; then
|
|
detail "VM did not auto-reboot, starting manually"
|
|
pve_start_vm "$vmid" || true
|
|
fi
|
|
fi
|
|
;;
|
|
running)
|
|
if [[ "$saw_stop" == true ]] || [[ $elapsed -ge 120 ]]; then
|
|
# VM has been running long enough post-reboot -- likely booted from disk
|
|
booted=true
|
|
break
|
|
fi
|
|
detail "VM running (installing...) [${elapsed}s]"
|
|
;;
|
|
*)
|
|
detail "VM status: ${status} [${elapsed}s]"
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ "$booted" != true ]]; then
|
|
warn "VM '${name}' did not complete installation within ${timeout}s"
|
|
warn "Check the VM console on Proxmox for details"
|
|
i=$((i + 1))
|
|
continue
|
|
fi
|
|
|
|
# 4. Give it a moment to fully initialize
|
|
detail "Waiting for services to start..."
|
|
sleep 15
|
|
|
|
# 5. Remove CD-ROM drives
|
|
step "Cleaning up '${name}'"
|
|
pve_set_vm "$vmid" "--delete ide2 --delete ide3" || true
|
|
|
|
# 6. Try to detect IP
|
|
local ip
|
|
ip=$(pve_vm_ip "$vmid") || ip=""
|
|
|
|
echo ""
|
|
if [[ -n "$ip" ]]; then
|
|
success "VM '${name}' installed and running at ${ip}"
|
|
if [[ -n "$CERT_FILE_RESOLVED" ]]; then
|
|
detail "Add remote: incus remote add ${name} ${ip}"
|
|
fi
|
|
else
|
|
success "VM '${name}' installed and running"
|
|
detail "Check Proxmox console for IP address"
|
|
fi
|
|
echo ""
|
|
|
|
i=$((i + 1))
|
|
done
|
|
|
|
success "Installation phase complete"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cleanup mode
|
|
# ---------------------------------------------------------------------------
|
|
|
|
do_cleanup() {
|
|
step "Cleanup: destroying VMs defined in config"
|
|
if [[ "$FORCE_CLEANUP" == true ]]; then
|
|
warn "Force cleanup enabled -- will destroy VMs even without management marker"
|
|
fi
|
|
echo ""
|
|
|
|
local count=${#VM_NAMES[@]}
|
|
local destroyed=0
|
|
local not_found=0
|
|
local skipped_unmanaged=0
|
|
local i=0
|
|
|
|
while [[ $i -lt $count ]]; do
|
|
local name="${VM_NAMES[$i]}"
|
|
|
|
if [[ -n "$VM_FILTER" ]] && [[ "$name" != "$VM_FILTER" ]]; then
|
|
i=$((i + 1))
|
|
continue
|
|
fi
|
|
|
|
local vmid="${VM_VMIDS[$i]}"
|
|
|
|
# 1. Check if VM exists
|
|
if ! pve_vm_exists "$vmid"; then
|
|
detail "VM '${name}' (VMID ${vmid}): does not exist"
|
|
not_found=$((not_found + 1))
|
|
i=$((i + 1))
|
|
continue
|
|
fi
|
|
|
|
# 2. Check if VM is managed by us
|
|
if [[ "$DRY_RUN" != true ]]; then
|
|
if ! pve_vm_is_managed "$vmid"; then
|
|
if [[ "$FORCE_CLEANUP" == true ]]; then
|
|
warn "VM '${name}' (VMID ${vmid}): NOT managed by ${SCRIPT_NAME} -- destroying anyway (--force-cleanup)"
|
|
else
|
|
warn "VM '${name}' (VMID ${vmid}): NOT managed by ${SCRIPT_NAME} -- refusing to destroy"
|
|
warn " This VM was not created by this tool. To destroy it manually:"
|
|
warn " ssh ${PVE_SSH_USER}@${PVE_HOST} qm stop ${vmid} && ssh ${PVE_SSH_USER}@${PVE_HOST} qm destroy ${vmid} --purge"
|
|
warn " Or use --force-cleanup to override this safety check"
|
|
skipped_unmanaged=$((skipped_unmanaged + 1))
|
|
i=$((i + 1))
|
|
continue
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# 3. Destroy the VM
|
|
step "Destroying VM '${name}' (VMID ${vmid})"
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
detail "[dry-run] Would stop and destroy VM ${vmid}"
|
|
destroyed=$((destroyed + 1))
|
|
i=$((i + 1))
|
|
continue
|
|
fi
|
|
|
|
# Stop if running
|
|
local status
|
|
status=$(pve_vm_status "$vmid") || status="unknown"
|
|
if [[ "$status" == "running" ]]; then
|
|
detail "Stopping VM..."
|
|
pve_stop_vm "$vmid" || true
|
|
sleep 5
|
|
fi
|
|
|
|
# Destroy
|
|
if pve_destroy_vm "$vmid"; then
|
|
success "VM '${name}' (VMID ${vmid}) destroyed"
|
|
destroyed=$((destroyed + 1))
|
|
else
|
|
error "Failed to destroy VM '${name}' (VMID ${vmid})"
|
|
fi
|
|
|
|
i=$((i + 1))
|
|
done
|
|
|
|
# Summary
|
|
echo ""
|
|
local summary="Cleanup complete: ${destroyed} destroyed"
|
|
if [[ $not_found -gt 0 ]]; then
|
|
summary+=", ${not_found} not found"
|
|
fi
|
|
if [[ $skipped_unmanaged -gt 0 ]]; then
|
|
summary+=", ${skipped_unmanaged} skipped (unmanaged)"
|
|
fi
|
|
success "$summary"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
main() {
|
|
setup_colors
|
|
parse_args "$@"
|
|
validate_args
|
|
|
|
if [[ "$QUIET" != true ]]; then
|
|
echo -e "${BOLD}${SCRIPT_NAME}${RESET} v${VERSION}"
|
|
echo ""
|
|
fi
|
|
|
|
setup_workdir
|
|
|
|
if [[ "$DRY_RUN" != true ]]; then
|
|
check_dependencies
|
|
fi
|
|
echo ""
|
|
|
|
# Always run validate first
|
|
phase_validate
|
|
|
|
# Handle cleanup mode
|
|
if [[ "$CLEANUP" == true ]]; then
|
|
confirm "Destroy VMs listed above?"
|
|
do_cleanup
|
|
return
|
|
fi
|
|
|
|
# Confirm before proceeding
|
|
if [[ "$PHASE" == "all" ]]; then
|
|
confirm "Proceed with deployment?"
|
|
fi
|
|
|
|
# Run requested phases
|
|
case "$PHASE" in
|
|
validate)
|
|
# Already done above
|
|
;;
|
|
prepare)
|
|
phase_prepare
|
|
;;
|
|
upload)
|
|
phase_prepare
|
|
phase_upload
|
|
;;
|
|
create)
|
|
phase_prepare
|
|
phase_upload
|
|
phase_create
|
|
;;
|
|
install)
|
|
phase_prepare
|
|
phase_upload
|
|
phase_create
|
|
phase_install
|
|
;;
|
|
all)
|
|
phase_prepare
|
|
echo ""
|
|
phase_upload
|
|
echo ""
|
|
phase_create
|
|
echo ""
|
|
phase_install
|
|
;;
|
|
esac
|
|
|
|
echo ""
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
info "Dry run complete -- no changes were made"
|
|
else
|
|
success "Done"
|
|
fi
|
|
}
|
|
|
|
main "$@"
|