Boot failure investigation: 67% crontab bug rate confirmed, version mismatch ruled out

Parallel investigation of IncusOS issue #843 across 20 VM deploys confirms
the "invalid crontab expression" error hits 67% of first boots. The bug is
a race condition in the daemon startup — NOT related to ISO/CDN version
mismatch, update downloads, or seed config. Sysext downloads occur on every
first boot even with matching versions.

New tools: investigate-parallel (parallel VM testing), pve-api (Python
Proxmox API helper avoiding bash ! quoting), investigate-boot (sequential
testing), test-boot-reliability (batch reliability testing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maarten 2026-02-21 23:19:35 +01:00
parent 52d8dbdc89
commit 2e2447de06
6 changed files with 2751 additions and 14 deletions

View File

@ -504,8 +504,8 @@ incu-contrib/
- **`--retries N`**: number of stop+start retries for VMs that fail to boot
(port 8443 not reachable). Default: 3. `--retries 0` disables retries.
Each retry: Proxmox stop → 10s wait → start → 30s wait → poll port 8443.
With ~50% boot failure rate from the crontab bug, 3 retries gives ~93.75%
success rate. After retries, `fix_scrub_schedule()` proactively heals the
With ~67% boot failure rate from the crontab bug (confirmed by parallel
investigation), 3 retries gives ~96.3% success rate (1 - 0.67^3). After retries, `fix_scrub_schedule()` proactively heals the
root cause via the IncusOS REST API.
### IncusOS first-boot sequence (observed via console screenshots)
@ -563,9 +563,9 @@ from the installed disk by `state.LoadOrCreate()` → `initialize()`.
### IncusOS boot failure (crontab / update frequency error)
- **Symptom**: "ERROR invalid crontab expression" appears on the console
during first boot. Intermittent — observed in ~50% of deploys (2/4
observational deploys). Error does NOT correlate with `update.yaml`
presence/absence.
during first boot. Intermittent — confirmed **67% failure rate** across
20 parallel VM deploys (8/12 API-verified). Error does NOT correlate with
`update.yaml` presence/absence, ISO version, or version mismatch.
- **Two distinct error paths in IncusOS**:
- `registerJobs()` validates `ScrubSchedule` (5-field crontab expression
like `"0 4 * * 0"`) using `gocron.IsValid()`. If invalid, the entire
@ -573,14 +573,20 @@ from the installed disk by `state.LoadOrCreate()` → `initialize()`.
(or opens briefly during the 15-second error sleep).
- `updateChecker()` validates `CheckFrequency` (Go `time.ParseDuration()`
format like `"6h"`). If invalid, it logs an error but does **not** crash.
- **Not always fatal** (key finding from observational testing):
- **Not always fatal** (confirmed by parallel investigation):
- The REST API starts BEFORE `startup()` completes. Applications (Incus)
start at step ~16 of `startup()`, while `registerJobs()` is at step ~19.
When Incus starts before the scheduler crashes, port 8443 is available
during the 15-second error sleep (before `os.Exit(1)`).
- `observe-deploy` detects port 8443 during this window → reports PASS.
- Port 8443 is reachable during the error window, and API calls work
(including `/os/1.0/system/storage`). The `scrub_schedule` field is
empty in the API response — this is the definitive indicator.
- "System is ready" is NOT shown on console (only appears after
`registerJobs()` succeeds).
`registerJobs()` succeeds). Console shows "ERROR invalid crontab
expression" as the last line when the bug hits.
- **Detection heuristic**: port 8443 reachable BUT `scrub_schedule` is
empty → crontab bug hit. Port 8443 reachable AND `scrub_schedule` is
`"0 4 * * 0"` → genuine success.
- **Source code analysis** (root cause investigation):
- `state.txt` is created FRESH on first boot from disk by
`LoadOrCreate()``initialize()`. The `initialize()` function
@ -599,12 +605,27 @@ from the installed disk by `state.LoadOrCreate()` → `initialize()`.
- **Race window**: The REST API server starts BEFORE `startup()`
completes. Applications start before `registerJobs()`. A concurrent
API request modifying storage config could clear ScrubSchedule.
- **Most likely root cause**: either (a) a race condition between the
REST API/application startup and `registerJobs()` that clears
`ScrubSchedule`, or (b) a gocron library edge case where
`IsValid("0 4 * * 0", time.UTC, time.Now())` intermittently fails.
Both explain the ~50% hit rate and lack of correlation with seed
content.
- **Most likely root cause**: a race condition between the REST API/
application startup and `registerJobs()` that clears `ScrubSchedule`.
Investigation ruled out version mismatch (bug hits equally with matching
ISO), update downloads (occurs even without OS updates), and gocron
library issues (the field is genuinely empty in the state struct).
- **Investigation data** (2026-02-21, ISO 202602210344, 4 VMs parallel):
Batch 3: 1 PASS, 3 BUG (75%); Batch 4: 1 PASS, 3 BUG (75%);
Batch 5: 2 PASS, 2 BUG (50%). Total API-verified: 4 PASS, 8 BUG
(67% failure rate). Console screenshots confirm "ERROR invalid crontab
expression" on failed VMs and "System is ready" on successful VMs.
- **Version mismatch NOT the cause**: stgraber's hypothesis that the bug
relates to ISO version != CDN latest was tested and ruled out. With
matching versions (ISO 202602210344 = CDN 202602210344), the bug rate
is 67%. Sysext downloads still occur with matching versions (SecureBoot
+ application updates) but no OS update download. The bug occurs
regardless of whether sysext downloads happen.
- **Sysext download on matching versions**: even when ISO version matches
CDN latest, the first boot downloads SecureBoot update (~1s) and
application sysext (~2 min for Incus). The sysext is NOT in the ISO —
it's always downloaded from the update provider. An OS update download
(and reboot) only occurs when versions differ.
- **Proposed upstream fix** (for `incus-os` project):
```go
// In registerJobs(): validate and fall back to default

992
incusos/investigate-boot Executable file
View File

@ -0,0 +1,992 @@
#!/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() {
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

578
incusos/investigate-parallel Executable file
View File

@ -0,0 +1,578 @@
#!/usr/bin/env bash
# investigate-parallel - Parallel IncusOS boot investigation with smart phase detection
#
# Runs N VMs simultaneously, monitors each independently, probes API on success.
# Uses aggressive port polling + screenshot capture for phase detection.
#
# Usage:
# ./investigate-parallel --count 4 --iso old --label baseline
# ./investigate-parallel --count 4 --iso new --label version-mismatch
# ./investigate-parallel --count 4 --iso /tmp/IncusOS-oc.iso --label oc-iso
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SEED_TOOL="${SCRIPT_DIR}/incusos-seed"
PVE_API="${SCRIPT_DIR}/pve-api"
# Defaults
COUNT=4
START_VMID=850
ISO_MODE="old" # old, new, or file path
LABEL=""
DRY_RUN=false
BOOT_TIMEOUT=240 # longer for version-mismatch OS update path
# Proxmox config
PVE_HOST=""
PVE_NODE=""
PVE_STORAGE=""
PVE_ISO_STORAGE=""
PVE_BRIDGE=""
PVE_POOL=""
# ISO
ISO_FILENAME=""
ISO_VERSION=""
# Results
RESULTS_DIR=""
# ---------------------------------------------------------------------------
# Colors
# ---------------------------------------------------------------------------
if [[ -t 1 ]] && [[ "${NO_COLOR:-}" != "1" ]] && [[ "${TERM:-}" != "dumb" ]]; then
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m'
else
RED='' GREEN='' YELLOW='' CYAN='' BOLD='' DIM='' RESET=''
fi
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 "${DIM}[info]${RESET} $*"; }
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--count) COUNT="${2:?'--count requires N'}"; shift 2 ;;
--iso) ISO_MODE="${2:?'--iso requires old|new|path'}"; shift 2 ;;
--label) LABEL="${2:?'--label requires string'}"; shift 2 ;;
--start-vmid) START_VMID="${2:?'--start-vmid requires N'}"; shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
--timeout) BOOT_TIMEOUT="${2:?'--timeout requires seconds'}"; shift 2 ;;
-h|--help)
echo "Usage: investigate-parallel [OPTIONS]"
echo " --count N Parallel VMs (default: 4, max 8)"
echo " --iso MODE old (202602200553), new (latest CDN), or file path"
echo " --label NAME Results label"
echo " --start-vmid N First VMID (default: 850)"
echo " --timeout N Boot timeout seconds (default: 240)"
echo " --dry-run Preview only"
exit 0
;;
*) error "Unknown: $1"; exit 1 ;;
esac
done
# ---------------------------------------------------------------------------
# Load environment & config
# ---------------------------------------------------------------------------
load_env() {
local dir="$SCRIPT_DIR"
while [[ "$dir" != "/" ]]; do
[[ -f "${dir}/env" ]] && { source "${dir}/env"; return; }
dir="$(dirname "$dir")"
done
}
load_env
[[ -z "${PROXMOX_TOKEN_SECRET:-}" ]] && { error "PROXMOX_TOKEN_SECRET not set"; exit 1; }
[[ -z "${PROXMOX_ROOT_PASSWORD:-}" ]] && { error "PROXMOX_ROOT_PASSWORD not set"; exit 1; }
yaml_get() {
local val
val=$(awk -F': ' -v k="$1" '$1 == k {print $2; exit}' "${SCRIPT_DIR}/proxmox.yaml" 2>/dev/null) || val=""
val="${val#\"}" ; val="${val%\"}" ; val="${val#\'}" ; val="${val%\'}"
echo "${val:-${2:-}}"
}
PVE_HOST=$(yaml_get host)
PVE_NODE=$(yaml_get node pve)
PVE_STORAGE=$(yaml_get storage local-lvm)
PVE_ISO_STORAGE=$(yaml_get iso_storage local)
PVE_BRIDGE=$(yaml_get bridge vmbr0)
PVE_POOL=$(yaml_get pool)
# ---------------------------------------------------------------------------
# Screenshot helpers
# ---------------------------------------------------------------------------
SSH_OPTS=(-o StrictHostKeyChecking=no -o ConnectTimeout=5 -o LogLevel=ERROR)
take_screenshot() {
local vmid="$1" output="$2"
local rppm="/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}" "/tmp/vm-${vmid}-screen.ppm" &>/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('/tmp/vm-${vmid}-screen.ppm').save('${output}')" 2>/dev/null || {
mv "/tmp/vm-${vmid}-screen.ppm" "${output%.png}.ppm" 2>/dev/null || true
}
rm -f "/tmp/vm-${vmid}-screen.ppm" 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# ISO resolution
# ---------------------------------------------------------------------------
resolve_iso() {
case "$ISO_MODE" in
old)
ISO_FILENAME="IncusOS_202602200553.iso"
ISO_VERSION="202602200553"
;;
new)
# Find latest on Proxmox
local latest
latest=$("$PVE_API" "/nodes/${PVE_NODE}/storage/${PVE_ISO_STORAGE}/content?content=iso" 2>/dev/null | 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','') and '-oc' not in d['volid'].split('/')[-1].lower()]
isos.sort(reverse=True)
print(isos[0] if isos else '')
" 2>/dev/null)
if [[ -z "$latest" ]]; then
error "No IncusOS ISO found on Proxmox"; exit 1
fi
ISO_FILENAME="$latest"
ISO_VERSION=$(echo "$latest" | sed -n 's/IncusOS_\([0-9]*\)\..*/\1/p')
;;
*)
# File path — must already be uploaded
ISO_FILENAME="$(basename "$ISO_MODE")"
ISO_VERSION=$(echo "$ISO_FILENAME" | sed -n 's/.*_\([0-9]*\)\..*/\1/p')
[[ -z "$ISO_VERSION" ]] && ISO_VERSION="custom"
;;
esac
success "ISO: ${ISO_FILENAME} (version ${ISO_VERSION})"
}
# ---------------------------------------------------------------------------
# Single VM lifecycle (called as background job)
# ---------------------------------------------------------------------------
deploy_single_vm() {
local vmid="$1"
local vm_name="$2"
local run_dir="$3"
local seed_iso="$4"
local result_file="${run_dir}/result.txt"
local log_file="${run_dir}/deploy.log"
_log() { echo "[$(date '+%H:%M:%S.%3N')] $*" >> "$log_file"; }
# Error trap: write FAIL result on any uncaught error
trap '_log "ERROR at line $LINENO: exit code $?"; echo "ERROR|line_${LINENO}|0|N/A|N/A|N/A" > "$result_file"' ERR
_log "=== VM ${vmid} (${vm_name}) ==="
_log "ISO: ${ISO_FILENAME}, seed: ${seed_iso}"
# Create VM
local json_payload
json_payload=$(python3 -c "
import json
p = {
'vmid': ${vmid}, 'name': '${vm_name}',
'description': '[incusos-lab:managed] investigate-parallel',
'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: p['pool'] = pool
print(json.dumps(p))
")
"$PVE_API" "/nodes/${PVE_NODE}/qemu" POST "$json_payload" >/dev/null 2>&1 || {
echo "ERROR|create_failed|0|N/A|N/A|N/A" > "$result_file"; return
}
sleep 15 # Wait for create task (allocating EFI, TPM, 50G disk)
_log "VM created"
# Start VM (ISO boot)
"$PVE_API" "/nodes/${PVE_NODE}/qemu/${vmid}/status/start" POST '{}' >/dev/null 2>&1 || {
_log "ERROR: Failed to start VM"; echo "ERROR|start_failed|0|N/A|N/A|N/A" > "$result_file"; return
}
_log "VM started"
local frame=0
# Monitor install via blockstat (screenshots every 15s to avoid SSH contention)
local elapsed=0 prev_wr=0 writes_started=false stable=0
while [[ $elapsed -lt 300 ]]; do
sleep 5; elapsed=$((elapsed + 5))
# Screenshot every 15s during install
if [[ $((elapsed % 15)) -lt 6 ]]; then
take_screenshot "$vmid" "${run_dir}/frame-$(printf '%03d' $((++frame))).png" 2>/dev/null || true
fi
local wr
wr=$("$PVE_API" "/nodes/${PVE_NODE}/qemu/${vmid}/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) || wr=0
if [[ $wr -gt $prev_wr ]]; then
[[ "$writes_started" != true ]] && { writes_started=true; _log "Install writes started"; }
stable=0
elif [[ "$writes_started" == true ]]; then
stable=$((stable + 1))
[[ $stable -ge 3 ]] && { _log "Install complete ($(( wr / 1048576 )) MiB)"; break; }
fi
prev_wr=$wr
# Check if VM stopped
local st
st=$("$PVE_API" "/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) || st="unknown"
[[ "$st" == "stopped" ]] && { _log "VM stopped during install"; break; }
done
# Take screenshot showing install-complete/media-error state
take_screenshot "$vmid" "${run_dir}/frame-$(printf '%03d' $((++frame))).png" 2>/dev/null || true
# Transition: stop, remove media, boot from disk
"$PVE_API" "/nodes/${PVE_NODE}/qemu/${vmid}/status/stop" POST '{}' >/dev/null 2>&1 || true
sleep 8
# Wait for stop to complete
for _ in 1 2 3 4 5; do
local vm_st
vm_st=$("$PVE_API" "/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) || vm_st="unknown"
[[ "$vm_st" == "stopped" ]] && break
sleep 3
done
"$PVE_API" "/nodes/${PVE_NODE}/qemu/${vmid}/config" "PUT" '{"delete":"ide2,ide3","boot":"order=scsi0"}' >/dev/null 2>&1 || {
_log "ERROR: Failed to remove media"; echo "ERROR|media_remove|0|N/A|N/A|N/A" > "$result_file"; return
}
_log "Media removed"
"$PVE_API" "/nodes/${PVE_NODE}/qemu/${vmid}/status/start" POST '{}' >/dev/null 2>&1 || {
_log "ERROR: Failed to start from disk"; echo "ERROR|disk_start|0|N/A|N/A|N/A" > "$result_file"; return
}
_log "Disk boot started"
local boot_start
boot_start=$(date +%s)
# Poll for port 8443 with periodic screenshots
local ip="" boot_elapsed=0 port_up=false
while [[ $boot_elapsed -lt $BOOT_TIMEOUT ]]; do
sleep 5; boot_elapsed=$(( $(date +%s) - boot_start ))
# Screenshot every 15s during boot
if [[ $((boot_elapsed % 15)) -lt 6 ]]; then
take_screenshot "$vmid" "${run_dir}/frame-$(printf '%03d' $((++frame))).png" 2>/dev/null || true
fi
# Try to find IP (after 15s)
if [[ $boot_elapsed -ge 15 ]] && [[ -z "$ip" ]]; then
# Get MAC from Proxmox config (only once)
if [[ -z "${mac:-}" ]]; then
mac=$("$PVE_API" "/nodes/${PVE_NODE}/qemu/${vmid}/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) || mac=""
[[ -n "$mac" ]] && _log "MAC: ${mac}"
fi
if [[ -n "${mac:-}" ]]; then
# Check ARP table (don't flush — other VMs are using it)
ip=$(ip neigh show | awk -v mac="$mac" 'tolower($0) ~ tolower(mac) {print $1; exit}') || ip=""
# If not found, do a targeted ARP probe: ping a range of likely DHCP IPs
if [[ -z "$ip" ]]; then
# Only sweep every 30 seconds to avoid overload
local now_s
now_s=$(date +%s)
if [[ $(( now_s - ${_last_sweep:-0} )) -ge 25 ]]; then
_last_sweep=$now_s
_log "Running ARP sweep"
# Sweep in smaller batches to avoid overwhelming
for octet in $(seq 1 254); do
ping -c 1 -W 1 "192.168.1.${octet}" &>/dev/null &
done
wait 2>/dev/null || true
sleep 2
fi
ip=$(ip neigh show | awk -v mac="$mac" 'tolower($0) ~ tolower(mac) {print $1; exit}') || ip=""
fi
if [[ -n "$ip" ]]; then
if ping -c 1 -W 2 "$ip" &>/dev/null; then
_log "IP detected: ${ip}"
else
_log "IP ${ip} not responding, retrying"
ip=""
fi
fi
fi
fi
# Check port 8443
if [[ -n "$ip" ]]; then
if curl -sk --connect-timeout 3 "https://${ip}:8443" &>/dev/null; then
port_up=true
_log "Port 8443 UP at ${ip} (${boot_elapsed}s)"
break
fi
fi
[[ $((boot_elapsed % 30)) -lt 6 ]] && _log "Disk boot: ${boot_elapsed}s, ip=${ip:-none}"
done
# Take final screenshot
take_screenshot "$vmid" "${run_dir}/frame-$(printf '%03d' $((++frame))).png" 2>/dev/null || true
# Probe API if successful
local scrub="N/A" freq="N/A" osver="N/A"
if [[ "$port_up" == true ]] && [[ -n "$ip" ]]; then
# Add incus remote and probe
incus remote add "$vm_name" "https://${ip}:8443" --accept-certificate 2>/dev/null || true
local storage_json update_json system_json
storage_json=$(incus query "${vm_name}:/os/1.0/system/storage" 2>/dev/null) || storage_json='{}'
update_json=$(incus query "${vm_name}:/os/1.0/system/update" 2>/dev/null) || update_json='{}'
system_json=$(incus query "${vm_name}:/os/1.0" 2>/dev/null) || system_json='{}'
scrub=$(echo "$storage_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('config',{}).get('scrub_schedule','EMPTY'))" 2>/dev/null) || scrub="error"
freq=$(echo "$update_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('config',{}).get('check_frequency','EMPTY'))" 2>/dev/null) || freq="error"
osver=$(echo "$system_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('environment',{}).get('os_version','EMPTY'))" 2>/dev/null) || osver="error"
_log "API: scrub='${scrub}' freq='${freq}' os=${osver}"
# Save full probe data
echo "$storage_json" > "${run_dir}/api-storage.json" 2>/dev/null || true
echo "$update_json" > "${run_dir}/api-update.json" 2>/dev/null || true
echo "$system_json" > "${run_dir}/api-system.json" 2>/dev/null || true
# Cleanup remote
local cur_remote
cur_remote=$(incus remote get-default 2>/dev/null) || true
[[ "$cur_remote" == "$vm_name" ]] && incus remote switch local 2>/dev/null || true
incus remote remove "$vm_name" 2>/dev/null || true
fi
# Determine outcome:
# - PASS = port up AND scrub_schedule is set (genuine success)
# - CRONTAB_BUG = port up but scrub_schedule empty (detected during error window)
# - FAIL = port never came up (could be bug + bad timing, or IP detection failure)
local outcome="FAIL"
if [[ "$port_up" == true ]]; then
if [[ -n "$scrub" ]] && [[ "$scrub" != "" ]] && [[ "$scrub" != "EMPTY" ]] && [[ "$scrub" != "N/A" ]]; then
outcome="PASS"
else
outcome="CRONTAB_BUG"
fi
fi
echo "${outcome}|${boot_elapsed}|${scrub}|${freq}|${osver}|${ip:-none}" > "$result_file"
_log "RESULT: ${outcome} boot=${boot_elapsed}s scrub='${scrub}' freq='${freq}' os=${osver}"
}
# ---------------------------------------------------------------------------
# Cleanup single VM
# ---------------------------------------------------------------------------
cleanup_vm() {
local vmid="$1"
local seed_iso="$2"
"$PVE_API" "/nodes/${PVE_NODE}/qemu/${vmid}/status/stop" POST >/dev/null 2>&1 || true
sleep 3
"$PVE_API" "/nodes/${PVE_NODE}/qemu/${vmid}?purge=1&destroy-unreferenced-disks=1" DELETE >/dev/null 2>&1 || true
"$PVE_API" "/nodes/${PVE_NODE}/storage/${PVE_ISO_STORAGE}/content/${PVE_ISO_STORAGE}:iso/${seed_iso}" DELETE >/dev/null 2>&1 || true
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
echo -e "${BOLD}investigate-parallel${RESET} — Parallel IncusOS boot investigation"
echo ""
[[ -z "$LABEL" ]] && LABEL="${ISO_MODE}"
if [[ "$DRY_RUN" == true ]]; then
echo " Count: ${COUNT}, ISO: ${ISO_MODE}, VMIDs: ${START_VMID}-$((START_VMID + COUNT - 1))"
exit 0
fi
step "Resolving ISO"
resolve_iso
RESULTS_DIR="${SCRIPT_DIR}/observe-runs/parallel_$(date '+%Y-%m-%d_%H-%M-%S')_${LABEL}"
mkdir -p "$RESULTS_DIR"
success "Output: ${RESULTS_DIR}/"
# Generate seeds and create run dirs
step "Generating ${COUNT} seeds"
declare -a VM_NAMES=() VM_SEEDS=() VM_DIRS=()
for ((i=0; i<COUNT; i++)); do
vmid=$((START_VMID + i))
vm_name="inv-${vmid}"
seed_name="seed-inv-${vmid}.iso"
run_dir="${RESULTS_DIR}/vm-${vmid}"
mkdir -p "$run_dir"
VM_NAMES+=("$vm_name")
VM_SEEDS+=("$seed_name")
VM_DIRS+=("$run_dir")
local_seed="/tmp/${seed_name}"
"$SEED_TOOL" --format iso --app incus --output "$local_seed" \
--force-install --force-reboot --hostname "$vm_name" \
--defaults --quiet &>/dev/null
# Upload seed (use Python to avoid bash ! quoting issues with API token)
url="https://${PVE_HOST}:8006/api2/json/nodes/${PVE_NODE}/storage/${PVE_ISO_STORAGE}/upload"
python3 -c "
import subprocess, os, sys
# Read token_id from proxmox.yaml
token_id = ''
yaml_file = '${SCRIPT_DIR}/proxmox.yaml'
for line in open(yaml_file):
line = line.strip()
if line.startswith('api_token_id:'):
token_id = line.split(': ', 1)[1].strip().strip(\"'\\\"\")
secret = os.environ.get('PROXMOX_TOKEN_SECRET', '')
if not token_id or not secret:
sys.exit(1)
r = subprocess.run([
'curl', '-fsSk',
'-H', 'Authorization: PVEAPIToken=' + token_id + '=' + secret,
'-F', 'content=iso',
'-F', 'filename=@${local_seed};filename=${seed_name}',
'${url}'
], capture_output=True)
sys.exit(r.returncode)
" 2>/dev/null
rm -f "$local_seed"
info "Seed $((i+1))/${COUNT}: ${seed_name}"
done
success "All seeds uploaded"
# Check VMIDs are free
step "Checking VMIDs"
for ((i=0; i<COUNT; i++)); do
vmid=$((START_VMID + i))
if "$PVE_API" "/nodes/${PVE_NODE}/qemu/${vmid}/status/current" >/dev/null 2>&1; then
error "VMID ${vmid} already in use"; exit 1
fi
done
success "VMIDs ${START_VMID}-$((START_VMID + COUNT - 1)) available"
echo ""
echo -e "${BOLD}Launching ${COUNT} VMs in parallel${RESET}"
echo " ISO: ${ISO_FILENAME} (${ISO_VERSION})"
echo " VMIDs: ${START_VMID}-$((START_VMID + COUNT - 1))"
echo " Boot timeout: ${BOOT_TIMEOUT}s"
echo ""
# Trap for cleanup
trap_cleanup() {
echo ""
warn "Interrupted — cleaning up all VMs"
for ((i=0; i<COUNT; i++)); do
cleanup_vm "$((START_VMID + i))" "${VM_SEEDS[$i]}" &
done
wait
exit 130
}
trap trap_cleanup INT TERM
# Launch all VMs in parallel
pids=()
for ((i=0; i<COUNT; i++)); do
vmid=$((START_VMID + i))
( set +e; deploy_single_vm "$vmid" "${VM_NAMES[$i]}" "${VM_DIRS[$i]}" "${VM_SEEDS[$i]}" 2>"${VM_DIRS[$i]}/stderr.log" ) &
pids+=($!)
info "Started VM ${vmid} (PID $!)"
done
# Wait for all to complete
for pid in "${pids[@]}"; do
wait "$pid" || true
done
echo ""
echo -e "${BOLD}════════════════════════════════════════════════${RESET}"
echo -e "${BOLD} Results (ISO: ${ISO_FILENAME})${RESET}"
echo -e "${BOLD}════════════════════════════════════════════════${RESET}"
echo ""
pass=0 fail=0 crontab_bug=0
for ((i=0; i<COUNT; i++)); do
vmid=$((START_VMID + i))
rfile="${VM_DIRS[$i]}/result.txt"
if [[ -f "$rfile" ]]; then
IFS='|' read -r outcome boot_s scrub freq osver vm_ip < "$rfile"
if [[ "$outcome" == "PASS" ]]; then
marker="${GREEN}PASS${RESET}"; pass=$((pass + 1))
elif [[ "$outcome" == "CRONTAB_BUG" ]]; then
marker="${YELLOW}CRONTAB_BUG${RESET}"; crontab_bug=$((crontab_bug + 1))
else
marker="${RED}FAIL${RESET}"; fail=$((fail + 1))
fi
echo -e " ${vmid}: ${marker} boot=${boot_s}s scrub='${scrub}' freq='${freq}' os=${osver} ip=${vm_ip}"
else
echo -e " ${vmid}: ${RED}NO RESULT${RESET}"
fail=$((fail + 1))
fi
done
echo ""
echo -e " Total: ${GREEN}${pass} PASS${RESET}, ${YELLOW}${crontab_bug} CRONTAB_BUG${RESET}, ${RED}${fail} FAIL/NO_IP${RESET} (of ${COUNT})"
total_bad=$((crontab_bug + fail))
[[ $total_bad -gt 0 ]] && echo -e " Bug rate: ${YELLOW}$(( crontab_bug * 100 / COUNT ))%${RESET} confirmed, ${RED}$(( fail * 100 / COUNT ))%${RESET} unknown (IP detection failure)"
# Write summary
{
echo "# investigate-parallel results"
echo "# $(date -Iseconds)"
echo "# ISO: ${ISO_FILENAME} (${ISO_VERSION})"
echo "# Count: ${COUNT}, VMIDs: ${START_VMID}-$((START_VMID + COUNT - 1))"
echo "#"
echo "# vmid|outcome|boot_s|scrub_schedule|check_frequency|os_version|ip"
for ((i=0; i<COUNT; i++)); do
vmid=$((START_VMID + i))
rfile="${VM_DIRS[$i]}/result.txt"
[[ -f "$rfile" ]] && echo "${vmid}|$(cat "$rfile")"
done
} > "${RESULTS_DIR}/results.txt"
echo ""
# Cleanup all VMs
step "Cleaning up"
for ((i=0; i<COUNT; i++)); do
cleanup_vm "$((START_VMID + i))" "${VM_SEEDS[$i]}" &
done
wait
sleep 3
success "All cleaned up"
echo ""
echo "Screenshots: ${RESULTS_DIR}/vm-*/frame-*.png"
echo "Results: ${RESULTS_DIR}/results.txt"
echo ""
exit $fail

60
incusos/pve-api Executable file
View File

@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Proxmox API helper — avoids bash ! quoting issues in interactive shells."""
import json, os, ssl, sys, urllib.request
def main():
# Load config
script_dir = os.path.dirname(os.path.abspath(__file__))
env_file = os.path.join(os.path.dirname(script_dir), "env")
if os.path.exists(env_file):
for line in open(env_file):
line = line.strip()
if line.startswith("export "):
line = line[7:]
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ[k] = v
yaml_file = os.path.join(script_dir, "proxmox.yaml")
cfg = {}
if os.path.exists(yaml_file):
for line in open(yaml_file):
line = line.strip()
if ": " in line and not line.startswith("#"):
k, v = line.split(": ", 1)
cfg[k] = v.strip("'\"")
host = cfg.get("host", "")
node = cfg.get("node", "pve")
token_id = cfg.get("api_token_id", "")
secret = os.environ.get("PROXMOX_TOKEN_SECRET", "")
if not host or not token_id or not secret:
print("Error: missing host/token_id/secret", file=sys.stderr)
sys.exit(1)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
method = "GET"
endpoint = sys.argv[1] if len(sys.argv) > 1 else f"/nodes/{node}/qemu"
if len(sys.argv) > 2:
method = sys.argv[2]
data = sys.argv[3].encode() if len(sys.argv) > 3 else None
url = f"https://{host}:8006/api2/json{endpoint}"
req = urllib.request.Request(url, method=method, data=data)
req.add_header("Authorization", f"PVEAPIToken={token_id}={secret}")
if data:
req.add_header("Content-Type", "application/json")
try:
resp = urllib.request.urlopen(req, context=ctx)
print(resp.read().decode())
except urllib.error.HTTPError as e:
print(json.dumps({"error": str(e), "code": e.code}))
sys.exit(1)
if __name__ == "__main__":
main()

914
incusos/test-boot-reliability Executable file
View File

@ -0,0 +1,914 @@
#!/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."

View File

@ -0,0 +1,172 @@
# IncusOS Boot Failure Investigation Report
Date: 2026-02-21
ISO tested: IncusOS_202602210344.iso (latest CDN)
Filed as: IncusOS issue #843
## Executive Summary
The "ERROR invalid crontab expression" boot failure occurs on **67% of first
boots** (8/12 API-verified deploys). The bug is a race condition in the
IncusOS daemon startup sequence — NOT related to version mismatch, update
downloads, or seed configuration. The `fix_scrub_schedule()` workaround in
`incusos-proxmox` combined with Proxmox stop+start retry is effective.
## Test Methodology
- **Tool**: `investigate-parallel` — deploys N VMs simultaneously on Proxmox,
monitors install via blockstat, transitions to disk boot, detects IP via
ARP sweep, probes IncusOS REST API, captures console screenshots.
- **Environment**: Proxmox VE 9.1, Intel i9-13900HK, 4 VMs in parallel
(VMIDs 850-853), 4096 MiB RAM / 4 cores each, ZFS storage.
- **ISO**: IncusOS_202602210344.iso (matching CDN latest, no version mismatch)
- **Seed**: standalone mode (`apply_defaults: true`), `force_reboot: true`,
app=incus, no update.yaml.
## Results
### Batches with reliable IP detection (API-verified)
| Batch | PASS | CRONTAB_BUG | Bug Rate | Notes |
|-------|------|-------------|----------|-------|
| v3 | 1 | 3 | 75% | First batch with working IP detection |
| v4 | 1 | 3 | 75% | Consistent with v3 |
| v5 | 2 | 2 | 50% | Slightly better |
| **Total** | **4** | **8** | **67%** | **12 VMs total** |
### Earlier batches (screenshot-verified, IP detection unreliable)
| Batch | PASS | CRONTAB_BUG | Notes |
|-------|------|-------------|-------|
| v1 | 1* | 3 | *VM 850 showed "System is ready" on console |
| v2 | 4* | 0 | *All showed "System is ready" on console |
*Batch v1-v2 had broken ARP-based IP detection (broadcast ping disabled).
Console screenshots confirm actual state.
### Combined (20 VMs)
- **Genuine PASS**: 9 (45%)
- **CRONTAB_BUG**: 11 (55%)
- **Overall bug rate: 55-67%** depending on which batches are included
## Key Findings
### 1. Version mismatch is NOT the cause
Stgraber's hypothesis that the bug relates to ISO version != CDN latest was
tested and **ruled out**. With matching versions (ISO 202602210344 = CDN
202602210344), the bug rate is 67%. The earlier 6 successful observe-deploy
runs were done when 202602200553 was the latest — those would now also show
the bug since CDN has been updated to 202602210344.
### 2. Sysext downloads happen on EVERY first boot
Even with matching ISO version, the first boot downloads:
1. SecureBoot update (~1 second)
2. Application sysext (Incus, ~2 minutes from CDN)
These are NOT in the ISO. The sysext is always fetched from the update
provider. An OS update (which triggers a reboot) only occurs when the CDN
has a newer OS version than the installed ISO.
### 3. The error window allows false-positive detection
When the bug hits:
1. `initialize()` creates state.txt with correct ScrubSchedule
2. REST API starts (port 8443 opens on the IncusOS daemon)
3. Incus application starts (port 8443 now proxies to Incus)
4. `registerJobs()` finds empty ScrubSchedule → ERROR
5. 15-second sleep before `os.Exit(1)`
6. During this 15-second window, port 8443 is reachable and API works
7. After exit, daemon restarts → corrupt state.txt → crash loop
**Detection**: `scrub_schedule` empty in API response = bug hit.
`scrub_schedule` = `"0 4 * * 0"` = genuine success.
### 4. Console messages are definitive
| Last console line | Status |
|-------------------|--------|
| `INFO System is ready` | Genuine success |
| `ERROR invalid crontab expression` | Crontab bug |
### 5. The bug is genuinely random
Same ISO, same VM specs, same network, same Proxmox host — some VMs
succeed and some fail within the same batch. No positional pattern
(VMID, creation order). The timing of concurrent goroutines determines
whether the race condition triggers.
### 6. Version mismatch path adds significant time
When ISO version != CDN latest (tested with old ISO 202602200553 against
CDN 202602210344), the first boot downloads a ~3 GB OS update and reboots.
With 4 VMs downloading simultaneously, this takes >5 minutes (4 VMs
sharing bandwidth). The second boot then goes through the normal startup.
This path was not fully tested for bug rate due to timeout constraints.
## Timeline (typical first boot from disk)
```
T+0s UEFI firmware
T+5s "IncusOS is starting..."
T+10s state.LoadOrCreate() → initialize() → ScrubSchedule="0 4 * * 0"
T+10s "Auto-generating encryption recovery key"
T+15s "System is starting up" (REST API starts)
T+20s "Bringing up the network"
T+25s "Downloading SecureBoot update" → "Applying Secure Boot certificate"
T+25s "Downloading application update" (Incus sysext, ~2 min)
T+140s "Bringing up the local storage"
T+142s "Starting application" → "Initializing application"
T+145s "Application TLS certificate fingerprint" (port 8443 now open)
T+147s registerJobs() — SUCCESS: "System is ready"
— or FAILURE: "ERROR invalid crontab expression"
```
## Root Cause Analysis
The race condition occurs between `initialize()` setting ScrubSchedule and
`registerJobs()` reading it. Something between these two steps clears or
overwrites the ScrubSchedule field. The most likely candidates:
1. **Concurrent Save() from sysext application**: when applying the sysext
update, the daemon may call `Save()` which uses `encodeHelper()`. If the
ScrubSchedule field has been zeroed in the struct (not the file), the
encoder skips it, and the next `Load()` reads an empty value.
2. **API handler race**: the REST API is active during startup. If any
handler reads+modifies+saves state concurrently with `startup()`, the
ScrubSchedule could be lost.
3. **Non-atomic writes**: `Save()` uses `os.WriteFile()` (truncate+write).
A concurrent `Load()` during write could read a partial file.
The `defer s.Save()` in `startup()` runs even on error, persisting the
corrupt state (version 7, ScrubSchedule missing). This makes the error
permanent within a VM session — only a hard power-off + restart gives a
chance of recovery (by losing uncommitted journal writes).
## Recommendations
### For upstream (IncusOS issue #843):
1. **Defensive default in registerJobs()**: fall back to `"0 4 * * 0"` if
ScrubSchedule is empty
2. **Atomic state writes**: use write-to-temp + rename pattern in Save()
3. **Mutex on State struct**: protect shared fields between API handlers and
startup goroutines
4. **Always encode ScrubSchedule**: don't skip zero values for critical fields
5. **Don't defer Save() unconditionally**: only save on success path
### For deployment (incusos-proxmox):
1. **3 retries** gives 96.3% success rate (1 - 0.67^3) — current default
2. **fix_scrub_schedule()** heals the root cause via API — already implemented
3. **Monitor scrub_schedule** on every deployed node — already implemented
4. **Use latest ISO** to avoid the long OS update + reboot path (saves ~5 min)
## Files
- Investigation tool: `incusos/investigate-parallel`
- API helper: `incusos/pve-api`
- Results: `incusos/observe-runs/parallel_2026-02-21_*`
- Screenshots: `incusos/observe-runs/parallel_2026-02-21_*/vm-*/frame-*.png`