Add observe-deploy diagnostics, boot failure analysis, and deploy reliability fixes

Observational single-VM deploy tool (observe-deploy) captures frame-by-frame
console screenshots via Proxmox SSH root screendump to diagnose IncusOS boot
failures. Four deploys revealed ~50% "invalid crontab expression" hit rate
with no correlation to update.yaml presence.

Deep analysis of IncusOS source code (main.go, state/, scheduling/, install.go)
traced the full crash path: registerJobs() validates ScrubSchedule via gocron
at step 19 of startup(), after applications start at step 16 and REST API
starts even earlier. Most likely a race condition or gocron edge case.

Key fixes:
- incusos-proxmox: automatic retry on first-boot failure (stop+start cycle)
- incusos-proxmox: pve_destroy_vm now waits for async UPID task completion
- observe-deploy: same UPID wait for destroy, full screenshot pipeline
- incusos-seed: always generate update.yaml with explicit check_frequency
- CLAUDE.md: complete boot sequence timeline, source-level root cause analysis,
  proposed upstream fix for IncusOS registerJobs() defensive validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maarten 2026-02-21 13:42:51 +01:00
parent 00f57746ff
commit 1499dd7072
5 changed files with 1526 additions and 19 deletions

3
.gitignore vendored
View File

@ -31,3 +31,6 @@ proxmox.yaml
# Environment secrets # Environment secrets
env env
# Observational deploy output (screenshots, logs)
observe-runs/

215
CLAUDE.md
View File

@ -19,6 +19,8 @@ incu-contrib/
│ ├── incusos-seed # Seed archive generator (cross-platform: Linux + macOS) │ ├── incusos-seed # Seed archive generator (cross-platform: Linux + macOS)
│ ├── incusos-proxmox # Declarative Proxmox VM deployment + lab lifecycle │ ├── incusos-proxmox # Declarative Proxmox VM deployment + lab lifecycle
│ ├── lab-test # Guided lab validation (12 test phases) │ ├── lab-test # Guided lab validation (12 test phases)
│ ├── observe-deploy # Single-VM deploy with rapid console screenshots
│ ├── observe-runs/ # Screenshot output from observe-deploy (gitignored)
│ ├── proxmox.yaml # Proxmox connection config (gitignored, contains credentials) │ ├── proxmox.yaml # Proxmox connection config (gitignored, contains credentials)
│ ├── ../env # PROXMOX_TOKEN_SECRET (gitignored); source with: source env │ ├── ../env # PROXMOX_TOKEN_SECRET (gitignored); source with: source env
│ ├── TESTING.md # Testing guide for incusos-proxmox and lab-test │ ├── TESTING.md # Testing guide for incusos-proxmox and lab-test
@ -180,6 +182,23 @@ incu-contrib/
| 920-929 | OC server standalone | | 920-929 | OC server standalone |
| 930-939 | Advanced / heterogeneous | | 930-939 | Advanced / heterogeneous |
### Resource constraints for multi-lab
- **RAM is the bottleneck**: each IncusOS VM needs 4 GiB minimum. A 3-node
cluster = 12 GiB, OC lab (4 VMs) = 28 GiB. RAM is the only resource
where you can actually run out.
- **Storage is not a concern**: Proxmox ZFS uses thin provisioning by default
(`sparse` in storage.cfg). 3x 50 GiB VMs use ~7-8 GiB actual disk.
LZ4 compression provides ~1.5-1.8x ratio on OS data.
- **CPU is plentiful**: 4 cores per VM, 20 cores on host. Multiple labs
can share CPUs without contention.
- **Pre-deploy checks**: `incusos-proxmox` warns during preflight if
requested RAM exceeds available host RAM (API method only).
- **`--lab-up` auto-deploy**: if no VMs exist yet, `--lab-up` offers to
run the full deploy pipeline (auto-accepts with `--yes`).
- **`--resources` actual disk**: shows actual vs allocated disk for pool VMs
(via storage content API) and notes thin provisioning for ZFS/LVM-thin.
### Incus clustering via remotes ### Incus clustering via remotes
- **Cluster formation** is done entirely through the `incus` CLI using remotes. - **Cluster formation** is done entirely through the `incus` CLI using remotes.
@ -443,10 +462,10 @@ incu-contrib/
- **Inventory is NOT real-time** -- requires explicit `cluster resync` - **Inventory is NOT real-time** -- requires explicit `cluster resync`
- **OC reboot breaks OC-managed nodes on Proxmox** -- guest reboot is safe - **OC reboot breaks OC-managed nodes on Proxmox** -- guest reboot is safe
on standalone IncusOS (tested: simultaneous 3-node reboot, all recover in on standalone IncusOS (tested: simultaneous 3-node reboot, all recover in
~50s with data intact). The failure is OC-specific: the OC agent runs on ~50s with data intact). The failure is OC-specific: the OC agent pushes
boot and fails with errors like "invalid crontab expression" (bad update config via the IncusOS REST API that gets persisted to `state.txt`. On
config), "constraint violation" (re-registration of existing server), or reboot, invalid values (e.g., cron expression where Go duration is expected)
Incus stuck at "starting application" (waiting for peers in boot loops). crash the daemon. See "IncusOS boot failure" section below for full analysis.
Fix: destroy and redeploy OC-managed nodes. Proxmox stop/start is safe. Fix: destroy and redeploy OC-managed nodes. Proxmox stop/start is safe.
- **No cluster member state tracking** -- OC always shows `ready` even for - **No cluster member state tracking** -- OC always shows `ready` even for
EVACUATED/OFFLINE nodes EVACUATED/OFFLINE nodes
@ -472,6 +491,194 @@ incu-contrib/
output is concise (step names + results). `--quiet` suppresses everything output is concise (step names + results). `--quiet` suppresses everything
except warnings and errors. except warnings and errors.
### IncusOS first-boot sequence (observed via console screenshots)
Observed using `observe-deploy` with 2-second screenshot intervals. The
complete lifecycle from Proxmox VM start to port 8443 ready:
**Phase 1: ISO boot and installation (~60-85s)**
1. UEFI firmware → "Guest has not initialized the display (yet)" (~2s)
2. IncusOS boot menu: "IncusOS 202602200553" selected, "Boot in 1 s." (~10s)
3. IncusOS Install TUI: "Starting install of IncusOS to local disk" (~24s)
- "Installing IncusOS source=/dev/mapper/sr0 target=/dev/sda"
- "Cloning GPT partitions" → progress bar → complete
4. `force_reboot: true` triggers **SysRq-B** (raw kernel reboot via
`/proc/sysrq-trigger`, preceded by `unix.Sync()` + 5s sleep). Not
a graceful systemd reboot. This is intentional — the install
environment is minimal and may not have systemd running.
5. VM reboots but ISO still attached → boots from ISO again
6. "System check error: install media detected, but the system is already
installed; please remove USB/CDROM and reboot the system" (~40s).
IncusOS daemon sleeps **1 hour** at this error, then exits. The
`state.txt` on the installed disk (scsi0 data partition) is **NOT
touched** — this boot runs from the ISO rootfs (tmpfs).
7. Stays at this error until blockstat detects stable writes (3×5s = 15s idle)
**Phase 2: Transition (~15s)**
1. Proxmox stops VM
2. Remove ide2 (ISO) and ide3 (seed), set `boot: order=scsi0`
3. Start VM from disk
**Phase 3: First boot from disk (~50s to port 8443)**
1. UEFI → "IncusOS is starting..." (~8s)
2. `state.LoadOrCreate()` → state.txt doesn't exist → `initialize()` sets
defaults: `ScrubSchedule="0 4 * * 0"`, `CheckFrequency="6h"`,
`Channel="stable"``Save()` writes state.txt to data partition
3. "Auto-generating encryption recovery key, this may take a few seconds" (~10s)
4. "System is starting up machine-id=... mode=production" (~10s after key)
5. "Bringing up the network" (immediate)
6. "Downloading SecureBoot update" → "Applying Secure Boot certificate" (~12s)
7. "Downloading application update application=incus" + progress bar (~33-38s)
8. "Bringing up the local storage" (after download)
9. "Starting application" → "Initializing application" → TLS cert fingerprint
10. `registerJobs()` validates ScrubSchedule with gocron
11. "System is ready" — port 8443 now reachable
**Total: ~130-215s from VM create to port 8443 ready.** The biggest variable
is the application download (33-38s on first boot; skipped if already cached).
**Important**: `DoInstall()` does NOT create `state.txt`. It writes the
IncusOS image to disk, processes seed files (including deleting `install.yaml`
from the target's seed partition via `CleanupPostInstall()`), and triggers
the SysRq-B reboot. The `state.txt` is created exclusively on first boot
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.
- **Two distinct error paths in IncusOS**:
- `registerJobs()` validates `ScrubSchedule` (5-field crontab expression
like `"0 4 * * 0"`) using `gocron.IsValid()`. If invalid, the entire
daemon exits — **this is the fatal one**. Port 8443 never opens
(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):
- 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.
- "System is ready" is NOT shown on console (only appears after
`registerJobs()` succeeds).
- **Source code analysis** (root cause investigation):
- `state.txt` is created FRESH on first boot from disk by
`LoadOrCreate()``initialize()`. The `initialize()` function
correctly sets `ScrubSchedule = "0 4 * * 0"`.
- The "install media detected" boot (#2, ISO still attached) runs from
the ISO rootfs. It creates state.txt in tmpfs, NOT on the installed
disk. The installed disk's state.txt is untouched.
- `DoInstall()` does not create state.txt. It only writes the OS image
and processes seed files.
- **The encoder skips zero values**: `encodeHelper()` returns early on
`v.IsZero()`. An empty ScrubSchedule string is NOT written to file.
If state.txt is read with a missing ScrubSchedule, the field stays
at Go's zero value (empty string) → `gocron.IsValid("")` fails.
- **Non-atomic state writes**: `Save()` uses `os.WriteFile()` which
truncates then writes. Crash during write → partial file.
- **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.
- **Proposed upstream fix** (for `incus-os` project):
```go
// In registerJobs(): validate and fall back to default
func registerJobs(s *state.State) error {
schedule := s.System.Storage.Config.ScrubSchedule
if schedule == "" {
schedule = "0 4 * * 0"
s.System.Storage.Config.ScrubSchedule = schedule
}
err := s.JobScheduler.RegisterJob(zfs.PoolScrubJob, schedule, zfs.ScrubAllPools)
if err != nil {
// Fall back to default instead of crashing the daemon
slog.Warn("Invalid scrub schedule, using default", "schedule", schedule)
s.System.Storage.Config.ScrubSchedule = "0 4 * * 0"
return s.JobScheduler.RegisterJob(zfs.PoolScrubJob, "0 4 * * 0", zfs.ScrubAllPools)
}
return nil
}
```
Additional recommendations: (1) use atomic writes in `Save()` (write
to temp file + `os.Rename()`), (2) always encode `ScrubSchedule` even
when empty (to preserve the default through encode/decode cycles),
(3) add mutex protection on State fields shared between API handlers
and startup().
- **Recovery via restart**: `phase_install` in `incusos-proxmox` now
automatically retries once (stop → 10s wait → start → re-check).
After the first crash, the deferred `s.Save()` writes state. On the
second boot, systemd restarts the daemon; if the root cause was
transient (race condition or gocron edge case), the second boot
succeeds.
- **Detection**: `phase_install` checks port 8443 after starting from disk
(30s wait + up to 60s polling). If first check fails, automatic retry
adds ~100s. Failed VMs after retry are reported with remediation.
- **Source files**: `incus-osd/cmd/incus-osd/main.go` (`registerJobs()`,
`updateChecker()`, `startup()`, `firstBootActions()`),
`incus-osd/internal/scheduling/scheduling.go` (`RegisterJob()`,
`ErrInvalidCronTab`), `incus-osd/internal/state/file.go`
(`LoadOrCreate()`, `Save()`, `initialize()`),
`incus-osd/internal/state/encode.go` (`encodeHelper()` zero-value skip),
`incus-osd/internal/install/install.go` (`DoInstall()`,
`rebootUponDeviceRemoval()` SysRq-B).
### Proxmox SSH root access — strict rules
Root SSH access to the Proxmox host is available for **diagnostics only**.
The password is stored in the `env` file as `PROXMOX_ROOT_PASSWORD`.
**MANDATORY RULES — violation of any rule is unacceptable:**
1. **Screenshots only**: the ONLY permitted use of root SSH is taking VM
console screenshots via `qm monitor <vmid> screendump`. No other use
without explicit user instruction.
2. **Test VMs only**: only screenshot VMs in the test VMID range (850-869)
or VMs in the IncusLab pool that were created by our scripts.
3. **No modifications**: NEVER run any command that modifies, stops, starts,
or deletes any VM. No `qm set`, `qm stop`, `qm start`, `qm destroy`,
`pct` commands, `zfs` commands, `systemctl` commands, or ANY write
operation on the host.
4. **Do not touch the dev VM**: the user's dev VM runs on the same host.
Do not interact with it in any way — do not even `qm status` it.
5. **No config access**: do not read or modify `/etc/pve/`, storage configs,
network configs, user/ACL settings, or any host-level configuration.
6. **Transparency**: every SSH command is visible in tool output. If the
user rejects a command, do not retry it.
7. **Only during active tests**: only SSH during test runs where test VMs
have been deployed, and only to screenshot those test VMs.
**Permitted commands (exhaustive list):**
```bash
# Take screenshot of a test VM (VMID in 850-869 range)
sshpass -p "$PROXMOX_ROOT_PASSWORD" ssh -o StrictHostKeyChecking=no \
root@<host> "echo 'screendump /tmp/vm-<vmid>-screen.ppm' | qm monitor <vmid>"
# Retrieve screenshot
sshpass -p "$PROXMOX_ROOT_PASSWORD" scp -o StrictHostKeyChecking=no \
root@<host>:/tmp/vm-<vmid>-screen.ppm /tmp/
# Cleanup screenshot on remote
sshpass -p "$PROXMOX_ROOT_PASSWORD" ssh -o StrictHostKeyChecking=no \
root@<host> "rm -f /tmp/vm-<vmid>-screen.ppm"
```
**Technical notes:**
- QEMU's `screendump` is root-only in PVE 9's HMP permission model.
API tokens cannot execute it regardless of privileges.
- Use `.ppm` format — PNG (`-f png`) requires QEMU compiled with libpng,
which PVE 9.1 does **not** have (`Error: Enable PNG support with libpng`).
- PPM files are ~3 MB (1280x800). Convert to PNG with `python3-pil`:
`python3 -c "from PIL import Image; Image.open('f.ppm').save('f.png')"`
PNG output is ~20 KB and can be read directly by Claude Code (multimodal).
## Coding conventions for scripts ## Coding conventions for scripts
- **Shell**: bash with `set -euo pipefail` - **Shell**: bash with `set -euo pipefail`

View File

@ -1937,13 +1937,35 @@ pve_stop_vm() {
esac esac
} }
# Destroy a VM # Destroy a VM (waits for async task to complete)
pve_destroy_vm() { pve_destroy_vm() {
local vmid="$1" local vmid="$1"
case "$PVE_METHOD" in case "$PVE_METHOD" in
ssh) pve_run "qm destroy ${vmid} --purge" ;; ssh) pve_run "qm destroy ${vmid} --purge" ;;
api) pve_api DELETE "/nodes/${PVE_NODE}/qemu/${vmid}?purge=1" >/dev/null ;; api)
local destroy_response
destroy_response=$(pve_api DELETE "/nodes/${PVE_NODE}/qemu/${vmid}?purge=1&destroy-unreferenced-disks=1" 2>/dev/null) || return 1
# VM destruction is async -- wait for the UPID task to finish
# to avoid VMID/disk reuse issues in rapid destroy+create cycles.
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
;;
esac esac
} }
@ -2458,6 +2480,70 @@ for s in data.get('data', []):
errors=$((errors + 1)) errors=$((errors + 1))
fi fi
# 6. Host resource feasibility (API method only)
if [[ "$PVE_METHOD" == "api" ]] && [[ "$DRY_RUN" != true ]] && [[ ${#VM_MEMORY[@]} -gt 0 ]]; then
local total_ram_mib=0
local total_disk_gib=0
local total_cores=0
local ri=0
while [[ $ri -lt ${#VM_MEMORY[@]} ]]; do
total_ram_mib=$((total_ram_mib + VM_MEMORY[$ri]))
total_disk_gib=$((total_disk_gib + VM_DISK[$ri]))
total_cores=$((total_cores + VM_CORES[$ri]))
ri=$((ri + 1))
done
local res_node_status
res_node_status=$(pve_api GET "/nodes/${PVE_NODE}/status" 2>/dev/null) || res_node_status=""
if [[ -n "$res_node_status" ]]; then
local resource_lines
resource_lines=$(python3 -c "
import json, sys
data = json.loads('''${res_node_status}''').get('data', {})
mem = data.get('memory', {})
mem_total = mem.get('total', 0) / (1024**3)
mem_used = mem.get('used', 0) / (1024**3)
mem_free = mem_total - mem_used
cpu_count = data.get('cpuinfo', {}).get('cpus', 0)
req_ram = ${total_ram_mib} / 1024
req_cores = ${total_cores}
# RAM check
if mem_free > 0:
pct = req_ram / mem_free * 100
if req_ram > mem_free:
print(f'error|RAM: {req_ram:.1f} GiB requested, {mem_free:.1f} GiB free -- not enough RAM')
elif pct > 70:
print(f'warn|RAM: {req_ram:.1f} GiB requested, {mem_free:.1f} GiB free ({pct:.0f}% of free) -- tight fit')
else:
print(f'ok|RAM: {req_ram:.1f} GiB requested, {mem_free:.1f} GiB free ({pct:.0f}% of free)')
# CPU (info only)
print(f'ok|CPU: {req_cores} cores requested, {cpu_count} available')
" 2>/dev/null) || resource_lines=""
local line
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local level="${line%%|*}"
local msg="${line#*|}"
case "$level" in
error)
error "$msg"
errors=$((errors + 1))
;;
warn)
warn "$msg"
warnings=$((warnings + 1))
;;
ok)
detail "$msg"
;;
esac
done <<< "$resource_lines"
fi
fi
if [[ $errors -gt 0 ]]; then if [[ $errors -gt 0 ]]; then
error "${errors} pre-flight check(s) failed" error "${errors} pre-flight check(s) failed"
exit 1 exit 1
@ -2826,6 +2912,20 @@ confirm() {
fi fi
} }
# Like confirm(), but returns 0/1 instead of exiting on "no"
confirm_yn() {
local message="$1"
if [[ "$YES" == true ]]; then
return 0
fi
echo -n -e "${BOLD}${message} [y/N] ${RESET}"
local reply
read -r reply
[[ "$reply" =~ ^[Yy]$ ]]
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Phase 1: Validate # Phase 1: Validate
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -3130,6 +3230,7 @@ phase_install() {
echo "" echo ""
local count=${#VM_NAMES[@]} local count=${#VM_NAMES[@]}
local install_failures=0
local i=0 local i=0
while [[ $i -lt $count ]]; do while [[ $i -lt $count ]]; do
local name="${VM_NAMES[$i]}" local name="${VM_NAMES[$i]}"
@ -3306,28 +3407,92 @@ phase_install() {
continue continue
fi fi
# 5. Wait for services to come up # 5. Wait for services to come up and verify health
info "Waiting for services to start..." info "Waiting for services to start..."
sleep 30 sleep 30
# 6. Try to detect IP # 6. Detect IP and check port 8443
local ip local ip
ip=$(pve_vm_ip "$vmid") || ip="" ip=$(pve_vm_ip "$vmid") || ip=""
echo "" local healthy=false
if [[ -n "$ip" ]]; then if [[ -n "$ip" ]]; then
# Poll port 8443 for up to 60s (the 30s sleep above + 60s = 90s total)
local poll=0
while [[ $poll -lt 12 ]]; do
if curl -sk --connect-timeout 3 "https://${ip}:8443" &>/dev/null; then
healthy=true
break
fi
sleep 5
poll=$((poll + 1))
done
fi
echo ""
if [[ "$healthy" == true ]]; then
success "VM '${name}' installed and running at ${ip}" success "VM '${name}' installed and running at ${ip}"
register_remote "$name" "$ip" register_remote "$name" "$ip"
else else
success "VM '${name}' installed and running" # Retry once: IncusOS has an intermittent "invalid crontab expression"
info "Check Proxmox console for IP address" # bug (~50% first-boot hit rate) where registerJobs() fails during
# startup(). Most likely a race condition between the REST API and
# startup sequence, or a gocron library edge case. A stop+start
# cycle resolves it because the transient condition clears on retry.
if [[ -n "$ip" ]]; then
warn "VM '${name}': port 8443 not reachable at ${ip} — retrying (stop+start)"
else
warn "VM '${name}': could not detect IP — retrying (stop+start)"
fi
info "Stopping VM for retry..."
pve_stop_vm "$vmid" || true
sleep 10
info "Starting VM (retry)..."
pve_start_vm "$vmid" || true
sleep 30
# Re-detect IP (may have changed after restart)
ip=$(pve_vm_ip "$vmid") || ip=""
local retry_healthy=false
if [[ -n "$ip" ]]; then
local retry_poll=0
while [[ $retry_poll -lt 12 ]]; do
if curl -sk --connect-timeout 3 "https://${ip}:8443" &>/dev/null; then
retry_healthy=true
break
fi
sleep 5
retry_poll=$((retry_poll + 1))
done
fi
if [[ "$retry_healthy" == true ]]; then
success "VM '${name}' recovered after retry — running at ${ip}"
register_remote "$name" "$ip"
elif [[ -n "$ip" ]]; then
warn "VM '${name}' installed but port 8443 not reachable at ${ip} (even after retry)"
warn "Likely IncusOS boot failure (check Proxmox console for errors)"
warn "Redeploy this VM with: ${SCRIPT_NAME} --cleanup --yes ${CONFIG_FILE} && ${SCRIPT_NAME} --yes ${CONFIG_FILE}"
install_failures=$((install_failures + 1))
else
warn "VM '${name}' installed but could not detect IP (even after retry)"
warn "Check Proxmox console for status"
install_failures=$((install_failures + 1))
fi
fi fi
echo "" echo ""
i=$((i + 1)) i=$((i + 1))
done done
if [[ $install_failures -gt 0 ]]; then
warn "Installation phase complete: ${install_failures} VM(s) failed health check"
warn "Failed VMs are likely stuck in an IncusOS boot loop"
warn "Re-run deployment to redeploy, or use --cleanup to start fresh"
else
success "Installation phase complete" success "Installation phase complete"
fi
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -3393,6 +3558,48 @@ phase_lab_up() {
local count=${#VM_NAMES[@]} local count=${#VM_NAMES[@]}
local started=0 local started=0
local skipped=0 local skipped=0
local missing=0
# First pass: check how many VMs exist
if [[ "$DRY_RUN" != true ]]; then
local i=0
while [[ $i -lt $count ]]; do
if ! pve_vm_exists "${VM_VMIDS[$i]}"; then
missing=$((missing + 1))
fi
i=$((i + 1))
done
# If ALL VMs are missing, offer to deploy
if [[ $missing -eq $count ]]; then
echo ""
warn "No VMs found -- lab has not been deployed yet"
if confirm_yn "Deploy lab now?"; then
echo ""
phase_prepare
echo ""
phase_upload
echo ""
phase_create
echo ""
phase_install
echo ""
phase_post_deploy
return
else
info "Aborted"
return
fi
fi
# If SOME VMs are missing, warn but don't offer auto-deploy
if [[ $missing -gt 0 ]]; then
warn "${missing} of ${count} VMs not found -- partial state detected"
warn "Use --cleanup then redeploy, or deploy missing VMs manually"
fi
fi
# Second pass: start existing VMs
local i=0 local i=0
while [[ $i -lt $count ]]; do while [[ $i -lt $count ]]; do
local name="${VM_NAMES[$i]}" local name="${VM_NAMES[$i]}"
@ -3505,6 +3712,7 @@ print(f' Uptime: {days}d {hours}h')
python3 -c " python3 -c "
import json, sys import json, sys
data = json.loads('''${storage_list}''').get('data', []) data = json.loads('''${storage_list}''').get('data', [])
thin_types = {'zfspool', 'lvmthin'}
for s in sorted(data, key=lambda x: x.get('storage', '')): for s in sorted(data, key=lambda x: x.get('storage', '')):
name = s.get('storage', '?') name = s.get('storage', '?')
total = s.get('total', 0) / (1024**3) total = s.get('total', 0) / (1024**3)
@ -3513,10 +3721,11 @@ for s in sorted(data, key=lambda x: x.get('storage', '')):
pct = (used / total * 100) if total > 0 else 0 pct = (used / total * 100) if total > 0 else 0
stype = s.get('type', '?') stype = s.get('type', '?')
content = s.get('content', '?') content = s.get('content', '?')
thin = ' [thin]' if stype in thin_types else ''
if total > 0: if total > 0:
print(f' {name:<16} {stype:<12} {used:.1f}G / {total:.1f}G ({pct:.0f}%) -- {content}') print(f' {name:<16} {stype:<12} {used:.1f}G / {total:.1f}G ({pct:.0f}%) -- {content}{thin}')
else: else:
print(f' {name:<16} {stype:<12} (no capacity info) -- {content}') print(f' {name:<16} {stype:<12} (no capacity info) -- {content}{thin}')
" 2>/dev/null || warn "Failed to parse storage list" " 2>/dev/null || warn "Failed to parse storage list"
fi fi
@ -3540,6 +3749,57 @@ print(f' VMs: {len(vms)} total ({running} running, {stopped} stopped)')
print(f' Allocated: {total_mem:.1f} GiB RAM, {total_disk:.0f} GiB disk') print(f' Allocated: {total_mem:.1f} GiB RAM, {total_disk:.0f} GiB disk')
" 2>/dev/null || warn "Failed to parse pool data" " 2>/dev/null || warn "Failed to parse pool data"
fi fi
# Actual disk usage via storage content API
if [[ -n "$pool_data" ]]; then
local storage_content
storage_content=$(pve_api GET "/nodes/${PVE_NODE}/storage/${PVE_STORAGE}/content" 2>/dev/null) || storage_content=""
local storage_status_json
storage_status_json=$(echo "$storage_list" | python3 -c "
import json, sys
data = json.load(sys.stdin).get('data', [])
for s in data:
if s.get('storage') == '${PVE_STORAGE}':
print(json.dumps(s))
break
" 2>/dev/null) || storage_status_json=""
if [[ -n "$storage_content" ]] && [[ -n "$pool_data" ]]; then
python3 -c "
import json, sys
content = json.loads('''${storage_content}''').get('data', [])
pool = json.loads('''${pool_data}''').get('data', {})
vm_vmids = {str(m.get('vmid', '')) for m in pool.get('members', []) if m.get('type') == 'qemu'}
# Sum actual (used) bytes for volumes belonging to pool VMs
actual_bytes = 0
allocated_bytes = 0
for vol in content:
vmid = str(vol.get('vmid', ''))
if vmid in vm_vmids:
actual_bytes += vol.get('used', 0)
allocated_bytes += vol.get('size', 0)
if allocated_bytes > 0:
actual_gib = actual_bytes / (1024**3)
allocated_gib = allocated_bytes / (1024**3)
ratio = (actual_bytes / allocated_bytes * 100) if allocated_bytes > 0 else 0
print(f' Actual: {actual_gib:.1f} GiB disk used ({ratio:.0f}% of allocated, thin provisioned)')
" 2>/dev/null || true
fi
# Storage type note
if [[ -n "$storage_status_json" ]]; then
python3 -c "
import json
s = json.loads('''${storage_status_json}''')
stype = s.get('type', '')
notes = {'zfspool': 'ZFS (thin provisioned, lz4 compression)', 'lvmthin': 'LVM-thin (thin provisioned)'}
if stype in notes:
print(f' Storage: {notes[stype]}')
" 2>/dev/null || true
fi
fi
fi fi
echo "" echo ""

View File

@ -616,17 +616,13 @@ generate_network_yaml() {
} }
generate_update_yaml() { generate_update_yaml() {
# Only generate if non-default values were specified
if [[ "$CHANNEL" == "stable" ]] && [[ "$AUTO_REBOOT" == false ]]; then
return
fi
local file="${WORKDIR}/update.yaml" local file="${WORKDIR}/update.yaml"
step "Generating update.yaml" step "Generating update.yaml"
{ {
echo "# Update configuration" echo "# Update configuration"
echo "# Always generated to ensure explicit check_frequency on first boot"
echo "channel: \"${CHANNEL}\"" echo "channel: \"${CHANNEL}\""
echo "auto_reboot: ${AUTO_REBOOT}" echo "auto_reboot: ${AUTO_REBOOT}"
echo "check_frequency: \"6h\"" echo "check_frequency: \"6h\""

1041
incusos/observe-deploy Executable file

File diff suppressed because it is too large Load Diff