Files
esh-pfi-infrastructure/scripts/proxmox_inspect.sh
T
vh 852896120a scripts: proxmox_inspect.sh for VM/LXC/storage/backup discovery
Read-only companion to server_inspect.sh. Runs over ssh via stdin the
same way:

  ssh root@pfi-pve 'bash -s' < scripts/proxmox_inspect.sh \\
      > servers/pfi-pve/proxmox-details.txt

Captures what the generic inspect misses on PVE nodes:

- Cluster + node resources (pvesh get /cluster/resources)
- VM list + per-vmid config highlights (qm list, qm config)
- LXC list + per-ctid config highlights (pct list, pct config)
- Storage pools (pvesm status, zpool, storage.cfg, NFS mounts)
- Backup jobs table parsed from /etc/pve/jobs.cfg (no jq needed)
- Per-guest backup coverage verdict: YES/NO with reason
- Most-recent actual backup per VMID on each local dump storage
- PBS targets, replication jobs, listening ports, subscription status

Coverage analysis surfaces the real gap: on pfi-pve, VMIDs 106, 107,
109, 112, 113 have no scheduled backup; VMID 106 (PFI-Tailscale) has
not been backed up since 2024-05-31. nh3-pve uses "all" job and covers
everything. esh-pve + esh-pve-nas share the tank-vmbu dump storage via
NFS; coverage splits cleanly across the two nodes.

First snapshots committed for each PVE host.
2026-04-20 22:14:57 -07:00

349 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# proxmox_inspect.sh — read-only discovery probe for Proxmox VE hosts.
#
# Companion to server_inspect.sh. Where that script focuses on OS / docker
# detail, this one enumerates VMs, LXC containers, storage pools, and
# backup job coverage — the Proxmox-specific surface that the generic
# inspect misses.
#
# Run remotely via stdin the same way server_inspect.sh does:
#
# ssh pfi-pve 'bash -s' < scripts/proxmox_inspect.sh > servers/pfi-pve/proxmox-details.txt
#
# Output format: sectioned plain text, easy to `grep` / diff.
#
# Expects to run as root on a Proxmox node (that's the default SSH user
# on the fleet's PVE hosts). Safe to run repeatedly — no writes, no
# mutations. Missing tools just emit a note and continue.
set -u # fail on unset vars; keep going on command errors
have() { command -v "$1" >/dev/null 2>&1; }
hdr() { printf '\n===== %s =====\n\n' "$1"; }
sub() { printf '\n----- %s -----\n' "$1"; }
note() { printf '(%s)\n' "$*"; }
# ------------------------------------------------------------
# 0. sanity check — is this actually Proxmox?
# ------------------------------------------------------------
if ! have pveversion; then
echo "error: pveversion not found. This doesn't look like a Proxmox host." >&2
echo " Use scripts/server_inspect.sh for plain Linux hosts instead." >&2
exit 2
fi
# ------------------------------------------------------------
# 1. Host summary (mirrors server_inspect.sh top section)
# ------------------------------------------------------------
hdr "HOST"
printf 'Hostname: %s\n' "$(hostname -f 2>/dev/null || hostname)"
printf 'Date: %s\n' "$(date -Iseconds)"
printf 'Uptime: %s\n' "$(uptime -p 2>/dev/null || uptime)"
printf 'PVE: %s\n' "$(pveversion 2>/dev/null | head -1)"
printf 'Kernel: %s\n' "$(uname -r)"
printf 'Arch: %s\n' "$(uname -m)"
hdr "HARDWARE"
printf 'CPU model: %s\n' "$(awk -F': ' '/^model name/ {print $2; exit}' /proc/cpuinfo)"
printf 'CPU cores: %s\n' "$(nproc)"
printf 'MemTotal: %s\n' "$(awk '/^MemTotal/ {printf "%.1f GB", $2/1024/1024}' /proc/meminfo)"
printf 'MemAvail: %s\n' "$(awk '/^MemAvailable/ {printf "%.1f GB", $2/1024/1024}' /proc/meminfo)"
# ------------------------------------------------------------
# 2. Cluster status (single-node hosts print a polite note)
# ------------------------------------------------------------
hdr "CLUSTER"
if have pvecm; then
out=$(pvecm status 2>&1)
if grep -qi "cluster information" <<<"$out"; then
printf '%s\n' "$out"
else
note "single-node host (or pvecm returned no cluster info)"
fi
else
note "pvecm not available"
fi
# ------------------------------------------------------------
# 3. Node resources (one line per VM / LXC / node / storage)
# ------------------------------------------------------------
hdr "CLUSTER RESOURCES (pvesh)"
if have pvesh; then
# Plain text form is human-readable; JSON form is machine-parseable.
# We emit plain here and a JSON dump in the BACKUP COVERAGE section.
pvesh get /cluster/resources --output-format text 2>/dev/null || \
note "pvesh /cluster/resources failed"
else
note "pvesh not available"
fi
# ------------------------------------------------------------
# 4. VMs (KVM) — per-VM summary + config highlights
# ------------------------------------------------------------
hdr "VMs (qm list)"
if have qm; then
qm list 2>/dev/null || note "qm list failed"
hdr "VM CONFIGS (qm config per id)"
vmids=$(qm list 2>/dev/null | awk 'NR>1 {print $1}')
if [ -n "$vmids" ]; then
for id in $vmids; do
sub "VMID $id"
qm config "$id" 2>/dev/null | awk '
/^(name|cores|memory|balloon|net[0-9]+|scsi[0-9]+|virtio[0-9]+|ide[0-9]+|sata[0-9]+|ostype|onboot|agent|tags|boot|bootdisk|description):/
'
done
else
note "no VMs found"
fi
else
note "qm not available (not a Proxmox VE node?)"
fi
# ------------------------------------------------------------
# 5. LXC containers
# ------------------------------------------------------------
hdr "LXC CONTAINERS (pct list)"
if have pct; then
pct list 2>/dev/null || note "pct list failed"
hdr "LXC CONFIGS (pct config per id)"
ctids=$(pct list 2>/dev/null | awk 'NR>1 {print $1}')
if [ -n "$ctids" ]; then
for id in $ctids; do
sub "CTID $id"
pct config "$id" 2>/dev/null | awk '
/^(hostname|cores|memory|swap|net[0-9]+|rootfs|mp[0-9]+|onboot|ostype|unprivileged|features|tags|description):/
'
done
else
note "no LXC containers found"
fi
else
note "pct not available"
fi
# ------------------------------------------------------------
# 6. Storage — pools, usage, ZFS if present
# ------------------------------------------------------------
hdr "STORAGE (pvesm status)"
if have pvesm; then
pvesm status 2>/dev/null || note "pvesm status failed"
else
note "pvesm not available"
fi
hdr "DATASTORE CONFIG (/etc/pve/storage.cfg)"
if [ -r /etc/pve/storage.cfg ]; then
cat /etc/pve/storage.cfg
else
note "/etc/pve/storage.cfg not readable"
fi
hdr "ZFS POOLS (zpool list -v)"
if have zpool; then
zpool list -v 2>/dev/null || note "zpool list returned no pools"
else
note "no zpool on this host (not ZFS)"
fi
hdr "FILESYSTEMS (df -h, local fs only)"
df -hT -x tmpfs -x devtmpfs -x overlay -x squashfs 2>/dev/null | head -40
hdr "MOUNTED NFS / CIFS"
awk '$3 ~ /^(nfs|nfs4|cifs|smb)/ {print}' /proc/mounts || note "no network mounts"
# ------------------------------------------------------------
# 7. Backup jobs + coverage analysis
# ------------------------------------------------------------
hdr "BACKUP JOBS (/etc/pve/jobs.cfg + /etc/vzdump.conf)"
if [ -r /etc/pve/jobs.cfg ]; then
cat /etc/pve/jobs.cfg
else
note "/etc/pve/jobs.cfg not readable (no scheduled backups?)"
fi
sub "vzdump defaults"
if [ -r /etc/vzdump.conf ]; then
awk '!/^#/ && NF' /etc/vzdump.conf || note "vzdump.conf has no non-comment lines"
else
note "/etc/vzdump.conf not present"
fi
hdr "BACKUP COVERAGE ANALYSIS"
# Parse /etc/pve/jobs.cfg directly — it's always present on PVE nodes and
# doesn't need jq. Format is stanzas separated by blank lines; each stanza
# starts with `vzdump: <id>` and is followed by indented `key value` pairs.
covered_all=0
covered_ids=""
pool_jobs=""
excluded_ids=""
parse_jobs_cfg() {
# $1 = jobs.cfg path; $2 = "table" for human-readable, "data" for TSV.
awk -v mode="${2:-data}" '
BEGIN { RS=""; FS="\n" }
/^vzdump:/ {
jid="-"; sched="-"; all="0"; enabled="1"
vmids=""; excl=""; pool=""; storage="-"
for (i=1; i<=NF; i++) {
line=$i
if (line ~ /^vzdump:/) { split(line, a, " "); jid=a[2]; continue }
sub(/^[[:space:]]+/, "", line)
if (line == "") continue
key=line; val=""
sp=index(line, " ")
if (sp > 0) { key=substr(line,1,sp-1); val=substr(line,sp+1); sub(/^[[:space:]]+/, "", val) }
if (key=="schedule") sched=val
else if (key=="all") all=val
else if (key=="enabled") enabled=val
else if (key=="vmid") vmids=val
else if (key=="exclude") excl=val
else if (key=="pool") pool=val
else if (key=="storage") storage=val
}
if (mode == "table") {
printf "%-24s %-14s %-4s %-8s %-40s %-20s %s\n", jid, sched, all, enabled, (vmids==""?"-":vmids), (excl==""?"-":excl), storage
} else {
printf "%s\t%s\t%s\t%s\t%s\n", all, vmids, excl, pool, enabled
}
}
' "$1"
}
if [ -r /etc/pve/jobs.cfg ]; then
printf '%-24s %-14s %-4s %-8s %-40s %-20s %s\n' \
"JOB-ID" "SCHEDULE" "ALL" "ENABLED" "VMID-LIST" "EXCLUDE" "STORAGE"
parse_jobs_cfg /etc/pve/jobs.cfg table
while IFS=$'\t' read -r all vmids excl pool enabled; do
[ -z "${all:-}" ] && continue
[ "$enabled" = "0" ] && continue
[ "$all" = "1" ] && covered_all=1
[ -n "$vmids" ] && covered_ids="$covered_ids $(echo "$vmids" | tr ',' ' ')"
[ -n "$excl" ] && excluded_ids="$excluded_ids $(echo "$excl" | tr ',' ' ')"
[ -n "$pool" ] && pool_jobs="$pool_jobs $pool"
done < <(parse_jobs_cfg /etc/pve/jobs.cfg data)
else
note "/etc/pve/jobs.cfg not readable — no coverage data"
fi
# Per-guest coverage verdict
sub "per-guest coverage"
all_guest_ids=""
if have qm; then all_guest_ids="$all_guest_ids $(qm list 2>/dev/null | awk 'NR>1 {print $1}')"; fi
if have pct; then all_guest_ids="$all_guest_ids $(pct list 2>/dev/null | awk 'NR>1 {print $1}')"; fi
if [ -n "$all_guest_ids" ]; then
printf '%-6s %-10s %s\n' "ID" "VERDICT" "REASON"
for id in $all_guest_ids; do
verdict="NO"; reason="no job targets this id"
if [ "$covered_all" = "1" ]; then
verdict="YES"; reason="covered by 'all' job"
fi
for c in $covered_ids; do
if [ "$c" = "$id" ]; then verdict="YES"; reason="explicit vmid match"; break; fi
done
for x in $excluded_ids; do
if [ "$x" = "$id" ]; then verdict="NO"; reason="explicitly excluded"; break; fi
done
if [ -n "$pool_jobs" ] && [ "$verdict" = "NO" ]; then
reason="$reason (pool job exists: $pool_jobs — manual audit)"
fi
printf '%-6s %-10s %s\n' "$id" "$verdict" "$reason"
done
else
note "no guests on this node"
fi
# ------------------------------------------------------------
# 8. Most-recent dump files per guest (local dump storages)
# ------------------------------------------------------------
hdr "RECENT BACKUPS ON LOCAL DUMP STORAGES"
if [ -r /etc/pve/storage.cfg ]; then
# Find all storages flagged as dump-capable (content includes 'backup').
dump_paths=$(awk '
/^[a-z0-9_-]+: / { stname=$2; path=""; content="" }
/^[[:space:]]+path / { path=$2 }
/^[[:space:]]+content / { content=$0 }
/^$/ { if (path != "" && content ~ /backup/) print path; path=""; content="" }
END { if (path != "" && content ~ /backup/) print path }
' /etc/pve/storage.cfg)
if [ -z "$dump_paths" ]; then
note "no local dump storages configured (backups may target remote/PBS)"
else
for p in $dump_paths; do
sub "$p/dump/"
if [ -d "$p/dump" ]; then
# Latest actual backup (not .notes sidecar) per VMID.
# Filename pattern: vzdump-{qemu,lxc}-<VMID>-<YYYY_MM_DD>-<HH_MM_SS>.{vma,tar}.{zst,gz,lzo}
find "$p/dump" -maxdepth 1 -type f \( \
-name '*.vma.zst' -o -name '*.vma.gz' -o -name '*.vma.lzo' \
-o -name '*.tar.zst' -o -name '*.tar.gz' -o -name '*.tar.lzo' \
\) -printf '%T@ %p\n' 2>/dev/null | sort -nr | awk '
{
path=$2
# extract vmid from filename (3rd hyphen-delimited field after vzdump-)
fname=path; sub(/.*\//, "", fname)
n=split(fname, a, "-")
id=a[3]
if (!(id in seen)) {
seen[id]=1
# human date: extract date+time from filename
cmd="date -d @" $1 " +%Y-%m-%d\\ %H:%M"
cmd | getline when
close(cmd)
printf " VMID %-6s %s %s\n", id, when, fname
}
}
' | sort -k2
else
note "$p/dump does not exist"
fi
done
fi
fi
# ------------------------------------------------------------
# 9. Proxmox Backup Server targets
# ------------------------------------------------------------
hdr "PBS TARGETS"
if [ -r /etc/pve/storage.cfg ]; then
awk '/^pbs:/ {show=1; print; next} /^[a-z]/ {show=0} show {print}' /etc/pve/storage.cfg
fi
if [ -z "$(awk '/^pbs:/ {print}' /etc/pve/storage.cfg 2>/dev/null)" ]; then
note "no PBS storage configured on this node"
fi
# ------------------------------------------------------------
# 10. Replication (cluster-only)
# ------------------------------------------------------------
hdr "REPLICATION JOBS (pvesr status)"
if have pvesr; then
pvesr status 2>/dev/null | head -40 || note "pvesr status failed (single-node?)"
else
note "pvesr not available"
fi
# ------------------------------------------------------------
# 11. Listening ports (quick attack surface glance)
# ------------------------------------------------------------
hdr "LISTENING PORTS"
if have ss; then
ss -ltn 2>/dev/null | awk 'NR>1 {print $4}' | sort -u
fi
# ------------------------------------------------------------
# 12. Subscription + updates
# ------------------------------------------------------------
hdr "SUBSCRIPTION / UPDATES"
if have pvesubscription; then
pvesubscription get 2>/dev/null | grep -E '^(status|productname|key):' || note "pvesubscription returned nothing"
fi
sub "apt upgradable (top 20)"
apt list --upgradable 2>/dev/null | grep -v '^Listing' | head -20 || note "apt not available"
hdr "DONE"
echo "Review this output for backup gaps (VERDICT=NO in coverage section)"
echo "and anomalies in storage / PBS / replication sections."