incus-contrib/bin/diagnostic/investigate-boot

1007 lines
36 KiB
Bash
Executable File

#!/usr/bin/env bash
# investigate-boot - Systematic IncusOS boot failure investigation
#
# Runs repeated deploy/destroy cycles with observe-deploy-style screenshot
# capture, PLUS probes the IncusOS REST API after each deploy to capture
# state.txt contents. Records structured results for analysis.
#
# Supports different ISO sources to test the version mismatch hypothesis:
# --iso-source cdn Use latest CDN ISO (downloads if not on Proxmox)
# --iso-source old Use IncusOS_202602200553.iso (already on Proxmox)
# --iso-source FILE Use a specific ISO file path
#
# Usage:
# ./investigate-boot --runs 5 --iso-source old --label old-iso
# ./investigate-boot --runs 5 --iso-source cdn --label new-iso
# ./investigate-boot --runs 5 --iso-source /tmp/IncusOS-oc.iso --label oc-iso
# ./investigate-boot --dry-run
set -euo pipefail
readonly VERSION="0.1.0"
readonly SCRIPT_NAME="investigate-boot"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SEED_TOOL="${SCRIPT_DIR}/incusos-seed"
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
VMID=850
VM_NAME="investigate-boot"
RUNS=5
ISO_SOURCE="old" # "cdn", "old", or a file path
LABEL=""
DRY_RUN=false
SCREENSHOT_INTERVAL=2
INSTALL_TIMEOUT=600
BOOT_TIMEOUT=180
INSTALL_POLL=5
STABLE_THRESHOLD=3
APPLY_DEFAULTS=true # true for standalone, false for OC-destined nodes
# Proxmox connection (from proxmox.yaml)
PVE_HOST=""
PVE_NODE=""
PVE_STORAGE=""
PVE_ISO_STORAGE=""
PVE_BRIDGE=""
PVE_POOL=""
PVE_API_TOKEN_ID=""
# ISO tracking
ISO_FILENAME=""
ISO_VERSION=""
ISO_UPLOADED_BY_US=false
# Run output
RESULTS_DIR=""
RUN_DIR=""
LOG_FILE=""
FRAME_NUM=0
# State tracking
PHASE="init"
PREV_WR_BYTES=0
WRITES_STARTED=false
STABLE_COUNT=0
# Results accumulator
declare -a RUN_OUTCOMES=()
declare -a RUN_TIMES=()
declare -a RUN_SCRUB_SCHEDULES=()
declare -a RUN_CHECK_FREQUENCIES=()
declare -a RUN_OS_VERSIONS=()
declare -a RUN_DIRS=()
# ---------------------------------------------------------------------------
# Colors
# ---------------------------------------------------------------------------
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
}
setup_colors
step() { echo -e "${CYAN}[step]${RESET} ${BOLD}$*${RESET}"; }
success() { echo -e "${GREEN}[ok]${RESET} $*"; }
warn() { echo -e "${YELLOW}[warn]${RESET} $*" >&2; }
error() { echo -e "${RED}[error]${RESET} $*" >&2; }
info() { echo -e "${BLUE}[info]${RESET} $*"; }
detail() { echo -e "${DIM} $*${RESET}"; }
log() {
local ts
ts=$(date '+%H:%M:%S.%3N')
local msg="[${ts}] $*"
echo -e "${DIM}${msg}${RESET}"
[[ -n "${LOG_FILE:-}" ]] && echo "$msg" >> "$LOG_FILE"
}
log_quiet() {
local ts
ts=$(date '+%H:%M:%S.%3N')
[[ -n "${LOG_FILE:-}" ]] && echo "[${ts}] $*" >> "$LOG_FILE"
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--vmid) VMID="${2:?'--vmid requires a number'}"; shift 2 ;;
--runs) RUNS="${2:?'--runs requires a number'}"; shift 2 ;;
--iso-source) ISO_SOURCE="${2:?'--iso-source requires cdn|old|path'}"; shift 2 ;;
--label) LABEL="${2:?'--label requires a string'}"; shift 2 ;;
--no-defaults) APPLY_DEFAULTS=false; shift ;;
--interval) SCREENSHOT_INTERVAL="${2:?'--interval requires seconds'}"; shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
-h|--help)
cat <<EOF
${BOLD}${SCRIPT_NAME}${RESET} v${VERSION} - Systematic IncusOS boot failure investigation
${BOLD}USAGE${RESET}
${SCRIPT_NAME} [OPTIONS]
${BOLD}OPTIONS${RESET}
--runs N Number of deploy/destroy cycles (default: 5)
--iso-source SRC ISO source: "cdn" (latest), "old" (202602200553), or file path
--vmid N VMID to use (default: 850, must be 850-869)
--label NAME Label for results directory
--no-defaults Use apply_defaults: false in seed
--interval N Screenshot interval in seconds (default: 2)
--dry-run Preview without deploying
-h, --help Show this help
${BOLD}EXAMPLES${RESET}
${SCRIPT_NAME} --runs 5 --iso-source old --label baseline-old
${SCRIPT_NAME} --runs 5 --iso-source cdn --label baseline-new
${SCRIPT_NAME} --runs 5 --iso-source /tmp/IncusOS-oc.iso --label oc-iso
${BOLD}OUTPUT${RESET}
Results saved to: observe-runs/investigation_<timestamp>_<label>/
run-01/frame-*.png, deploy.log
run-02/...
results.txt (structured results matrix)
summary.txt (human-readable summary)
EOF
exit 0
;;
*) error "Unknown option: $1"; exit 1 ;;
esac
done
# ---------------------------------------------------------------------------
# Load environment
# ---------------------------------------------------------------------------
load_env() {
[[ -n "${PROXMOX_TOKEN_SECRET:-}" ]] && return
# Check envs/*/env in repo root
local repo_root
repo_root=$(cd "$SCRIPT_DIR/../.." && pwd)
for env_file in "${repo_root}"/envs/*/env; do
if [[ -f "$env_file" ]]; then
# shellcheck disable=SC1091
source "$env_file"
[[ -n "${PROXMOX_TOKEN_SECRET:-}" ]] && return
fi
done
# Walk up from script dir (backward compat)
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
if [[ -z "${PROXMOX_ROOT_PASSWORD:-}" ]]; then
error "PROXMOX_ROOT_PASSWORD not set. Add it to the env file."
exit 1
fi
# ---------------------------------------------------------------------------
# Load proxmox.yaml
# ---------------------------------------------------------------------------
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=""
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")
if [[ -z "$PVE_HOST" ]]; then
error "proxmox.yaml: 'host' is required"
exit 1
fi
}
# ---------------------------------------------------------------------------
# Proxmox API helpers
# ---------------------------------------------------------------------------
pve_api() {
local method="$1" endpoint="$2" data="${3:-}" 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")
[[ "$content_type" == "json" ]] && curl_args+=(-H "Content-Type: application/json")
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() { pve_api GET "/nodes/${PVE_NODE}/qemu/$1/status/current" &>/dev/null; }
pve_vm_status() { pve_api GET "/nodes/${PVE_NODE}/qemu/$1/status/current" 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['status'])" 2>/dev/null; }
pve_start_vm() { pve_api POST "/nodes/${PVE_NODE}/qemu/$1/status/start" &>/dev/null; }
pve_stop_vm() { pve_api POST "/nodes/${PVE_NODE}/qemu/$1/status/stop" &>/dev/null; }
pve_destroy_vm() {
local vmid="$1"
local resp
resp=$(pve_api DELETE "/nodes/${PVE_NODE}/qemu/${vmid}?purge=1&destroy-unreferenced-disks=1" 2>/dev/null) || return 1
local upid
upid=$(echo "$resp" | python3 -c "import json,sys; print(json.load(sys.stdin).get('data',''))" 2>/dev/null) || upid=""
if [[ -n "$upid" ]]; then
local enc
enc=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${upid}', safe=''))" 2>/dev/null)
local w=0
while [[ $w -lt 30 ]]; do
sleep 2; w=$((w + 2))
local st
st=$(pve_api GET "/nodes/${PVE_NODE}/tasks/${enc}/status" 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin).get('data',{}).get('status',''))" 2>/dev/null) || st=""
[[ "$st" == "stopped" ]] && break
done
fi
}
pve_vm_disk_writes() {
pve_api GET "/nodes/${PVE_NODE}/qemu/$1/status/current" 2>/dev/null | python3 -c "
import json,sys
d = json.load(sys.stdin).get('data',{})
print(d.get('blockstat',{}).get('scsi0',{}).get('wr_bytes',0))
" 2>/dev/null || echo "0"
}
pve_vm_mac() {
pve_api GET "/nodes/${PVE_NODE}/qemu/$1/config" 2>/dev/null | python3 -c "
import json, sys, re
net0 = json.load(sys.stdin).get('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() {
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}')
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
[[ -n "${broadcast:-}" ]] && { ping -c 2 -W 1 -b "$broadcast" &>/dev/null || true; sleep 1; }
fi
local found_ip
found_ip=$(ip neigh show | awk -v mac="$mac" 'tolower($0) ~ tolower(mac) && (/REACHABLE/ || /DELAY/ || /STALE/) {print $1; exit}') || true
if [[ -n "$found_ip" ]] && ping -c 1 -W 1 "$found_ip" &>/dev/null; then
echo "$found_ip"; return 0
fi
if [[ -n "$bridge_subnet" ]]; then
local hosts
hosts=$(python3 -c "
import ipaddress
for h in ipaddress.ip_interface('${bridge_subnet}').network.hosts():
print(h)
" 2>/dev/null)
if [[ -n "$hosts" ]]; then
while IFS= read -r hip; do ping -c 1 -W 1 "$hip" &>/dev/null & done <<< "$hosts"
wait; sleep 1
found_ip=$(ip neigh show | awk -v mac="$mac" 'tolower($0) ~ tolower(mac) && (/REACHABLE/ || /DELAY/) {print $1; exit}') || true
if [[ -n "$found_ip" ]] && ping -c 1 -W 1 "$found_ip" &>/dev/null; then
echo "$found_ip"; return 0
fi
fi
fi
return 1
}
# ---------------------------------------------------------------------------
# Screenshot capture
# ---------------------------------------------------------------------------
SSH_OPTS=(-o StrictHostKeyChecking=no -o ConnectTimeout=5 -o LogLevel=ERROR)
capture_screenshot() {
local vmid="$1" output_png="$2"
local rppm="/tmp/vm-${vmid}-screen.ppm" lppm="/tmp/vm-${vmid}-screen.ppm"
sshpass -p "$PROXMOX_ROOT_PASSWORD" ssh "${SSH_OPTS[@]}" "root@${PVE_HOST}" \
"echo 'screendump ${rppm}' | qm monitor ${vmid}" &>/dev/null || return 1
sshpass -p "$PROXMOX_ROOT_PASSWORD" scp "${SSH_OPTS[@]}" \
"root@${PVE_HOST}:${rppm}" "$lppm" &>/dev/null || return 1
sshpass -p "$PROXMOX_ROOT_PASSWORD" ssh "${SSH_OPTS[@]}" \
"root@${PVE_HOST}" "rm -f ${rppm}" &>/dev/null || true
python3 -c "from PIL import Image; Image.open('${lppm}').save('${output_png}')" 2>/dev/null || {
mv "$lppm" "${output_png%.png}.ppm" 2>/dev/null || true; return 0
}
rm -f "$lppm"; return 0
}
take_frame() {
FRAME_NUM=$((FRAME_NUM + 1))
local fname
fname=$(printf "frame-%03d.png" "$FRAME_NUM")
if capture_screenshot "$VMID" "${RUN_DIR}/${fname}"; then
log_quiet "Frame ${FRAME_NUM}: ${fname} (phase=${PHASE})"
else
log_quiet "Frame ${FRAME_NUM}: FAILED (phase=${PHASE})"
fi
}
# ---------------------------------------------------------------------------
# ISO management
# ---------------------------------------------------------------------------
resolve_iso() {
step "Resolving ISO source: ${ISO_SOURCE}"
case "$ISO_SOURCE" in
old)
ISO_FILENAME="IncusOS_202602200553.iso"
ISO_VERSION="202602200553"
# Check it exists 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=""
if ! echo "$iso_list" | python3 -c "import json,sys; data=json.load(sys.stdin).get('data',[]); assert any('${ISO_FILENAME}' in d.get('volid','') for d in data)" 2>/dev/null; then
error "ISO ${ISO_FILENAME} not found on Proxmox. Upload it first."
exit 1
fi
success "Using existing ISO: ${ISO_FILENAME} (version ${ISO_VERSION})"
;;
cdn)
# Get latest version from CDN
local index_json
index_json=$(curl -fsSk "https://images.linuxcontainers.org/os/index.json" 2>/dev/null) || {
error "Failed to fetch CDN index"; exit 1
}
ISO_VERSION=$(echo "$index_json" | python3 -c "
import json, sys
data = json.load(sys.stdin)
versions = sorted(data.get('versions', {}).keys(), reverse=True)
print(versions[0] if versions else '')
" 2>/dev/null)
[[ -z "$ISO_VERSION" ]] && { error "Could not determine latest CDN version"; exit 1; }
ISO_FILENAME="IncusOS_${ISO_VERSION}.iso"
info "Latest CDN version: ${ISO_VERSION}"
# Check if already 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=""
if echo "$iso_list" | python3 -c "import json,sys; data=json.load(sys.stdin).get('data',[]); assert any('${ISO_FILENAME}' in d.get('volid','') for d in data)" 2>/dev/null; then
success "ISO already on Proxmox: ${ISO_FILENAME}"
else
step "Downloading ISO from CDN"
local dl_url="https://images.linuxcontainers.org/os/${ISO_VERSION}/x86_64/IncusOS_${ISO_VERSION}.iso.gz"
local tmp_gz="/tmp/${ISO_FILENAME}.gz"
local tmp_iso="/tmp/${ISO_FILENAME}"
curl -fSk -o "$tmp_gz" "$dl_url" 2>&1 | tail -1 || {
error "Failed to download ${dl_url}"; exit 1
}
info "Decompressing..."
gunzip -f "$tmp_gz"
step "Uploading to Proxmox"
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=@${tmp_iso};filename=${ISO_FILENAME}" "$url" &>/dev/null || {
error "Failed to upload ISO"; exit 1
}
rm -f "$tmp_iso"
ISO_UPLOADED_BY_US=true
success "Uploaded: ${ISO_FILENAME}"
fi
;;
*)
# File path
if [[ ! -f "$ISO_SOURCE" ]]; then
error "ISO file not found: ${ISO_SOURCE}"; exit 1
fi
local basename
basename=$(basename "$ISO_SOURCE")
ISO_FILENAME="$basename"
ISO_VERSION=$(echo "$basename" | sed -n 's/.*_\([0-9]*\)\..*/\1/p')
[[ -z "$ISO_VERSION" ]] && ISO_VERSION="custom"
# Upload to Proxmox
step "Uploading custom ISO: ${basename}"
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=@${ISO_SOURCE};filename=${basename}" "$url" &>/dev/null || {
error "Failed to upload ISO"; exit 1
}
ISO_UPLOADED_BY_US=true
success "Using custom ISO: ${ISO_FILENAME} (version ${ISO_VERSION})"
;;
esac
}
# ---------------------------------------------------------------------------
# Seed generation
# ---------------------------------------------------------------------------
SEED_ISO=""
SEED_TMPDIR=""
generate_seed() {
step "Generating seed ISO"
SEED_TMPDIR=$(mktemp -d)
local seed_name="seed-${VM_NAME}.iso"
SEED_ISO="$seed_name"
local local_path="${SEED_TMPDIR}/${seed_name}"
local defaults_flag=""
if [[ "$APPLY_DEFAULTS" == true ]]; then
defaults_flag="--defaults"
else
defaults_flag="--no-defaults"
fi
"$SEED_TOOL" --format iso --app incus --output "$local_path" \
--force-install --force-reboot --hostname "$VM_NAME" \
$defaults_flag --quiet &>/dev/null
success "Seed ISO: ${seed_name} (apply_defaults=${APPLY_DEFAULTS})"
step "Uploading seed ISO"
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=${seed_name}" "$url" &>/dev/null
success "Seed uploaded"
}
# ---------------------------------------------------------------------------
# VM lifecycle
# ---------------------------------------------------------------------------
create_vm() {
local json_payload
json_payload=$(python3 -c "
import json
payload = {
'vmid': ${VMID},
'name': '${VM_NAME}',
'description': '[incusos-lab:managed] investigate-boot',
'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 resp
resp=$(curl -fsSk -H "$auth" -H "Content-Type: application/json" \
-X POST -d "$json_payload" "$url" 2>/dev/null) || { error "Failed to create VM"; return 1; }
# Wait for async task
local upid
upid=$(echo "$resp" | python3 -c "import json,sys; print(json.load(sys.stdin).get('data',''))" 2>/dev/null) || upid=""
if [[ -n "$upid" ]]; then
local enc
enc=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${upid}', safe=''))" 2>/dev/null)
local w=0
while [[ $w -lt 60 ]]; do
sleep 2; w=$((w + 2))
local st
st=$(pve_api GET "/nodes/${PVE_NODE}/tasks/${enc}/status" 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin).get('data',{}).get('status',''))" 2>/dev/null) || st=""
[[ "$st" == "stopped" ]] && break
done
fi
}
cleanup_vm() {
local status
status=$(pve_vm_status "$VMID" 2>/dev/null) || status="unknown"
[[ "$status" == "running" ]] && { pve_stop_vm "$VMID" || true; sleep 3; }
pve_destroy_vm "$VMID" || true
pve_api DELETE "/nodes/${PVE_NODE}/storage/${PVE_ISO_STORAGE}/content/${PVE_ISO_STORAGE}:iso/${SEED_ISO}" &>/dev/null || true
if command -v incus &>/dev/null; then
local cur
cur=$(incus remote get-default 2>/dev/null) || true
[[ "$cur" == "$VM_NAME" ]] && { incus remote switch local 2>/dev/null || true; }
incus remote remove "$VM_NAME" 2>/dev/null || true
fi
rm -rf "${SEED_TMPDIR:-}" 2>/dev/null || true
rm -f "/tmp/vm-${VMID}-screen.ppm" 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# IncusOS API probing
# ---------------------------------------------------------------------------
probe_incusos_api() {
# Probe the IncusOS REST API via the Incus /os/ proxy.
# Requires that the VM has been added as an incus remote.
local ip="$1"
local probe_file="${RUN_DIR}/api-probe.json"
local cert_file="${HOME}/.config/incus/client.crt"
local key_file="${HOME}/.config/incus/client.key"
if [[ ! -f "$cert_file" ]] || [[ ! -f "$key_file" ]]; then
log "API probe: no client cert available"
echo '{"error": "no client cert"}' > "$probe_file"
return
fi
log "Probing IncusOS API at ${ip}..."
# Add as temporary incus remote
incus remote add "$VM_NAME" "https://${ip}:8443" --accept-certificate 2>/dev/null || true
# Probe storage config (scrub_schedule)
local storage_json
storage_json=$(incus query "${VM_NAME}:/os/1.0/system/storage" 2>/dev/null) || storage_json='{"error":"query failed"}'
# Probe update config (check_frequency)
local update_json
update_json=$(incus query "${VM_NAME}:/os/1.0/system/update" 2>/dev/null) || update_json='{"error":"query failed"}'
# Probe system info (os_version, hostname, uptime)
local system_json
system_json=$(incus query "${VM_NAME}:/os/1.0" 2>/dev/null) || system_json='{"error":"query failed"}'
# Write combined probe results
python3 -c "
import json, sys
storage = json.loads('''${storage_json}''')
update = json.loads('''${update_json}''')
system = json.loads('''${system_json}''')
result = {
'storage': storage,
'update': update,
'system': system,
'scrub_schedule': storage.get('config', {}).get('scrub_schedule', ''),
'check_frequency': update.get('config', {}).get('check_frequency', ''),
'os_version': system.get('environment', {}).get('os_version', ''),
'hostname': system.get('environment', {}).get('hostname', ''),
'uptime': system.get('environment', {}).get('uptime', 0),
}
print(json.dumps(result, indent=2))
" > "$probe_file" 2>/dev/null || echo '{"error":"probe parse failed"}' > "$probe_file"
# Extract key values for summary
local scrub check_freq os_ver
scrub=$(python3 -c "import json; d=json.load(open('${probe_file}')); print(d.get('scrub_schedule',''))" 2>/dev/null) || scrub=""
check_freq=$(python3 -c "import json; d=json.load(open('${probe_file}')); print(d.get('check_frequency',''))" 2>/dev/null) || check_freq=""
os_ver=$(python3 -c "import json; d=json.load(open('${probe_file}')); print(d.get('os_version',''))" 2>/dev/null) || os_ver=""
log "API probe results:"
log " scrub_schedule: '${scrub}'"
log " check_frequency: '${check_freq}'"
log " os_version: '${os_ver}'"
# Store for summary
RUN_SCRUB_SCHEDULES+=("$scrub")
RUN_CHECK_FREQUENCIES+=("$check_freq")
RUN_OS_VERSIONS+=("$os_ver")
}
# ---------------------------------------------------------------------------
# Install monitoring
# ---------------------------------------------------------------------------
check_install_progress() {
local wr_bytes
wr_bytes=$(pve_vm_disk_writes "$VMID" 2>/dev/null) || wr_bytes=0
if [[ $wr_bytes -gt $PREV_WR_BYTES ]]; then
if [[ "$WRITES_STARTED" != true ]]; then
WRITES_STARTED=true
PHASE="installing"
log "Install started — disk writes detected"
fi
STABLE_COUNT=0
elif [[ "$WRITES_STARTED" == true ]]; then
STABLE_COUNT=$((STABLE_COUNT + 1))
if [[ $STABLE_COUNT -ge $STABLE_THRESHOLD ]]; then
local wr_mb=$(( wr_bytes / 1048576 ))
log "Install complete — ${wr_mb} MiB written, ${STABLE_COUNT} stable polls"
PHASE="install_done"
fi
fi
PREV_WR_BYTES=$wr_bytes
}
# ---------------------------------------------------------------------------
# Single deploy cycle
# ---------------------------------------------------------------------------
run_single_deploy() {
local run_num="$1"
local deploy_start
deploy_start=$(date +%s)
# Reset state
PHASE="init"
PREV_WR_BYTES=0
WRITES_STARTED=false
STABLE_COUNT=0
FRAME_NUM=0
# Setup run directory
RUN_DIR="${RESULTS_DIR}/run-$(printf '%02d' "$run_num")"
mkdir -p "$RUN_DIR"
LOG_FILE="${RUN_DIR}/deploy.log"
touch "$LOG_FILE"
log "=== Run ${run_num}/${RUNS} ==="
log "ISO: ${ISO_FILENAME} (version ${ISO_VERSION})"
log "apply_defaults: ${APPLY_DEFAULTS}"
# Generate seed (fresh each time)
generate_seed
# Create VM
step "Run ${run_num}: Creating VM (VMID ${VMID})"
create_vm || { error "Failed to create VM"; return 1; }
success "VM created"
# --- Phase 1: Boot from ISO ---
step "Run ${run_num}: Starting VM (ISO boot)"
pve_start_vm "$VMID"
PHASE="iso_boot"
log "VM started — phase: iso_boot"
local elapsed=0
local last_bs_check=0
while [[ "$PHASE" == "iso_boot" ]] || [[ "$PHASE" == "installing" ]]; do
take_frame || true
local now; now=$(date +%s)
if [[ $((now - last_bs_check)) -ge $INSTALL_POLL ]]; then
check_install_progress
last_bs_check=$now
fi
sleep "$SCREENSHOT_INTERVAL"
elapsed=$((elapsed + SCREENSHOT_INTERVAL))
local status; status=$(pve_vm_status "$VMID" 2>/dev/null) || status="unknown"
[[ "$status" == "stopped" ]] && { log "VM stopped during install"; PHASE="install_done"; }
if [[ $elapsed -ge $INSTALL_TIMEOUT ]]; then
warn "Install timeout after ${INSTALL_TIMEOUT}s"
take_frame || true
PHASE="failed"
break
fi
if [[ $((elapsed % 30)) -lt $SCREENSHOT_INTERVAL ]]; then
local wr_mb=$(( PREV_WR_BYTES / 1048576 ))
log "Progress: ${elapsed}s, ${wr_mb} MiB, phase=${PHASE}"
fi
done
[[ "$PHASE" == "failed" ]] && return 1
# --- Phase 2: Transition ---
PHASE="transitioning"
take_frame || true
step "Run ${run_num}: Stopping VM, removing media"
pve_stop_vm "$VMID" || true
local sw=0
while [[ $sw -lt 30 ]]; do
local st; st=$(pve_vm_status "$VMID" 2>/dev/null) || st="unknown"
[[ "$st" == "stopped" ]] && break
sleep 2; sw=$((sw + 2))
done
pve_api PUT "/nodes/${PVE_NODE}/qemu/${VMID}/config" \
'{"delete":"ide2,ide3","boot":"order=scsi0"}' json &>/dev/null || {
error "Failed to remove install media"; PHASE="failed"; return 1
}
log "Media removed, boot=scsi0"
# --- Phase 3: Boot from disk ---
step "Run ${run_num}: Starting from disk"
pve_start_vm "$VMID"
PHASE="disk_boot"
log "Disk boot started"
local boot_elapsed=0
local ip_detected=""
local ip_started=false
while [[ "$PHASE" == "disk_boot" ]]; do
take_frame || true
sleep "$SCREENSHOT_INTERVAL"
boot_elapsed=$((boot_elapsed + SCREENSHOT_INTERVAL))
if [[ $boot_elapsed -ge 30 ]] && [[ "$ip_started" != true ]]; then
ip_started=true
log "Starting IP detection"
fi
if [[ "$ip_started" == true ]] && [[ $((boot_elapsed % 10)) -lt $SCREENSHOT_INTERVAL ]]; then
local ip
ip=$(pve_vm_ip "$VMID" 2>/dev/null) || ip=""
if [[ -n "$ip" ]]; then
[[ -z "$ip_detected" ]] && { ip_detected="$ip"; log "IP: ${ip}"; }
if curl -sk --connect-timeout 3 "https://${ip}:8443" &>/dev/null; then
log "Port 8443 UP at ${ip} (${boot_elapsed}s)"
PHASE="ready"
break
fi
fi
fi
if [[ $boot_elapsed -ge $BOOT_TIMEOUT ]]; then
warn "Boot timeout after ${BOOT_TIMEOUT}s"
take_frame || true
PHASE="failed"
break
fi
if [[ $((boot_elapsed % 15)) -lt $SCREENSHOT_INTERVAL ]]; then
log "Disk boot: ${boot_elapsed}s, ip=${ip_detected:-none}, phase=${PHASE}"
fi
done
# Take final frames
if [[ "$PHASE" == "ready" ]]; then
for _ in 1 2 3; do sleep "$SCREENSHOT_INTERVAL"; take_frame || true; done
fi
local deploy_end; deploy_end=$(date +%s)
local total=$((deploy_end - deploy_start))
log "Result: phase=${PHASE}, total=${total}s, frames=${FRAME_NUM}"
# --- Phase 4: API probing ---
if [[ "$PHASE" == "ready" ]] && [[ -n "$ip_detected" ]]; then
probe_incusos_api "$ip_detected"
else
RUN_SCRUB_SCHEDULES+=("N/A")
RUN_CHECK_FREQUENCIES+=("N/A")
RUN_OS_VERSIONS+=("N/A")
fi
RUN_TIMES+=("$total")
RUN_DIRS+=("$RUN_DIR")
if [[ "$PHASE" == "ready" ]]; then
RUN_OUTCOMES+=("PASS")
return 0
else
RUN_OUTCOMES+=("FAIL")
return 1
fi
}
# ---------------------------------------------------------------------------
# Preflight
# ---------------------------------------------------------------------------
preflight() {
step "Preflight checks"
local missing=()
command -v sshpass &>/dev/null || missing+=("sshpass")
command -v python3 &>/dev/null || missing+=("python3")
command -v curl &>/dev/null || missing+=("curl")
[[ ${#missing[@]} -gt 0 ]] && { error "Missing: ${missing[*]}"; exit 1; }
python3 -c "from PIL import Image" &>/dev/null || warn "PIL not available — PPM screenshots"
if ! sshpass -p "$PROXMOX_ROOT_PASSWORD" ssh "${SSH_OPTS[@]}" "root@${PVE_HOST}" "echo ok" &>/dev/null; then
error "Cannot SSH to root@${PVE_HOST}"; exit 1
fi
success "SSH OK"
if [[ $VMID -lt 850 ]] || [[ $VMID -gt 869 ]]; then
error "VMID ${VMID} outside test range (850-869)"; exit 1
fi
success "VMID ${VMID} in range"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
echo -e "${BOLD}${SCRIPT_NAME}${RESET} v${VERSION}"
echo "Systematic IncusOS boot failure investigation"
echo ""
if [[ -z "$LABEL" ]]; then
case "$ISO_SOURCE" in
cdn) LABEL="cdn-latest" ;;
old) LABEL="old-202602200553" ;;
*) LABEL="custom-iso" ;;
esac
fi
if [[ "$DRY_RUN" == true ]]; then
echo -e "${YELLOW}[dry-run] Preview mode${RESET}"
echo ""
echo "Configuration:"
echo " Runs: ${RUNS}"
echo " ISO source: ${ISO_SOURCE}"
echo " VMID: ${VMID}"
echo " Label: ${LABEL}"
echo " apply_defaults: ${APPLY_DEFAULTS}"
echo " Screenshots: every ${SCREENSHOT_INTERVAL}s"
echo ""
load_proxmox_yaml
detail "Host: ${PVE_HOST}, Node: ${PVE_NODE}"
echo ""
exit 0
fi
# Setup
step "Loading configuration"
load_proxmox_yaml
detail "Host: ${PVE_HOST}, Node: ${PVE_NODE}, Storage: ${PVE_STORAGE}"
preflight
resolve_iso
# Setup results directory
RESULTS_DIR="${SCRIPT_DIR}/observe-runs/investigation_$(date '+%Y-%m-%d_%H-%M-%S')_${LABEL}"
mkdir -p "$RESULTS_DIR"
success "Results: ${RESULTS_DIR}/"
echo ""
echo -e "${BOLD}Investigation plan:${RESET}"
echo " Runs: ${RUNS}"
echo " ISO: ${ISO_FILENAME} (version ${ISO_VERSION})"
echo " apply_defaults: ${APPLY_DEFAULTS}"
echo " VMID: ${VMID}"
echo ""
# Trap for cleanup
trap_cleanup() {
echo ""
warn "Interrupted — cleaning up"
cleanup_vm 2>/dev/null || true
exit 130
}
trap trap_cleanup INT TERM
# Run the investigation cycles
for ((run=1; run<=RUNS; run++)); do
echo ""
echo -e "${BOLD}════════════════════════════════════════${RESET}"
echo -e "${BOLD} Run ${run}/${RUNS}${RESET}"
echo -e "${BOLD}════════════════════════════════════════${RESET}"
echo ""
# Ensure VMID is free
if pve_vm_exists "$VMID"; then
warn "VMID ${VMID} exists — cleaning up from previous run"
cleanup_vm
sleep 5
fi
run_result=0
run_single_deploy "$run" || run_result=$?
echo ""
if [[ $run_result -eq 0 ]]; then
echo -e " Result: ${GREEN}PASS${RESET}"
else
echo -e " Result: ${RED}FAIL${RESET} (phase: ${PHASE})"
fi
# Cleanup
step "Cleanup run ${run}"
cleanup_vm
sleep 3 # Brief pause between runs
done
# ---------------------------------------------------------------------------
# Results summary
# ---------------------------------------------------------------------------
echo ""
echo ""
echo -e "${BOLD}╔══════════════════════════════════════════╗${RESET}"
echo -e "${BOLD}║ Investigation Results Summary ║${RESET}"
echo -e "${BOLD}╚══════════════════════════════════════════╝${RESET}"
echo ""
echo "ISO: ${ISO_FILENAME} (version ${ISO_VERSION})"
echo "ISO source: ${ISO_SOURCE}"
echo "apply_defaults: ${APPLY_DEFAULTS}"
echo "Runs: ${RUNS}"
echo ""
pass_count=0
fail_count=0
for ((i=0; i<${#RUN_OUTCOMES[@]}; i++)); do
local_run=$((i + 1))
outcome="${RUN_OUTCOMES[$i]}"
time_s="${RUN_TIMES[$i]:-?}"
scrub="${RUN_SCRUB_SCHEDULES[$i]:-?}"
freq="${RUN_CHECK_FREQUENCIES[$i]:-?}"
osver="${RUN_OS_VERSIONS[$i]:-?}"
marker=""
case "$outcome" in
PASS) marker="${GREEN}PASS${RESET}"; pass_count=$((pass_count + 1)) ;;
FAIL) marker="${RED}FAIL${RESET}"; fail_count=$((fail_count + 1)) ;;
esac
echo -e " Run $(printf '%2d' $local_run): ${marker} time=${time_s}s scrub='${scrub}' freq='${freq}' os=${osver}"
done
echo ""
echo -e " Total: ${GREEN}${pass_count} PASS${RESET}, ${RED}${fail_count} FAIL${RESET} (of ${RUNS})"
if [[ $fail_count -gt 0 ]]; then
pct=$(( fail_count * 100 / RUNS ))
echo -e " Failure rate: ${RED}${pct}%${RESET}"
fi
echo ""
# Write structured results file
results_file="${RESULTS_DIR}/results.txt"
{
echo "# investigate-boot results"
echo "# Generated: $(date -Iseconds)"
echo "# ISO: ${ISO_FILENAME} (version ${ISO_VERSION})"
echo "# ISO source: ${ISO_SOURCE}"
echo "# apply_defaults: ${APPLY_DEFAULTS}"
echo "# Runs: ${RUNS}"
echo "#"
echo "# run|outcome|time_s|scrub_schedule|check_frequency|os_version|run_dir"
for ((i=0; i<${#RUN_OUTCOMES[@]}; i++)); do
echo "$((i+1))|${RUN_OUTCOMES[$i]}|${RUN_TIMES[$i]:-?}|${RUN_SCRUB_SCHEDULES[$i]:-?}|${RUN_CHECK_FREQUENCIES[$i]:-?}|${RUN_OS_VERSIONS[$i]:-?}|${RUN_DIRS[$i]:-?}"
done
} > "$results_file"
success "Results written to: ${results_file}"
echo ""
exit $fail_count