incus-contrib/bin/diagnostic/observe-deploy

1355 lines
45 KiB
Bash
Executable File

#!/usr/bin/env bash
# observe-deploy - Single-VM IncusOS deploy with rapid console screenshots
#
# Captures the complete IncusOS lifecycle on screen — from ISO boot through
# installation, reboot, first-boot sequence, and port 8443 readiness. Takes
# screenshots every 2 seconds via Proxmox SSH root access (screendump).
#
# Purpose: diagnose boot failures by building a frame-by-frame timeline of
# exactly what IncusOS shows at every stage. NOT a batch test — one VM at
# a time, maximum visibility.
#
# Usage:
# ./observe-deploy # Standard deploy with screenshots
# ./observe-deploy --no-update-yaml # Omit update.yaml from seed
# ./observe-deploy --label run-01 # Custom run label
# ./observe-deploy --runs 10 --label baseline # 10 sequential cycles with stats
# ./observe-deploy --vmid 855 # Use a specific VMID
# ./observe-deploy --interval 1 # 1-second screenshots
# ./observe-deploy --dry-run # Preview without deploying
#
# Output: timestamped directory with PNG frames + deploy.log
# observe-runs/YYYY-MM-DD_HH-MM-SS_<label>/
# frame-001.png
# frame-002.png
# ...
# deploy.log
set -euo pipefail
readonly VERSION="0.1.0"
readonly SCRIPT_NAME="observe-deploy"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
SEED_TOOL="${BIN_DIR}/incusos-seed"
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
VMID=850
VM_NAME="observe-deploy"
LABEL=""
RUNS=1 # number of sequential deploy+destroy cycles
NO_UPDATE_YAML=false
NO_VLAN=false
STATIC_IP="" # Static IP in CIDR notation (e.g. 192.168.102.100/22)
VM_EXTRA="" # Extra VM creation overrides (key=value,key=value)
NO_FORCE_REBOOT=false # Skip force_reboot in seed (no SysRq-B reboot after install)
DRY_RUN=false
SCREENSHOT_INTERVAL=2 # seconds between screenshots
INSTALL_TIMEOUT=600 # seconds to wait for install completion
BOOT_TIMEOUT=180 # seconds to wait for port 8443 after disk boot
INSTALL_POLL=5 # blockstat poll interval during install phase
STABLE_THRESHOLD=3 # consecutive stable polls = install done
# Proxmox connection (from proxmox.yaml)
PVE_HOST=""
PVE_NODE=""
PVE_STORAGE=""
PVE_ISO_STORAGE=""
PVE_BRIDGE=""
PVE_POOL=""
PVE_VLAN=""
PVE_GATEWAY=""
PVE_DNS=""
PVE_API_TOKEN_ID=""
# IncusOS ISO
ISO_FILENAME=""
# Run output
RUN_DIR=""
LOG_FILE=""
FRAME_NUM=0
# State tracking
PHASE="init" # init → iso_boot → installing → install_done → transitioning → disk_boot → ready/failed
PREV_WR_BYTES=0
WRITES_STARTED=false
STABLE_COUNT=0
SCRUB_SCHEDULE="" # Filled after port 8443 check: "0 4 * * 0" = healthy, "" = crontab bug
DEPLOY_IP="" # IP of the deployed VM (set during disk_boot phase)
# ---------------------------------------------------------------------------
# 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 to both console and file
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 to file only (for high-frequency events)
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 ;;
--label) LABEL="${2:?'--label requires a string'}"; shift 2 ;;
--runs) RUNS="${2:?'--runs requires a number'}"; shift 2 ;;
--no-update-yaml) NO_UPDATE_YAML=true; shift ;;
--no-force-reboot) NO_FORCE_REBOOT=true; shift ;;
--no-vlan) NO_VLAN=true; shift ;;
--ip) STATIC_IP="${2:?'--ip requires ADDR/PREFIX'}"; shift 2 ;;
--vm-extra) VM_EXTRA="${2:?'--vm-extra requires key=value pairs'}"; shift 2 ;;
--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} - Observational IncusOS deploy with screenshots
${BOLD}USAGE${RESET}
${SCRIPT_NAME} [OPTIONS]
${BOLD}OPTIONS${RESET}
--vmid N VMID to use (default: 850, must be 850-869)
--label NAME Run label for output directory (default: auto)
--runs N Run N sequential deploy+destroy cycles with stats (default: 1)
--no-update-yaml Omit update.yaml from seed (Group A equivalent)
--no-force-reboot Omit force_reboot from seed (no SysRq-B after install)
--no-vlan Skip VLAN tag (use native LAN for IP detection)
--ip ADDR/PREFIX Static IP for the VM (e.g. 192.168.102.100/22)
Skips ARP detection; uses known IP for health checks
--interval N Screenshot interval in seconds (default: 2)
--dry-run Preview without deploying
-h, --help Show this help
${BOLD}OUTPUT${RESET}
Screenshots and logs are saved to:
observe-runs/<timestamp>_<label>/
frame-001.png ... frame-NNN.png
deploy.log
${BOLD}REQUIREMENTS${RESET}
- proxmox.yaml with Proxmox connection settings
- env file with PROXMOX_TOKEN_SECRET and PROXMOX_ROOT_PASSWORD
- IncusOS ISO already uploaded to Proxmox (use incusos-proxmox --phase upload)
- python3 with PIL/Pillow for PPM→PNG conversion
- sshpass for root SSH to Proxmox host
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."
error "Required for SSH root screenshots (screendump)."
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=""
local repo_root
repo_root=$(cd "$SCRIPT_DIR/../.." && pwd)
# Check envs/*/proxmox.yaml
for f in "${repo_root}"/envs/*/proxmox.yaml; do
if [[ -f "$f" ]]; then
yaml_file="$f"
break
fi
done
# Backward compat: repo root or cwd
if [[ -z "$yaml_file" ]]; then
if [[ -f "${repo_root}/proxmox.yaml" ]]; then
yaml_file="${repo_root}/proxmox.yaml"
elif [[ -f "proxmox.yaml" ]]; then
yaml_file="proxmox.yaml"
else
error "proxmox.yaml not found"
exit 1
fi
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_VLAN=$(yaml_get "$yaml_file" "vlan")
PVE_GATEWAY=$(yaml_get "$yaml_file" "gateway")
PVE_DNS=$(yaml_get "$yaml_file" "dns")
if [[ "$NO_VLAN" == true ]]; then
PVE_VLAN=""
fi
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"
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"
local destroy_response
destroy_response=$(pve_api DELETE "/nodes/${PVE_NODE}/qemu/${vmid}?purge=1&destroy-unreferenced-disks=1" 2>/dev/null) || return 1
# Wait for async destroy task to complete (avoids VMID reuse issues)
local upid
upid=$(echo "$destroy_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 30 ]]; 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
}
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() {
local vmid="$1"
# If static IP is configured, return the bare IP (strip CIDR prefix)
if [[ -n "$STATIC_IP" ]]; then
echo "${STATIC_IP%%/*}"
return 0
fi
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
if [[ -n "$broadcast" ]]; then
ping -c 2 -W 1 -b "$broadcast" &>/dev/null || true
sleep 1
fi
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
# Parallel ping sweep fallback
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
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
}
# ---------------------------------------------------------------------------
# scrub_schedule health check (definitive crontab bug indicator)
# ---------------------------------------------------------------------------
check_scrub_schedule() {
# Query the IncusOS REST API for the scrub_schedule field.
# This is the definitive health indicator:
# "0 4 * * 0" → genuine success
# "" → crontab bug hit (even if port 8443 responds)
# unreachable → daemon crashed before API started
#
# Sets global SCRUB_SCHEDULE and returns 0 on success, 1 on bug/failure.
local ip="$1"
local cert="${HOME}/.config/incus/client.crt"
local key="${HOME}/.config/incus/client.key"
if [[ ! -f "$cert" ]] || [[ ! -f "$key" ]]; then
log "No client certificate — skipping scrub_schedule check"
SCRUB_SCHEDULE="unchecked"
return 0
fi
local response
response=$(curl -sk --connect-timeout 5 --max-time 10 \
--cert "$cert" --key "$key" \
"https://${ip}:8443/os/1.0/system/storage" 2>/dev/null) || {
log "Could not query IncusOS storage API at ${ip}"
SCRUB_SCHEDULE="unreachable"
return 1
}
SCRUB_SCHEDULE=$(printf '%s' "$response" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
print(d.get('metadata', {}).get('config', {}).get('scrub_schedule', ''))
except:
print('')
" 2>/dev/null) || SCRUB_SCHEDULE=""
if [[ -n "$SCRUB_SCHEDULE" ]]; then
log "scrub_schedule = '${SCRUB_SCHEDULE}' → HEALTHY"
return 0
else
log "scrub_schedule is EMPTY → crontab bug detected"
return 1
fi
}
# ---------------------------------------------------------------------------
# Screenshot capture
# ---------------------------------------------------------------------------
# SSH options for root access to Proxmox (screenshots only)
SSH_OPTS=(-o StrictHostKeyChecking=no -o ConnectTimeout=5 -o LogLevel=ERROR)
capture_screenshot() {
# Captures a single frame from the VM console.
# Returns 0 on success, 1 on failure (VM not running, SSH error, etc.)
local vmid="$1"
local output_png="$2"
local remote_ppm="/tmp/vm-${vmid}-screen.ppm"
local local_ppm="/tmp/vm-${vmid}-screen.ppm"
# Take screenshot via QEMU monitor (screendump)
if ! sshpass -p "$PROXMOX_ROOT_PASSWORD" ssh "${SSH_OPTS[@]}" \
"root@${PVE_HOST}" \
"echo 'screendump ${remote_ppm}' | qm monitor ${vmid}" &>/dev/null; then
return 1
fi
# Retrieve PPM file
if ! sshpass -p "$PROXMOX_ROOT_PASSWORD" scp "${SSH_OPTS[@]}" \
"root@${PVE_HOST}:${remote_ppm}" "$local_ppm" &>/dev/null; then
return 1
fi
# Cleanup remote file
sshpass -p "$PROXMOX_ROOT_PASSWORD" ssh "${SSH_OPTS[@]}" \
"root@${PVE_HOST}" "rm -f ${remote_ppm}" &>/dev/null || true
# Convert PPM → PNG
if ! python3 -c "from PIL import Image; Image.open('${local_ppm}').save('${output_png}')" 2>/dev/null; then
# If PIL not available, keep PPM
mv "$local_ppm" "${output_png%.png}.ppm" 2>/dev/null || true
return 0
fi
rm -f "$local_ppm"
return 0
}
take_frame() {
# Capture a numbered frame and log it
FRAME_NUM=$((FRAME_NUM + 1))
local frame_name
frame_name=$(printf "frame-%03d.png" "$FRAME_NUM")
local frame_path="${RUN_DIR}/${frame_name}"
if capture_screenshot "$VMID" "$frame_path"; then
log_quiet "Frame ${FRAME_NUM}: ${frame_name} (phase=${PHASE})"
return 0
else
log_quiet "Frame ${FRAME_NUM}: FAILED (phase=${PHASE})"
return 1
fi
}
# ---------------------------------------------------------------------------
# Find IncusOS ISO 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
# ---------------------------------------------------------------------------
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}"
if [[ "$NO_UPDATE_YAML" == true ]]; then
info "Omitting update.yaml from seed (Group A equivalent)"
# Generate full seed as tar, extract, remove update.yaml, create ISO
local seed_extra=()
if [[ -n "$STATIC_IP" ]]; then
seed_extra+=(--ip "$STATIC_IP" --gateway "$PVE_GATEWAY")
[[ -n "$PVE_DNS" ]] && seed_extra+=(--dns "$PVE_DNS")
fi
local reboot_flag=()
[[ "$NO_FORCE_REBOOT" != true ]] && reboot_flag=(--force-reboot)
"$SEED_TOOL" --format tar --app incus --output "${SEED_TMPDIR}/seed.tar" \
--force-install "${reboot_flag[@]+"${reboot_flag[@]}"}" --hostname "$VM_NAME" \
--defaults "${seed_extra[@]+"${seed_extra[@]}"}" --quiet &>/dev/null
local seeddir="${SEED_TMPDIR}/seedfiles"
mkdir -p "$seeddir"
tar xf "${SEED_TMPDIR}/seed.tar" -C "$seeddir"
rm -f "${seeddir}/update.yaml"
# Create ISO from modified seed directory
if command -v genisoimage &>/dev/null; then
genisoimage -V SEED_DATA -J -r -o "$local_path" "$seeddir" 2>/dev/null
elif command -v mkisofs &>/dev/null; then
mkisofs -V SEED_DATA -J -r -o "$local_path" "$seeddir" 2>/dev/null
else
error "genisoimage or mkisofs required for ISO creation"
exit 1
fi
# Log seed contents
log "Seed contents (no update.yaml):"
for f in "$seeddir"/*.yaml; do
[[ -f "$f" ]] && log " $(basename "$f")"
done
else
info "Standard seed (with update.yaml, check_frequency=6h)"
local seed_extra=()
if [[ -n "$STATIC_IP" ]]; then
seed_extra+=(--ip "$STATIC_IP" --gateway "$PVE_GATEWAY")
[[ -n "$PVE_DNS" ]] && seed_extra+=(--dns "$PVE_DNS")
fi
local reboot_flag=()
[[ "$NO_FORCE_REBOOT" != true ]] && reboot_flag=(--force-reboot)
"$SEED_TOOL" --format iso --app incus --output "$local_path" \
--force-install "${reboot_flag[@]+"${reboot_flag[@]}"}" --hostname "$VM_NAME" \
--defaults "${seed_extra[@]+"${seed_extra[@]}"}" --quiet &>/dev/null
log "Seed: standard incusos-seed output (force_reboot=$([[ "$NO_FORCE_REBOOT" == true ]] && echo false || echo true))"
fi
success "Seed ISO: ${seed_name}"
# Upload to Proxmox
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() {
step "Creating VM (VMID ${VMID}, name ${VM_NAME})"
local json_payload
json_payload=$(python3 -c "
import json
payload = {
'vmid': ${VMID},
'name': '${VM_NAME}',
'description': '[incusos-lab:managed] observe-deploy',
'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}' + (',tag=${PVE_VLAN}' if '${PVE_VLAN}' else ''),
'boot': 'order=ide2;scsi0',
'agent': 1,
}
pool = '${PVE_POOL}'
if pool:
payload['pool'] = pool
# Apply --vm-extra overrides (e.g. 'scsi0=local-zfs:50,cache=writethrough,iothread=1')
vm_extra = '${VM_EXTRA}'
if vm_extra:
for pair in vm_extra.split(',,,'): # triple-comma separator for multi-value pairs
if '=' in pair:
k, v = pair.split('=', 1)
# Convert numeric strings to ints for JSON
try:
v = int(v)
except (ValueError, TypeError):
pass
payload[k.strip()] = v
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) || {
error "Failed to create VM"
exit 1
}
# Wait for async task
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
success "VM created"
}
cleanup_vm() {
step "Cleaning up VM ${VMID}"
local status
status=$(pve_vm_status "$VMID" 2>/dev/null) || status="unknown"
if [[ "$status" == "running" ]]; then
pve_stop_vm "$VMID" || true
sleep 3
fi
pve_destroy_vm "$VMID" || true
# Remove seed ISO from Proxmox 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" == "$VM_NAME" ]]; then
incus remote switch local 2>/dev/null || true
fi
incus remote remove "$VM_NAME" 2>/dev/null || true
fi
# Cleanup temp files
rm -rf "${SEED_TMPDIR:-}" 2>/dev/null || true
rm -f "/tmp/vm-${VMID}-screen.ppm" 2>/dev/null || true
success "Cleanup complete"
}
# ---------------------------------------------------------------------------
# Install monitoring (inline blockstat check)
# ---------------------------------------------------------------------------
check_install_progress() {
# Called periodically during the ISO boot phase to detect when
# installation completes. Updates PHASE when install is done.
# Returns immediately — does not block.
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"
local wr_mb=$(( wr_bytes / 1048576 ))
log "Install started — disk writes detected (${wr_mb} MiB)"
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 — disk activity stopped (${wr_mb} MiB total, ${STABLE_COUNT} stable polls)"
PHASE="install_done"
fi
fi
PREV_WR_BYTES=$wr_bytes
}
# ---------------------------------------------------------------------------
# Main deploy loop
# ---------------------------------------------------------------------------
run_deploy() {
local deploy_start
deploy_start=$(date +%s)
# --- Phase 1: Boot from ISO ---
step "Starting VM (ISO boot + screenshot capture)"
pve_start_vm "$VMID"
PHASE="iso_boot"
log "VM started — phase: iso_boot"
local elapsed=0
local last_blockstat_check=0
# Screenshot + monitor loop during ISO boot and installation
while [[ "$PHASE" == "iso_boot" ]] || [[ "$PHASE" == "installing" ]]; do
take_frame || true
# Check blockstat every INSTALL_POLL seconds (not every screenshot)
local now
now=$(date +%s)
if [[ $((now - last_blockstat_check)) -ge $INSTALL_POLL ]]; then
check_install_progress
last_blockstat_check=$now
fi
sleep "$SCREENSHOT_INTERVAL"
elapsed=$((elapsed + SCREENSHOT_INTERVAL))
# Check if VM stopped on its own (force_reboot caused guest reboot,
# but QEMU uptime doesn't reset — VM stays "running". If it's
# actually stopped, something went wrong.)
local status
status=$(pve_vm_status "$VMID" 2>/dev/null) || status="unknown"
if [[ "$status" == "stopped" ]]; then
log "VM stopped unexpectedly during install"
PHASE="install_done"
fi
if [[ $elapsed -ge $INSTALL_TIMEOUT ]]; then
log "Install timeout after ${INSTALL_TIMEOUT}s"
warn "Installation did not complete within ${INSTALL_TIMEOUT}s"
# Take a final frame for diagnosis
take_frame || true
PHASE="failed"
break
fi
# Progress logging every 30s
if [[ $((elapsed % 30)) -lt $SCREENSHOT_INTERVAL ]]; then
local wr_mb=$(( PREV_WR_BYTES / 1048576 ))
log "Progress: ${elapsed}s elapsed, ${wr_mb} MiB written, phase=${PHASE}, frame=${FRAME_NUM}"
fi
done
if [[ "$PHASE" == "failed" ]]; then
return 1
fi
# --- Phase 2: Transition to disk boot ---
PHASE="transitioning"
log "Transitioning: stop VM, remove install media, start from disk"
# Take a few more frames during transition
take_frame || true
# Stop the VM
step "Stopping VM for media removal"
pve_stop_vm "$VMID" || true
# Wait for VM to actually stop
local stop_wait=0
while [[ $stop_wait -lt 30 ]]; do
local status
status=$(pve_vm_status "$VMID" 2>/dev/null) || status="unknown"
if [[ "$status" == "stopped" ]]; then
break
fi
sleep 2
stop_wait=$((stop_wait + 2))
done
log "VM stopped after ${stop_wait}s"
# Remove install media and set boot order
step "Removing install media (ide2, ide3) and setting boot to scsi0"
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 "Install media removed, boot order set to scsi0"
# --- Phase 3: Boot from disk ---
step "Starting VM from disk (first boot)"
pve_start_vm "$VMID"
PHASE="disk_boot"
log "VM started from disk — phase: disk_boot"
local boot_elapsed=0
local ip_detected=""
local ip_detect_started=false
# Screenshot loop during first boot
while [[ "$PHASE" == "disk_boot" ]]; do
take_frame || true
sleep "$SCREENSHOT_INTERVAL"
boot_elapsed=$((boot_elapsed + SCREENSHOT_INTERVAL))
# After 30s, start trying to detect IP
if [[ $boot_elapsed -ge 30 ]] && [[ "$ip_detect_started" != true ]]; then
ip_detect_started=true
log "Starting IP detection (${boot_elapsed}s into disk boot)"
fi
# Try IP detection every 10s after the initial 30s wait
if [[ "$ip_detect_started" == true ]] && [[ $((boot_elapsed % 10)) -lt $SCREENSHOT_INTERVAL ]]; then
local ip
ip=$(pve_vm_ip "$VMID" 2>/dev/null) || ip=""
if [[ -n "$ip" ]]; then
if [[ -z "$ip_detected" ]]; then
ip_detected="$ip"
log "IP detected: ${ip} (${boot_elapsed}s into disk boot)"
fi
# Check port 8443
if curl -sk --connect-timeout 3 "https://${ip}:8443" &>/dev/null; then
log "Port 8443 responding at ${ip} (${boot_elapsed}s into disk boot)"
DEPLOY_IP="$ip"
PHASE="ready"
break
else
log_quiet "Port 8443 not yet responding at ${ip}"
fi
fi
fi
if [[ $boot_elapsed -ge $BOOT_TIMEOUT ]]; then
log "Boot timeout after ${BOOT_TIMEOUT}s"
warn "Port 8443 not reachable after ${BOOT_TIMEOUT}s"
# Take final diagnostic frames
take_frame || true
PHASE="failed"
break
fi
# Progress logging every 15s
if [[ $((boot_elapsed % 15)) -lt $SCREENSHOT_INTERVAL ]]; then
log "Disk boot: ${boot_elapsed}s elapsed, ip=${ip_detected:-none}, phase=${PHASE}, frame=${FRAME_NUM}"
fi
done
# Take a few final frames after reaching ready state
if [[ "$PHASE" == "ready" ]]; then
for i in 1 2 3; do
sleep "$SCREENSHOT_INTERVAL"
take_frame || true
done
fi
# Definitive health check: query scrub_schedule via IncusOS REST API
if [[ "$PHASE" == "ready" ]] && [[ -n "$DEPLOY_IP" ]]; then
step "Checking scrub_schedule (definitive health indicator)"
if ! check_scrub_schedule "$DEPLOY_IP"; then
PHASE="crontab_bug"
log "Deploy reached port 8443 but scrub_schedule is empty — crontab bug"
fi
fi
local deploy_end
deploy_end=$(date +%s)
local total_seconds=$((deploy_end - deploy_start))
log "Deploy finished: phase=${PHASE}, scrub_schedule=${SCRUB_SCHEDULE:-unset}, total_time=${total_seconds}s, frames=${FRAME_NUM}"
if [[ "$PHASE" == "ready" ]]; then
return 0
else
return 1
fi
}
# ---------------------------------------------------------------------------
# Preflight checks
# ---------------------------------------------------------------------------
preflight() {
step "Preflight checks"
# Check required tools
local missing=()
command -v sshpass &>/dev/null || missing+=("sshpass")
command -v python3 &>/dev/null || missing+=("python3")
command -v curl &>/dev/null || missing+=("curl")
if [[ ${#missing[@]} -gt 0 ]]; then
error "Missing required tools: ${missing[*]}"
exit 1
fi
# Check PIL availability
if ! python3 -c "from PIL import Image" &>/dev/null; then
warn "python3 PIL/Pillow not available — screenshots will be saved as PPM (larger files)"
fi
# Check SSH connectivity
if ! sshpass -p "$PROXMOX_ROOT_PASSWORD" ssh "${SSH_OPTS[@]}" \
"root@${PVE_HOST}" "echo ok" &>/dev/null; then
error "Cannot SSH to root@${PVE_HOST}"
error "Check PROXMOX_ROOT_PASSWORD in env file and SSH access"
exit 1
fi
success "SSH to root@${PVE_HOST}: OK"
# Check VMID range (must be 850-869 per CLAUDE.md)
if [[ $VMID -lt 850 ]] || [[ $VMID -gt 869 ]]; then
error "VMID ${VMID} outside allowed test range (850-869)"
exit 1
fi
# Check VMID not in use
if pve_vm_exists "$VMID"; then
error "VMID ${VMID} already exists on Proxmox"
error "Clean it up first or use --vmid to pick a different one"
exit 1
fi
success "VMID ${VMID} available"
}
# ---------------------------------------------------------------------------
# Setup output directory
# ---------------------------------------------------------------------------
setup_output() {
local timestamp
timestamp=$(date '+%Y-%m-%d_%H-%M-%S')
if [[ -z "$LABEL" ]]; then
if [[ "$NO_UPDATE_YAML" == true ]]; then
LABEL="no-update-yaml"
else
LABEL="standard"
fi
fi
RUN_DIR="${BIN_DIR}/diagnostic/observe-runs/${timestamp}_${LABEL}"
mkdir -p "$RUN_DIR"
LOG_FILE="${RUN_DIR}/deploy.log"
touch "$LOG_FILE"
success "Output directory: ${RUN_DIR}"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
echo -e "${BOLD}${SCRIPT_NAME}${RESET} v${VERSION}"
echo -e "Observational single-VM deploy with rapid console screenshots"
echo ""
if [[ "$DRY_RUN" == true ]]; then
echo -e "${YELLOW}[dry-run] Preview mode — no VMs will be created${RESET}"
echo ""
echo "Configuration:"
echo " VMID: ${VMID}"
echo " VM name: ${VM_NAME}"
echo " Update.yaml: $(if [[ "$NO_UPDATE_YAML" == true ]]; then echo "OMITTED"; else echo "included (6h)"; fi)"
echo " Screenshot interval: ${SCREENSHOT_INTERVAL}s"
echo ""
step "Would load proxmox.yaml"
load_proxmox_yaml
detail "Host: ${PVE_HOST}, Node: ${PVE_NODE}, Storage: ${PVE_STORAGE}"
detail "Pool: ${PVE_POOL}, VLAN: ${PVE_VLAN:-none}, Token: ${PVE_API_TOKEN_ID}"
echo ""
step "Would check:"
detail "- SSH to root@${PVE_HOST} for screenshots"
detail "- VMID ${VMID} availability"
detail "- IncusOS ISO on Proxmox"
detail "- PIL/Pillow for PPM→PNG conversion"
echo ""
step "Would execute:"
detail "1. Generate seed ISO ($(if [[ "$NO_UPDATE_YAML" == true ]]; then echo "no update.yaml"; else echo "standard"; fi))"
detail "2. Upload seed to Proxmox"
detail "3. Create VM ${VMID} (${VM_NAME})"
detail "4. Start VM, begin screenshot loop (every ${SCREENSHOT_INTERVAL}s)"
detail "5. Monitor blockstat for install completion"
detail "6. Stop VM, remove media, start from disk"
detail "7. Continue screenshots until port 8443 responds (or ${BOOT_TIMEOUT}s timeout)"
detail "8. Check scrub_schedule via IncusOS REST API"
detail "9. Save frames + deploy.log"
detail "10. Cleanup VM"
if [[ $RUNS -gt 1 ]]; then
detail "Repeat ${RUNS} times with aggregate statistics"
fi
echo ""
info "Dry run complete"
exit 0
fi
# Load configuration
step "Loading configuration"
load_proxmox_yaml
detail "Host: ${PVE_HOST}, Node: ${PVE_NODE}, Storage: ${PVE_STORAGE}, VLAN: ${PVE_VLAN:-none}"
if [[ -n "$STATIC_IP" ]]; then
detail "Static IP: ${STATIC_IP}, Gateway: ${PVE_GATEWAY:-MISSING}"
if [[ -z "$PVE_GATEWAY" ]]; then
error "--ip requires 'gateway' in proxmox.yaml"
exit 1
fi
fi
# Run preflight checks
preflight
# Find IncusOS ISO
find_incusos_iso
# Trap for clean exit
trap_cleanup() {
echo ""
warn "Interrupted — cleaning up"
cleanup_vm
exit 130
}
trap trap_cleanup INT TERM
# ---------------------------------------------------------------------------
# Run loop (single run or multi-run with --runs N)
# ---------------------------------------------------------------------------
run_single_cycle() {
# Runs one complete deploy+destroy cycle. Returns 0 on success, 1 on failure.
# Sets globals: PHASE, SCRUB_SCHEDULE, DEPLOY_IP, FRAME_NUM
# Reset state for this run
PHASE="init"
PREV_WR_BYTES=0
WRITES_STARTED=false
STABLE_COUNT=0
SCRUB_SCHEDULE=""
DEPLOY_IP=""
FRAME_NUM=0
# Setup per-run output directory
setup_output
# Log configuration
log "=== observe-deploy v${VERSION} ==="
log "VMID: ${VMID}"
log "VM name: ${VM_NAME}"
log "ISO: ${ISO_FILENAME}"
log "Update.yaml: $(if [[ "$NO_UPDATE_YAML" == true ]]; then echo "OMITTED"; else echo "included (6h)"; fi)"
log "Screenshot interval: ${SCREENSHOT_INTERVAL}s"
log "Install timeout: ${INSTALL_TIMEOUT}s"
log "Boot timeout: ${BOOT_TIMEOUT}s"
# Generate and upload seed
generate_seed
# Create VM
create_vm
echo ""
echo -e "${BOLD}Starting observational deploy${RESET}"
echo " Frames saved to: ${RUN_DIR}/"
echo " Log: ${RUN_DIR}/deploy.log"
echo " Press Ctrl+C to abort (VM will be cleaned up)"
echo ""
# Run the deploy with screenshots
local result=0
run_deploy || result=$?
echo ""
echo -e "${BOLD}========================================${RESET}"
echo -e "${BOLD} Deploy Results${RESET}"
echo -e "${BOLD}========================================${RESET}"
echo ""
echo " VMID: ${VMID}"
echo " Seed: $(if [[ "$NO_UPDATE_YAML" == true ]]; then echo "no update.yaml"; else echo "standard (check_frequency=6h)"; fi)"
if [[ $result -eq 0 ]]; then
echo -e " Outcome: ${GREEN}PASS${RESET} — port 8443 reachable, scrub_schedule='${SCRUB_SCHEDULE}'"
elif [[ "$PHASE" == "crontab_bug" ]]; then
echo -e " Outcome: ${RED}FAIL${RESET} — crontab bug (port 8443 up, scrub_schedule empty)"
else
echo -e " Outcome: ${RED}FAIL${RESET} — phase: ${PHASE}"
fi
echo " Frames: ${FRAME_NUM}"
echo " Output: ${RUN_DIR}/"
echo ""
# Cleanup
step "Post-deploy cleanup"
cleanup_vm
return $result
}
if [[ $RUNS -le 1 ]]; then
# Single-run mode (original behavior)
deploy_result=0
run_single_cycle || deploy_result=$?
if [[ $deploy_result -eq 0 ]]; then
success "Observational deploy complete — ${FRAME_NUM} frames captured"
else
warn "Deploy failed at phase: ${PHASE}"
warn "Check screenshots and log for diagnosis:"
warn " ${RUN_DIR}/deploy.log"
warn " ${RUN_DIR}/frame-*.png"
fi
echo ""
echo "To view key frames:"
echo " ls ${RUN_DIR}/frame-*.png"
echo " # View in image viewer or analyze with Claude Code"
echo ""
exit $deploy_result
else
# Multi-run mode: N sequential deploy+destroy cycles with aggregate stats
echo ""
echo -e "${BOLD}Multi-run mode: ${RUNS} sequential deploy cycles${RESET}"
echo ""
# Compute base label before the loop (LABEL may be empty at this point)
if [[ -z "$LABEL" ]]; then
if [[ "$NO_UPDATE_YAML" == true ]]; then
BASE_LABEL="no-update-yaml"
else
BASE_LABEL="standard"
fi
else
BASE_LABEL="$LABEL"
fi
total_pass=0
total_fail=0
total_crontab_bug=0
total_timeout=0
total_time=0
run_results=() # Array of "PASS|FAIL|BUG" per run
overall_start=$(date +%s)
run_num=0
while [[ $run_num -lt $RUNS ]]; do
run_num=$((run_num + 1))
echo ""
echo -e "${BOLD}╔══════════════════════════════════════╗${RESET}"
echo -e "${BOLD}║ Run ${run_num}/${RUNS}${RESET}"
echo -e "${BOLD}╚══════════════════════════════════════╝${RESET}"
echo ""
# Set per-run label
LABEL="${BASE_LABEL}_run-$(printf '%02d' "$run_num")"
local_start=$(date +%s)
deploy_result=0
run_single_cycle || deploy_result=$?
local_end=$(date +%s)
local_duration=$((local_end - local_start))
total_time=$((total_time + local_duration))
if [[ $deploy_result -eq 0 ]]; then
total_pass=$((total_pass + 1))
run_results+=("PASS")
success "Run ${run_num}/${RUNS}: PASS (${local_duration}s)"
elif [[ "$PHASE" == "crontab_bug" ]]; then
total_fail=$((total_fail + 1))
total_crontab_bug=$((total_crontab_bug + 1))
run_results+=("BUG")
warn "Run ${run_num}/${RUNS}: FAIL — crontab bug (${local_duration}s)"
else
total_fail=$((total_fail + 1))
total_timeout=$((total_timeout + 1))
run_results+=("FAIL")
warn "Run ${run_num}/${RUNS}: FAIL — ${PHASE} (${local_duration}s)"
fi
# Brief pause between runs to let Proxmox settle
if [[ $run_num -lt $RUNS ]]; then
info "Pausing 10s before next run..."
sleep 10
fi
done
overall_end=$(date +%s)
overall_duration=$((overall_end - overall_start))
avg_time=$((total_time / RUNS))
echo ""
echo -e "${BOLD}╔══════════════════════════════════════════════════╗${RESET}"
echo -e "${BOLD}║ Multi-Run Summary (${RUNS} cycles)${RESET}"
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${RESET}"
echo ""
echo -e " ${GREEN}PASS:${RESET} ${total_pass}/${RUNS}"
echo -e " ${RED}FAIL:${RESET} ${total_fail}/${RUNS}"
if [[ $total_crontab_bug -gt 0 ]]; then
echo -e " ${RED}crontab bug:${RESET} ${total_crontab_bug}"
fi
if [[ $total_timeout -gt 0 ]]; then
echo -e " ${RED}timeout/other:${RESET} ${total_timeout}"
fi
echo ""
echo " Avg time: ${avg_time}s per run"
echo " Total time: ${overall_duration}s"
echo ""
# Per-run results
echo " Per-run: ${run_results[*]}"
echo ""
# Success rate
if [[ $RUNS -gt 0 ]]; then
pct=$((total_pass * 100 / RUNS))
echo -e " Success rate: ${pct}%"
if [[ $pct -ge 95 ]]; then
echo -e " ${GREEN}Target met (≥95%)${RESET}"
else
echo -e " ${RED}Below target (<95%)${RESET}"
fi
fi
echo ""
# Output directories
echo " Output: ${BIN_DIR}/diagnostic/observe-runs/*${BASE_LABEL}*"
echo ""
if [[ $total_fail -gt 0 ]]; then
exit 1
fi
exit 0
fi