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