incus-contrib/incusos/test-boot-reliability

915 lines
30 KiB
Bash
Executable File

#!/usr/bin/env bash
# test-boot-reliability - Stress test IncusOS first-boot reliability
#
# Tests whether IncusOS nodes reliably come up after installation by deploying
# multiple single-node VMs and checking port 8443 health.
#
# Three test groups with different seed configurations:
# A) No update.yaml in seed (old behavior, state defaults only)
# B) update.yaml with check_frequency: "6h" (current default)
# C) update.yaml with check_frequency: "1h" (verifies seed takes effect)
#
# Runs batches of VMs in parallel, checks health, cleans up between batches.
# Reports per-group pass/fail rates at the end.
#
# Usage:
# ./test-boot-reliability # 20 deploys in batches of 4
# ./test-boot-reliability --count 12 # 12 deploys
# ./test-boot-reliability --batch-size 2 # 2 parallel per batch
# ./test-boot-reliability --dry-run # Preview without deploying
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SEED_TOOL="${SCRIPT_DIR}/incusos-seed"
# Defaults
TOTAL_COUNT=20
BATCH_SIZE=4
START_VMID=850
DRY_RUN=false
HEALTH_TIMEOUT=150 # seconds to wait for port 8443 after boot
INSTALL_TIMEOUT=600 # seconds to wait for install to complete
# Proxmox connection (from proxmox.yaml)
PVE_HOST=""
PVE_NODE=""
PVE_STORAGE=""
PVE_ISO_STORAGE=""
PVE_BRIDGE=""
PVE_POOL=""
PVE_API_TOKEN_ID=""
# IncusOS ISO (must already be on Proxmox)
ISO_FILENAME=""
# Results directory (each background job writes a file)
RESULTS_DIR=""
# ---------------------------------------------------------------------------
# Colors
# ---------------------------------------------------------------------------
setup_colors() {
if [[ "${NO_COLOR:-}" == "1" ]] || [[ "${TERM:-}" == "dumb" ]]; then
BOLD="" RESET="" RED="" GREEN="" YELLOW="" CYAN="" DIM=""
else
BOLD="\033[1m" RESET="\033[0m" RED="\033[31m" GREEN="\033[32m"
YELLOW="\033[33m" CYAN="\033[36m" DIM="\033[2m"
fi
}
setup_colors
step() { echo -e "${BOLD}[step]${RESET} $*"; }
success() { echo -e "${GREEN}[ok]${RESET} $*"; }
warn() { echo -e "${YELLOW}[warn]${RESET} $*"; }
error() { echo -e "${RED}[error]${RESET} $*"; }
info() { echo -e "${DIM}[info]${RESET} $*"; }
detail() { echo -e "${DIM} $*${RESET}"; }
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--count) TOTAL_COUNT="${2:?'--count requires a number'}"; shift 2 ;;
--batch-size) BATCH_SIZE="${2:?'--batch-size requires a number'}"; shift 2 ;;
--start-vmid) START_VMID="${2:?'--start-vmid requires a number'}"; shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
-h|--help)
echo "Usage: test-boot-reliability [OPTIONS]"
echo ""
echo "Options:"
echo " --count N Total deploys (default: 20)"
echo " --batch-size N Parallel VMs per batch (default: 4)"
echo " --start-vmid N First VMID to use (default: 850)"
echo " --dry-run Preview without deploying"
echo " -h, --help Show this help"
exit 0
;;
*) error "Unknown option: $1"; exit 1 ;;
esac
done
# ---------------------------------------------------------------------------
# Load environment
# ---------------------------------------------------------------------------
# Auto-load env file for PROXMOX_TOKEN_SECRET
load_env() {
local dir="$SCRIPT_DIR"
while [[ "$dir" != "/" ]]; do
if [[ -f "${dir}/env" ]]; then
# shellcheck disable=SC1091
source "${dir}/env"
return
fi
dir="$(dirname "$dir")"
done
}
load_env
if [[ -z "${PROXMOX_TOKEN_SECRET:-}" ]]; then
error "PROXMOX_TOKEN_SECRET not set. Create an env file or export it."
exit 1
fi
# Load proxmox.yaml (simple key: value format, no nested structures)
yaml_get() {
local file="$1" key="$2" default="${3:-}"
local val
val=$(awk -F': ' -v k="$key" '$1 == k {print $2; exit}' "$file" 2>/dev/null) || val=""
# Strip surrounding quotes
val="${val#\"}" ; val="${val%\"}"
val="${val#\'}" ; val="${val%\'}"
echo "${val:-$default}"
}
load_proxmox_yaml() {
local yaml_file=""
if [[ -f "${SCRIPT_DIR}/proxmox.yaml" ]]; then
yaml_file="${SCRIPT_DIR}/proxmox.yaml"
elif [[ -f "proxmox.yaml" ]]; then
yaml_file="proxmox.yaml"
else
error "proxmox.yaml not found"
exit 1
fi
PVE_HOST=$(yaml_get "$yaml_file" "host")
PVE_NODE=$(yaml_get "$yaml_file" "node" "pve")
PVE_STORAGE=$(yaml_get "$yaml_file" "storage" "local-lvm")
PVE_ISO_STORAGE=$(yaml_get "$yaml_file" "iso_storage" "local")
PVE_BRIDGE=$(yaml_get "$yaml_file" "bridge" "vmbr0")
PVE_POOL=$(yaml_get "$yaml_file" "pool")
PVE_API_TOKEN_ID=$(yaml_get "$yaml_file" "api_token_id")
detail "Host: ${PVE_HOST}, Node: ${PVE_NODE}, Storage: ${PVE_STORAGE}"
detail "Pool: ${PVE_POOL}, Token: ${PVE_API_TOKEN_ID}"
}
# ---------------------------------------------------------------------------
# Proxmox API helpers
# ---------------------------------------------------------------------------
pve_api() {
local method="$1"
local endpoint="$2"
local data="${3:-}"
local content_type="${4:-form}"
local url="https://${PVE_HOST}:8006/api2/json${endpoint}"
local auth="Authorization: PVEAPIToken=${PVE_API_TOKEN_ID}=${PROXMOX_TOKEN_SECRET}"
local curl_args=(-fsSk -H "$auth")
if [[ "$content_type" == "json" ]]; then
curl_args+=(-H "Content-Type: application/json")
fi
case "$method" in
GET) curl "${curl_args[@]}" "$url" ;;
POST) curl "${curl_args[@]}" -X POST ${data:+-d "$data"} "$url" ;;
PUT) curl "${curl_args[@]}" -X PUT ${data:+-d "$data"} "$url" ;;
DELETE) curl "${curl_args[@]}" -X DELETE "$url" ;;
esac
}
pve_vm_exists() {
local vmid="$1"
pve_api GET "/nodes/${PVE_NODE}/qemu/${vmid}/status/current" &>/dev/null
}
pve_vm_status() {
local vmid="$1"
pve_api GET "/nodes/${PVE_NODE}/qemu/${vmid}/status/current" 2>/dev/null | \
python3 -c "import json,sys; print(json.load(sys.stdin)['data']['status'])" 2>/dev/null
}
pve_start_vm() {
local vmid="$1"
pve_api POST "/nodes/${PVE_NODE}/qemu/${vmid}/status/start" &>/dev/null
}
pve_stop_vm() {
local vmid="$1"
pve_api POST "/nodes/${PVE_NODE}/qemu/${vmid}/status/stop" &>/dev/null
}
pve_destroy_vm() {
local vmid="$1"
pve_api DELETE "/nodes/${PVE_NODE}/qemu/${vmid}?purge=1&destroy-unreferenced-disks=1" &>/dev/null
}
pve_vm_disk_writes() {
local vmid="$1"
pve_api GET "/nodes/${PVE_NODE}/qemu/${vmid}/status/current" 2>/dev/null | \
python3 -c "
import json,sys
d = json.load(sys.stdin).get('data',{})
bs = d.get('blockstat',{}).get('scsi0',{})
print(bs.get('wr_bytes',0))
" 2>/dev/null || echo "0"
}
pve_vm_mac() {
local vmid="$1"
pve_api GET "/nodes/${PVE_NODE}/qemu/${vmid}/config" 2>/dev/null | \
python3 -c "
import json, sys, re
data = json.load(sys.stdin).get('data', {})
net0 = data.get('net0', '')
m = re.search(r'([0-9A-Fa-f:]{17})', net0)
print(m.group(1).lower() if m else '')
" 2>/dev/null
}
pve_vm_ip() {
# ARP-based IP detection matching incusos-proxmox pve_vm_ip().
# Strategy: flush stale ARP → broadcast ping → check ARP → subnet sweep fallback.
local vmid="$1"
local mac
mac=$(pve_vm_mac "$vmid") || return 1
[[ -z "$mac" ]] && return 1
ip neigh flush nud stale 2>/dev/null || true
local bridge_subnet
bridge_subnet=$(ip -4 addr show | awk '/inet / && !/127\.0\.0/ {print $2; exit}')
# Try broadcast ping first (fast, but many hosts ignore it)
if [[ -n "$bridge_subnet" ]]; then
local broadcast
broadcast=$(python3 -c "import ipaddress; print(ipaddress.ip_interface('${bridge_subnet}').network.broadcast_address)" 2>/dev/null) || true
if [[ -n "$broadcast" ]]; then
ping -c 2 -W 1 -b "$broadcast" &>/dev/null || true
sleep 1
fi
fi
# Check ARP table (filter by state)
local ip
ip=$(ip neigh show | awk -v mac="$mac" 'tolower($0) ~ tolower(mac) && (/REACHABLE/ || /DELAY/ || /STALE/) {print $1; exit}') || true
if [[ -n "$ip" ]] && ping -c 1 -W 1 "$ip" &>/dev/null; then
echo "$ip"
return 0
fi
# Broadcast didn't work — parallel ping sweep of the subnet
if [[ -n "$bridge_subnet" ]]; then
local hosts
hosts=$(python3 -c "
import ipaddress
net = ipaddress.ip_interface('${bridge_subnet}').network
for h in net.hosts():
print(h)
" 2>/dev/null)
if [[ -n "$hosts" ]]; then
while IFS= read -r host_ip; do
ping -c 1 -W 1 "$host_ip" &>/dev/null &
done <<< "$hosts"
wait
sleep 1
ip=$(ip neigh show | awk -v mac="$mac" 'tolower($0) ~ tolower(mac) && (/REACHABLE/ || /DELAY/) {print $1; exit}') || true
if [[ -n "$ip" ]] && ping -c 1 -W 1 "$ip" &>/dev/null; then
echo "$ip"
return 0
fi
fi
fi
return 1
}
# ---------------------------------------------------------------------------
# Find IncusOS ISO already on Proxmox
# ---------------------------------------------------------------------------
find_incusos_iso() {
step "Finding IncusOS ISO on Proxmox"
local iso_list
iso_list=$(pve_api GET "/nodes/${PVE_NODE}/storage/${PVE_ISO_STORAGE}/content?content=iso" 2>/dev/null) || iso_list=""
ISO_FILENAME=$(echo "$iso_list" | python3 -c "
import json, sys
data = json.load(sys.stdin).get('data', [])
isos = [d['volid'].split('/')[-1] for d in data if 'IncusOS_' in d.get('volid', '')]
if isos:
isos.sort()
print(isos[-1])
" 2>/dev/null) || ISO_FILENAME=""
if [[ -z "$ISO_FILENAME" ]]; then
error "No IncusOS ISO found on Proxmox storage '${PVE_ISO_STORAGE}'"
error "Deploy a lab first to upload the ISO, or use incusos-proxmox --phase upload"
exit 1
fi
success "Using ISO: ${ISO_FILENAME}"
}
# ---------------------------------------------------------------------------
# Seed generation per group
# ---------------------------------------------------------------------------
generate_seed() {
local group="$1" # A, B, or C
local name="$2"
local output="$3"
case "$group" in
A)
# No update.yaml -- uses incusos-seed to generate correct format,
# then removes update.yaml from the seed directory before ISO creation.
# This tests whether omitting update.yaml causes boot failures.
local tmpdir
tmpdir=$(mktemp -d)
# Generate full seed via incusos-seed to a tar, extract, remove update.yaml
"$SEED_TOOL" --format tar --app incus --output "${tmpdir}/seed.tar" \
--force-install --force-reboot --hostname "$name" \
--defaults --quiet &>/dev/null
local seeddir="${tmpdir}/seedfiles"
mkdir -p "$seeddir"
tar xf "${tmpdir}/seed.tar" -C "$seeddir"
rm -f "${seeddir}/update.yaml"
# Generate ISO from the modified seed directory
if command -v genisoimage &>/dev/null; then
genisoimage -V SEED_DATA -J -r -o "$output" "$seeddir" 2>/dev/null
elif command -v mkisofs &>/dev/null; then
mkisofs -V SEED_DATA -J -r -o "$output" "$seeddir" 2>/dev/null
fi
rm -rf "$tmpdir"
;;
B)
# update.yaml with check_frequency: "6h" (default/current behavior)
"$SEED_TOOL" --format iso --app incus --output "$output" \
--force-install --force-reboot --hostname "$name" \
--defaults --quiet &>/dev/null
;;
C)
# update.yaml with check_frequency: "1h" (verify seed takes effect)
# Uses incusos-seed to generate correct format, then patches
# update.yaml to use a different check_frequency.
local tmpdir
tmpdir=$(mktemp -d)
# Generate full seed via incusos-seed
"$SEED_TOOL" --format tar --app incus --output "${tmpdir}/seed.tar" \
--force-install --force-reboot --hostname "$name" \
--defaults --quiet &>/dev/null
local seeddir="${tmpdir}/seedfiles"
mkdir -p "$seeddir"
tar xf "${tmpdir}/seed.tar" -C "$seeddir"
# Patch check_frequency to 1h
cat > "${seeddir}/update.yaml" <<'YAML'
# Update configuration
# Patched: check_frequency set to 1h for test group C
channel: "stable"
auto_reboot: false
check_frequency: "1h"
YAML
# Generate ISO from the modified seed directory
if command -v genisoimage &>/dev/null; then
genisoimage -V SEED_DATA -J -r -o "$output" "$seeddir" 2>/dev/null
elif command -v mkisofs &>/dev/null; then
mkisofs -V SEED_DATA -J -r -o "$output" "$seeddir" 2>/dev/null
fi
rm -rf "$tmpdir"
;;
esac
}
# ---------------------------------------------------------------------------
# Upload seed ISO to Proxmox
# ---------------------------------------------------------------------------
upload_seed_iso() {
local local_path="$1"
local filename="$2"
pve_api POST "/nodes/${PVE_NODE}/storage/${PVE_ISO_STORAGE}/upload" \
"" >/dev/null 2>&1 || true
# Upload via curl multipart
local url="https://${PVE_HOST}:8006/api2/json/nodes/${PVE_NODE}/storage/${PVE_ISO_STORAGE}/upload"
local auth="Authorization: PVEAPIToken=${PVE_API_TOKEN_ID}=${PROXMOX_TOKEN_SECRET}"
curl -fsSk -H "$auth" \
-F "content=iso" \
-F "filename=@${local_path};filename=${filename}" \
"$url" &>/dev/null
}
# ---------------------------------------------------------------------------
# Create VM (minimal IncusOS settings)
# ---------------------------------------------------------------------------
create_vm() {
local vmid="$1"
local name="$2"
local seed_iso="$3"
# Proxmox API requires JSON for VM creation -- comma-separated disk/net
# specs are misinterpreted when sent as form-encoded data.
local json_payload
json_payload=$(python3 -c "
import json
payload = {
'vmid': ${vmid},
'name': '${name}',
'description': '[incusos-lab:managed] test-boot-reliability',
'ostype': 'l26',
'bios': 'ovmf',
'machine': 'q35',
'efidisk0': '${PVE_STORAGE}:0,efitype=4m,pre-enrolled-keys=0',
'tpmstate0': '${PVE_STORAGE}:1,version=v2.0',
'cpu': 'host',
'cores': 4,
'memory': 4096,
'balloon': 0,
'scsihw': 'virtio-scsi-pci',
'scsi0': '${PVE_STORAGE}:50,iothread=1',
'ide2': '${PVE_ISO_STORAGE}:iso/${ISO_FILENAME},media=cdrom',
'ide3': '${PVE_ISO_STORAGE}:iso/${seed_iso},media=cdrom',
'net0': 'virtio,bridge=${PVE_BRIDGE}',
'boot': 'order=ide2;scsi0',
'agent': 1,
}
pool = '${PVE_POOL}'
if pool:
payload['pool'] = pool
print(json.dumps(payload))
")
local url="https://${PVE_HOST}:8006/api2/json/nodes/${PVE_NODE}/qemu"
local auth="Authorization: PVEAPIToken=${PVE_API_TOKEN_ID}=${PROXMOX_TOKEN_SECRET}"
local create_response
create_response=$(curl -fsSk -H "$auth" -H "Content-Type: application/json" \
-X POST -d "$json_payload" "$url" 2>/dev/null) || return 1
# VM creation is async -- wait for the UPID task to complete
local upid
upid=$(echo "$create_response" | python3 -c "import json,sys; print(json.load(sys.stdin).get('data',''))" 2>/dev/null) || upid=""
if [[ -n "$upid" ]]; then
local encoded_upid
encoded_upid=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${upid}', safe=''))" 2>/dev/null)
local wait_elapsed=0
while [[ $wait_elapsed -lt 60 ]]; do
sleep 2
wait_elapsed=$((wait_elapsed + 2))
local task_status
task_status=$(pve_api GET "/nodes/${PVE_NODE}/tasks/${encoded_upid}/status" 2>/dev/null | \
python3 -c "import json,sys; print(json.load(sys.stdin).get('data',{}).get('status',''))" 2>/dev/null) || task_status=""
if [[ "$task_status" == "stopped" ]]; then
break
fi
done
fi
}
# ---------------------------------------------------------------------------
# Single VM deploy + health check
# ---------------------------------------------------------------------------
write_result() {
local vmid="$1"
local group="$2"
local outcome="$3"
local detail="$4"
echo "${outcome}|${detail}" > "${RESULTS_DIR}/${vmid}-${group}.result"
}
deploy_and_check() {
local vmid="$1"
local name="$2"
local group="$3"
local seed_iso="$4"
info "[$name] Group ${group}: creating VM (VMID ${vmid})"
if [[ "$DRY_RUN" == true ]]; then
info "[$name] [dry-run] Would create, install, check health"
write_result "$vmid" "$group" "dry-run" "skipped"
return
fi
# Create VM
if ! create_vm "$vmid" "$name" "$seed_iso"; then
error "[$name] Failed to create VM"
write_result "$vmid" "$group" "ERROR" "create failed"
return
fi
# Start VM
if ! pve_start_vm "$vmid"; then
error "[$name] Failed to start VM"
write_result "$vmid" "$group" "ERROR" "start failed"
return
fi
# Monitor installation (blockstat polling)
local elapsed=0
local prev_wr=0
local writes_started=false
local stable_count=0
local install_done=false
while [[ $elapsed -lt $INSTALL_TIMEOUT ]]; do
sleep 5
elapsed=$((elapsed + 5))
local status
status=$(pve_vm_status "$vmid" 2>/dev/null) || status="unknown"
if [[ "$status" == "stopped" ]]; then
install_done=true
break
fi
if [[ "$status" == "running" ]]; then
local wr
wr=$(pve_vm_disk_writes "$vmid" 2>/dev/null) || wr=0
if [[ $wr -gt $prev_wr ]]; then
writes_started=true
stable_count=0
elif [[ "$writes_started" == true ]]; then
stable_count=$((stable_count + 1))
if [[ $stable_count -ge 3 ]]; then
install_done=true
break
fi
fi
prev_wr=$wr
fi
done
if [[ "$install_done" != true ]]; then
warn "[$name] Install timeout after ${INSTALL_TIMEOUT}s"
write_result "$vmid" "$group" "FAIL" "install timeout"
return
fi
# Stop VM if still running
local cur_status
cur_status=$(pve_vm_status "$vmid" 2>/dev/null) || cur_status="unknown"
if [[ "$cur_status" == "running" ]]; then
pve_stop_vm "$vmid" || true
sleep 5
fi
# Remove install media, set boot order (must use JSON to avoid
# form-encoding ambiguity with commas and equals signs)
pve_api PUT "/nodes/${PVE_NODE}/qemu/${vmid}/config" \
'{"delete":"ide2,ide3","boot":"order=scsi0"}' json &>/dev/null || true
# Start from disk
if ! pve_start_vm "$vmid"; then
error "[$name] Failed to start from disk"
write_result "$vmid" "$group" "FAIL" "disk boot failed"
return
fi
# Health check: wait for port 8443
info "[$name] Waiting for port 8443..."
local health_elapsed=0
local healthy=false
# Wait 45s for VM to boot and get DHCP lease.
# Stagger by vmid mod to reduce ARP sweep contention across parallel jobs.
local stagger=$(( (vmid % 4) * 5 ))
sleep $((45 + stagger))
health_elapsed=$((45 + stagger))
# Detect IP with retries
local ip=""
local ip_retry=0
while [[ $ip_retry -lt 6 ]] && [[ -z "$ip" ]]; do
ip=$(pve_vm_ip "$vmid" 2>/dev/null) || ip=""
if [[ -n "$ip" ]]; then
break
fi
sleep 10
health_elapsed=$((health_elapsed + 10))
ip_retry=$((ip_retry + 1))
done
if [[ -z "$ip" ]]; then
warn "[$name] Could not detect IP"
write_result "$vmid" "$group" "FAIL" "no IP detected"
return
fi
# Poll port 8443
while [[ $health_elapsed -lt $HEALTH_TIMEOUT ]]; do
if curl -sk --connect-timeout 3 "https://${ip}:8443" &>/dev/null; then
healthy=true
break
fi
sleep 5
health_elapsed=$((health_elapsed + 5))
done
if [[ "$healthy" == true ]]; then
success "[$name] Group ${group}: PASS — port 8443 up at ${ip} (${health_elapsed}s)"
local result_detail="healthy at ${ip} in ${health_elapsed}s"
# For group C, verify check_frequency via IncusOS API
if [[ "$group" == "C" ]]; then
local freq="?"
local cert_file="${HOME}/.config/incus/client.crt"
local key_file="${HOME}/.config/incus/client.key"
if [[ -f "$cert_file" ]] && [[ -f "$key_file" ]]; then
freq=$(curl -sk --cert "$cert_file" --key "$key_file" \
"https://${ip}:8443/1.0/system/update" 2>/dev/null | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('metadata',d).get('check_frequency','?'))" 2>/dev/null) || freq="?"
fi
detail "[$name] check_frequency from API: ${freq}"
result_detail="healthy at ${ip}, check_frequency=${freq}"
fi
write_result "$vmid" "$group" "PASS" "$result_detail"
else
warn "[$name] Group ${group}: FAIL — port 8443 not reachable at ${ip} after ${HEALTH_TIMEOUT}s"
write_result "$vmid" "$group" "FAIL" "port 8443 unreachable at ${ip}"
fi
}
# ---------------------------------------------------------------------------
# Cleanup a VM
# ---------------------------------------------------------------------------
cleanup_vm() {
local vmid="$1"
local name="$2"
local seed_iso="$3"
if [[ "$DRY_RUN" == true ]]; then
return
fi
# Stop if running
local status
status=$(pve_vm_status "$vmid" 2>/dev/null) || status="unknown"
if [[ "$status" == "running" ]]; then
pve_stop_vm "$vmid" || true
sleep 3
fi
# Destroy
pve_destroy_vm "$vmid" || true
# Remove seed ISO from storage
pve_api DELETE "/nodes/${PVE_NODE}/storage/${PVE_ISO_STORAGE}/content/${PVE_ISO_STORAGE}:iso/${seed_iso}" &>/dev/null || true
# Remove incus remote
if command -v incus &>/dev/null; then
local current_remote
current_remote=$(incus remote get-default 2>/dev/null) || true
if [[ "$current_remote" == "$name" ]]; then
incus remote switch local 2>/dev/null || true
fi
incus remote remove "$name" 2>/dev/null || true
fi
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
echo -e "${BOLD}IncusOS Boot Reliability Test${RESET}"
echo ""
echo "Test design:"
echo " Group A: no update.yaml in seed (state defaults only)"
echo " Group B: update.yaml with check_frequency: \"6h\" (current default)"
echo " Group C: update.yaml with check_frequency: \"1h\" (verify seed effect)"
echo ""
echo "Deploys: ${TOTAL_COUNT} total, ${BATCH_SIZE} parallel per batch"
echo "VMIDs: ${START_VMID}-$((START_VMID + TOTAL_COUNT - 1))"
echo ""
step "Loading configuration"
load_proxmox_yaml
find_incusos_iso
# Assign groups: distribute evenly across A, B, C
# Round-robin: 0→A, 1→B, 2→C, 3→A, 4→B, ...
declare -a VM_GROUPS=()
declare -a VM_NAMES=()
declare -a VM_VMIDS=()
declare -a VM_SEEDS=()
groups=("A" "B" "C")
for ((idx=0; idx<TOTAL_COUNT; idx++)); do
group="${groups[$((idx % 3))]}"
vmid=$((START_VMID + idx))
name="boot-test-${group,,}-${vmid}"
seed_name="seed-${name}.iso"
VM_GROUPS+=("$group")
VM_NAMES+=("$name")
VM_VMIDS+=("$vmid")
VM_SEEDS+=("$seed_name")
done
echo ""
echo -e "${BOLD}Test matrix:${RESET}"
for ((idx=0; idx<TOTAL_COUNT; idx++)); do
echo " VMID ${VM_VMIDS[$idx]}: ${VM_NAMES[$idx]} (Group ${VM_GROUPS[$idx]})"
done
echo ""
# Check for VMID collisions
step "Checking for VMID collisions"
if [[ "$DRY_RUN" != true ]]; then
for ((idx=0; idx<TOTAL_COUNT; idx++)); do
if pve_vm_exists "${VM_VMIDS[$idx]}"; then
error "VMID ${VM_VMIDS[$idx]} already exists. Choose a different --start-vmid range."
exit 1
fi
done
success "No collisions"
fi
# Generate and upload seed ISOs
step "Generating seed ISOs"
SEED_TMPDIR=$(mktemp -d)
RESULTS_DIR=$(mktemp -d)
trap 'rm -rf "$SEED_TMPDIR" "$RESULTS_DIR"' EXIT
for ((idx=0; idx<TOTAL_COUNT; idx++)); do
local_seed="${SEED_TMPDIR}/${VM_SEEDS[$idx]}"
generate_seed "${VM_GROUPS[$idx]}" "${VM_NAMES[$idx]}" "$local_seed"
if [[ "$DRY_RUN" == true ]]; then
info "[dry-run] Would generate seed: ${VM_SEEDS[$idx]} (Group ${VM_GROUPS[$idx]})"
else
upload_seed_iso "$local_seed" "${VM_SEEDS[$idx]}"
detail "Uploaded: ${VM_SEEDS[$idx]} (Group ${VM_GROUPS[$idx]})"
fi
done
success "All seed ISOs generated and uploaded"
# Run batches
echo ""
total_batches=$(( (TOTAL_COUNT + BATCH_SIZE - 1) / BATCH_SIZE ))
batch_num=0
for ((batch_start=0; batch_start<TOTAL_COUNT; batch_start+=BATCH_SIZE)); do
batch_num=$((batch_num + 1))
batch_end=$((batch_start + BATCH_SIZE))
[[ $batch_end -gt $TOTAL_COUNT ]] && batch_end=$TOTAL_COUNT
batch_count=$((batch_end - batch_start))
echo ""
step "Batch ${batch_num}/${total_batches}: deploying ${batch_count} VMs (VMIDs $((START_VMID + batch_start))-$((START_VMID + batch_end - 1)))"
echo ""
# Deploy all VMs in this batch in parallel
pids=()
for ((idx=batch_start; idx<batch_end; idx++)); do
deploy_and_check "${VM_VMIDS[$idx]}" "${VM_NAMES[$idx]}" \
"${VM_GROUPS[$idx]}" "${VM_SEEDS[$idx]}" &
pids+=($!)
done
# Wait for all in this batch to complete
for pid in "${pids[@]}"; do
wait "$pid" || true
done
echo ""
step "Batch ${batch_num} complete — cleaning up"
# Cleanup all VMs in this batch
for ((idx=batch_start; idx<batch_end; idx++)); do
cleanup_vm "${VM_VMIDS[$idx]}" "${VM_NAMES[$idx]}" "${VM_SEEDS[$idx]}"
detail "Cleaned: ${VM_NAMES[$idx]}"
done
success "Batch ${batch_num} cleaned"
done
# ---------------------------------------------------------------------------
# Results summary (read from result files)
# ---------------------------------------------------------------------------
echo ""
echo ""
echo -e "${BOLD}========================================${RESET}"
echo -e "${BOLD} Test Results Summary${RESET}"
echo -e "${BOLD}========================================${RESET}"
echo ""
# Per-group stats
for group in A B C; do
total=0
pass=0
fail=0
err=0
for ((idx=0; idx<TOTAL_COUNT; idx++)); do
if [[ "${VM_GROUPS[$idx]}" == "$group" ]]; then
total=$((total + 1))
rfile="${RESULTS_DIR}/${VM_VMIDS[$idx]}-${group}.result"
if [[ -f "$rfile" ]]; then
outcome=$(cut -d'|' -f1 "$rfile")
case "$outcome" in
PASS) pass=$((pass + 1)) ;;
FAIL) fail=$((fail + 1)) ;;
ERROR) err=$((err + 1)) ;;
esac
else
err=$((err + 1)) # no result file = something went wrong
fi
fi
done
label=""
case "$group" in
A) label="No update.yaml (state defaults)" ;;
B) label="update.yaml check_frequency=6h" ;;
C) label="update.yaml check_frequency=1h" ;;
esac
if [[ $fail -eq 0 ]] && [[ $err -eq 0 ]]; then
echo -e " Group ${group} (${label}):"
echo -e " ${GREEN}${pass}/${total} PASS${RESET}"
else
echo -e " Group ${group} (${label}):"
echo -e " ${GREEN}${pass} PASS${RESET}, ${RED}${fail} FAIL${RESET}, ${RED}${err} ERROR${RESET} (of ${total})"
fi
done
echo ""
echo -e "${BOLD}Per-VM details:${RESET}"
for ((idx=0; idx<TOTAL_COUNT; idx++)); do
rfile="${RESULTS_DIR}/${VM_VMIDS[$idx]}-${VM_GROUPS[$idx]}.result"
outcome="MISSING"
rdetail="no result file"
if [[ -f "$rfile" ]]; then
outcome=$(cut -d'|' -f1 "$rfile")
rdetail=$(cut -d'|' -f2- "$rfile")
fi
marker=""
case "$outcome" in
PASS) marker="${GREEN}PASS${RESET}" ;;
FAIL) marker="${RED}FAIL${RESET}" ;;
ERROR) marker="${RED}ERROR${RESET}" ;;
*) marker="${DIM}${outcome}${RESET}" ;;
esac
echo -e " ${VM_VMIDS[$idx]} ${VM_NAMES[$idx]} Group ${VM_GROUPS[$idx]}: ${marker} -- ${rdetail}"
done
echo ""
# Overall assessment
total_fail=0
for ((idx=0; idx<TOTAL_COUNT; idx++)); do
rfile="${RESULTS_DIR}/${VM_VMIDS[$idx]}-${VM_GROUPS[$idx]}.result"
if [[ -f "$rfile" ]]; then
outcome=$(cut -d'|' -f1 "$rfile")
if [[ "$outcome" == "FAIL" ]] || [[ "$outcome" == "ERROR" ]]; then
total_fail=$((total_fail + 1))
fi
else
total_fail=$((total_fail + 1))
fi
done
if [[ $total_fail -eq 0 ]]; then
success "All ${TOTAL_COUNT} deploys passed — no boot failures detected"
else
warn "${total_fail}/${TOTAL_COUNT} deploys failed"
echo ""
echo "Analysis:"
for group in A B C; do
g_fail=0
g_total=0
for ((idx=0; idx<TOTAL_COUNT; idx++)); do
if [[ "${VM_GROUPS[$idx]}" == "$group" ]]; then
g_total=$((g_total + 1))
rfile="${RESULTS_DIR}/${VM_VMIDS[$idx]}-${group}.result"
outcome=""
[[ -f "$rfile" ]] && outcome=$(cut -d'|' -f1 "$rfile")
if [[ "$outcome" == "FAIL" ]] || [[ "$outcome" == "ERROR" ]] || [[ -z "$outcome" ]]; then
g_fail=$((g_fail + 1))
fi
fi
done
echo " Group ${group}: ${g_fail}/${g_total} failures"
done
fi
echo ""
echo "Done."