update beta 1.2.2.2

This commit is contained in:
MacRimi
2026-06-09 00:13:24 +02:00
parent 6844406cf7
commit 61ff665cec
30 changed files with 5510 additions and 396 deletions

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux backup manifest orchestrator
# ==========================================================
# Composes the six collectors into one manifest.json that
# validates against schema/manifest.schema.json. Designed to
# be called by backup_host.sh during a backup run. Read-only
# (no side effects on the host).
#
# Usage:
# build_manifest.sh [--paths-archived <path1> <path2> ...]
# build_manifest.sh --validate (re-runs the JSON Schema validation)
#
# Stdout: pretty-printed manifest JSON.
# Stderr: progress + warnings.
# ==========================================================
set -euo pipefail
COLLECTORS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCHEMA_FILE="$COLLECTORS_DIR/../schema/manifest.schema.json"
# Parse flags
paths_archived='null'
do_validate=0
while [[ $# -gt 0 ]]; do
case "$1" in
--paths-archived)
shift
tmp='[]'
while [[ $# -gt 0 && "$1" != --* ]]; do
tmp="$(jq --argjson a "$tmp" --arg p "$1" -n '$a + [$p]')"
shift
done
paths_archived="$tmp"
;;
--validate)
do_validate=1; shift ;;
-h|--help)
sed -nE '/^# Usage:/,/^# Stderr:/p' "$0" | sed -E 's/^# ?//' >&2
exit 0
;;
*) shift ;;
esac
done
# Run each collector. If a collector fails we fall back to a safe default
# (empty array / null object) and warn — the manifest is still useful even
# if one section is incomplete.
run_collector() {
local name="$1" fallback="$2"
local out
if out="$(bash "$COLLECTORS_DIR/$name" 2>>/tmp/proxmenux-manifest-stderr.log)"; then
printf '%s' "$out"
else
printf 'warning: collector %s failed; using fallback\n' "$name" >&2
printf '%s' "$fallback"
fi
}
# Empty error log first so we can attribute failures to this run.
: >/tmp/proxmenux-manifest-stderr.log
source_host="$(run_collector collect_source_host.sh '{}')"
hardware_inventory="$(run_collector collect_hardware.sh '{"gpu":[],"tpu":[],"nic":[],"wireless":[]}')"
storage_inventory="$(run_collector collect_storage.sh '{"zfs_pools":[],"lvm":{"vgs":[]},"physical_disks":[],"pve_storage_cfg":[],"mounts":[]}')"
installed_components="$(run_collector collect_proxmenux_state.sh '[]')"
kernel_params="$(run_collector collect_kernel.sh '{"cmdline_extra":[],"modules_loaded_at_boot":[],"modprobe_d_files":[]}')"
guests="$(run_collector collect_guests.sh '{"vms":[],"lxcs":[]}')"
created_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# Compose the final manifest. The wrapper key matches the schema:
# the top level is a single "proxmenux_backup_manifest" object.
manifest="$(jq -n \
--arg created_at "$created_at" \
--arg created_by "proxmenux-host-backup/1.3.0" \
--argjson source_host "$source_host" \
--argjson hardware "$hardware_inventory" \
--argjson storage "$storage_inventory" \
--argjson components "$installed_components" \
--argjson kernel "$kernel_params" \
--argjson guests "$guests" \
--argjson paths_archived "$paths_archived" \
'{
proxmenux_backup_manifest: {
schema_version: 1,
created_at: $created_at,
created_by: $created_by,
source_host: $source_host,
hardware_inventory: $hardware,
storage_inventory: $storage,
proxmenux_installed_components: $components,
kernel_params: $kernel,
vms_lxcs_at_backup: $guests,
backup_metadata: {
encrypted: false,
encryption_format: null,
compression: "zstd",
paths_archived: $paths_archived,
sha256_archive: null,
size_bytes: null
}
}
}')"
# Optional validation step. If python3 + jsonschema are available, run
# them; otherwise silently skip (validation is mostly a developer aid).
if [[ "$do_validate" == 1 ]]; then
if command -v python3 >/dev/null 2>&1 && python3 -c 'import jsonschema' 2>/dev/null; then
printf '%s' "$manifest" | python3 -c "
import json, sys, jsonschema
schema = json.load(open('$SCHEMA_FILE'))
inst = json.load(sys.stdin)
try:
jsonschema.validate(instance=inst, schema=schema)
print('manifest: validates against schema', file=sys.stderr)
except jsonschema.exceptions.ValidationError as e:
print(f'manifest: SCHEMA VIOLATION at {list(e.absolute_path)}: {e.message}', file=sys.stderr)
sys.exit(1)
"
else
printf 'manifest: jsonschema python module not present; skipping validation\n' >&2
fi
fi
printf '%s\n' "$manifest"

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux backup manifest collector — vms_lxcs_at_backup
# ==========================================================
# Enumerates VMs (qm list) and LXCs (pct list) on this PVE node.
# Read-only; emits the metadata only — actual VM/LXC data is
# the responsibility of vzdump / PBS, not this manifest.
# Schema: scripts/backup_restore/schema/manifest.schema.json
# ==========================================================
set -euo pipefail
vms='[]'
lxcs='[]'
# ── VMs (qm list) ──
# Output:
# VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID
# 100 Alpine-Linux-3-21 stopped 4096 0.00 0
# Header line starts with VMID; we skip it.
if command -v qm >/dev/null 2>&1; then
while IFS= read -r line; do
[[ -z "$line" ]] && continue
# Skip the header
[[ "$line" =~ ^[[:space:]]*VMID[[:space:]] ]] && continue
# Parse positionally. NAME can contain spaces, but `qm list` pads/columns
# them, so we use fixed positions: VMID at col 1, STATUS as the 3rd
# whitespace-delimited token from the END (mem, bootdisk, pid are after).
vmid="$(printf '%s' "$line" | awk '{print $1}')"
[[ "$vmid" =~ ^[0-9]+$ ]] || continue
# Strip trailing PID + BOOTDISK + MEM(MB) + STATUS to extract the NAME.
# rev → cut → rev technique:
trailing="$(printf '%s' "$line" | awk '{printf "%s %s %s %s", $(NF-3), $(NF-2), $(NF-1), $NF}')"
status="$(printf '%s' "$trailing" | awk '{print $1}')"
memory_mb="$(printf '%s' "$trailing" | awk '{print $2}')"
bootdisk_gb="$(printf '%s' "$trailing" | awk '{print $3}')"
# Name: drop first column (vmid) and last 4 columns
name="$(printf '%s' "$line" | awk '{$1=""; for(i=NF-3;i<=NF;i++) $i=""; sub(/^[[:space:]]+/,""); sub(/[[:space:]]+$/,""); print}')"
case "$status" in
running|stopped|paused) ;;
*) status="stopped" ;;
esac
vms="$(jq --argjson acc "$vms" \
--argjson vmid "$vmid" \
--arg name "$name" \
--argjson memory_mb "${memory_mb:-0}" \
--argjson bootdisk_gb "${bootdisk_gb:-0}" \
--arg status "$status" \
-n '
$acc + [{
vmid: $vmid,
name: $name,
memory_mb: $memory_mb,
bootdisk_gb: $bootdisk_gb,
status: $status,
config_file: ("configs/qemu-server/" + ($vmid|tostring) + ".conf")
}]
')"
done < <(qm list 2>/dev/null || true)
fi
# ── LXCs (pct list) ──
# Output:
# VMID Status Lock Name
# 101 running alpine
# Header line starts with VMID; we skip it.
if command -v pct >/dev/null 2>&1; then
while IFS= read -r line; do
[[ -z "$line" ]] && continue
[[ "$line" =~ ^[[:space:]]*VMID[[:space:]] ]] && continue
vmid="$(printf '%s' "$line" | awk '{print $1}')"
[[ "$vmid" =~ ^[0-9]+$ ]] || continue
status="$(printf '%s' "$line" | awk '{print $2}')"
# Lock column is sparse; name is always last positional non-empty token
name="$(printf '%s' "$line" | awk '{print $NF}')"
case "$status" in
running|stopped) ;;
*) status="stopped" ;;
esac
lxcs="$(jq --argjson acc "$lxcs" \
--argjson vmid "$vmid" \
--arg name "$name" \
--arg status "$status" \
-n '
$acc + [{
vmid: $vmid,
name: $name,
status: $status,
config_file: ("configs/lxc/" + ($vmid|tostring) + ".conf")
}]
')"
done < <(pct list 2>/dev/null || true)
fi
jq -n --argjson vms "$vms" --argjson lxcs "$lxcs" \
'{ vms: $vms, lxcs: $lxcs }'

View File

@@ -0,0 +1,222 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux backup manifest collector — hardware_inventory
# ==========================================================
# Detects GPUs (with vendor → ProxMenux installer mapping),
# TPUs (Coral PCIe/USB), NICs (with bridge membership), and
# Wireless interfaces. Read-only. Schema:
# scripts/backup_restore/schema/manifest.schema.json
# ==========================================================
set -euo pipefail
# Vendor → installer path mapping. Update when ProxMenux adds new
# installers for hardware that depends on out-of-tree drivers.
# Vendors WITHOUT a mapping get null (e.g. Intel/AMD iGPUs work with
# in-tree drivers, no special installer needed).
gpu_installer_for() {
case "$1" in
NVIDIA) echo "scripts/gpu_tpu/nvidia_installer.sh" ;;
*) echo "" ;;
esac
}
# ── GPUs ──
# lspci -nnD outputs:
# 0000:01:00.0 VGA compatible controller [0300]: NVIDIA Corporation GP107GL [Quadro P620] [10de:1cb6] (rev a1)
# We pick anything classified as VGA/3D/Display (display controllers).
gpu_array='[]'
while IFS= read -r line; do
[[ -z "$line" ]] && continue
pci_address="$(printf '%s' "$line" | awk '{print $1}')"
pci_id="$(printf '%s' "$line" | grep -oE '\[[0-9a-f]{4}:[0-9a-f]{4}\]' | tail -1 | tr -d '[]')"
# Description: everything between the "controller]:" header and the
# final "[pci_id]" tag. For AMD this includes the [AMD/ATI] tag; for
# NVIDIA/Intel it's just vendor + model.
desc="$(printf '%s' "$line" | sed -nE "s@.*\]:[[:space:]]*(.*)[[:space:]]+\[[0-9a-f]{4}:[0-9a-f]{4}\].*@\1@p")"
# Vendor classification
case "$desc" in
*NVIDIA*) vendor="NVIDIA" ;;
*"Advanced Micro Devices"*|*AMD*) vendor="AMD" ;;
*"Intel Corporation"*|*Intel*) vendor="Intel" ;;
*) vendor="Other" ;;
esac
# Model: strip every known vendor prefix from desc. Order matters —
# the longest specific prefix (AMD's "Inc. [AMD/ATI]") must come before
# the generic short one.
model="$(printf '%s' "$desc" | sed -E '
s/^Advanced Micro Devices, Inc\. \[AMD\/ATI\][[:space:]]+//
s/^Advanced Micro Devices(, Inc\.)?[[:space:]]+//
s/^NVIDIA Corporation[[:space:]]+//
s/^Intel Corporation[[:space:]]+//
s/[[:space:]]+$//
')"
# Kernel driver in use (may be empty if module not loaded yet)
kernel_driver="$(lspci -nnks "$pci_address" 2>/dev/null | awk -F: '/Kernel driver in use/{sub(/^[ \t]+/,"",$2); print $2; exit}')"
# Passthrough eligible if the GPU is bound to vfio-pci OR it's a discrete
# secondary GPU (not the primary console). Pragmatic heuristic: discrete
# GPUs are usually eligible; iGPUs (Intel HD/UHD, AMD APU iGPUs) usually not
# because they drive the host console.
passthrough_eligible=false
case "$kernel_driver" in
vfio-pci) passthrough_eligible=true ;;
nvidia|nouveau) passthrough_eligible=true ;; # discrete by definition
esac
# ProxMenux installer for this GPU vendor
proxmenux_installer="$(gpu_installer_for "$vendor")"
# Installed driver version from the managed_installs registry
installed_driver_version=""
if [[ "$vendor" == "NVIDIA" ]] && [[ -f /usr/local/share/proxmenux/managed_installs.json ]]; then
installed_driver_version="$(jq -r '
.items[]
| select(.removed_at == null and .type == "nvidia_xfree86")
| .current_version // ""
' /usr/local/share/proxmenux/managed_installs.json 2>/dev/null | head -1)"
fi
gpu_array="$(jq --argjson acc "$gpu_array" \
--arg vendor "$vendor" \
--arg model "$model" \
--arg pci_address "$pci_address" \
--arg pci_id "$pci_id" \
--arg kernel_driver "$kernel_driver" \
--argjson passthrough_eligible "$passthrough_eligible" \
--arg proxmenux_installer "$proxmenux_installer" \
--arg installed_driver_version "$installed_driver_version" \
-n '
$acc + [{
vendor: $vendor,
model: $model,
pci_address: $pci_address,
pci_id: $pci_id,
kernel_driver: (if $kernel_driver == "" then null else $kernel_driver end),
passthrough_eligible: $passthrough_eligible,
proxmenux_installer: (if $proxmenux_installer == "" then null else $proxmenux_installer end),
installed_driver_version: (if $installed_driver_version == "" then null else $installed_driver_version end)
}]
')"
done < <(lspci -nnD 2>/dev/null | grep -E 'VGA compatible|3D controller|Display controller' || true)
# ── TPUs (Google Coral) ──
# PCIe variant: vendor 1ac1 (Global Unichip Corp) is the Coral M.2 / mPCIe.
# USB variant: vendor 18d1 product 9302 (Google).
tpu_array='[]'
# PCIe Coral
while IFS= read -r line; do
[[ -z "$line" ]] && continue
pci_address="$(printf '%s' "$line" | awk '{print $1}')"
pci_id="$(printf '%s' "$line" | grep -oE '\[[0-9a-f]{4}:[0-9a-f]{4}\]' | tail -1 | tr -d '[]')"
tpu_array="$(jq --argjson acc "$tpu_array" \
--arg model "Coral PCIe" \
--arg pci_address "$pci_address" \
-n '
$acc + [{
vendor: "Google",
model: $model,
bus: "PCIe",
pci_address: $pci_address,
proxmenux_installer: "scripts/gpu_tpu/install_coral.sh",
installed_version: null
}]
')"
done < <(lspci -nnD 2>/dev/null | grep -iE '1ac1:|global unichip' || true)
# USB Coral
if command -v lsusb >/dev/null 2>&1; then
if lsusb 2>/dev/null | grep -qE '18d1:9302|Google.*Coral'; then
tpu_array="$(jq --argjson acc "$tpu_array" \
-n '
$acc + [{
vendor: "Google",
model: "Coral USB",
bus: "USB",
pci_address: null,
proxmenux_installer: "scripts/gpu_tpu/install_coral.sh",
installed_version: null
}]
')"
fi
fi
# ── NICs ──
# We want PHYSICAL interfaces (skip lo, veth*, tap*, fwln*, fwbr*, fwpr*).
# Also distinguish wired from wireless.
nic_array='[]'
wireless_array='[]'
# Map each interface → its bridge by walking /sys/class/net/<bridge>/brif/.
# We use bash glob expansion instead of `find -path` because find doesn't
# follow the symlinks under /sys cleanly.
declare -A bridge_for
for brif_dir in /sys/class/net/*/brif; do
[[ -d "$brif_dir" ]] || continue
bridge="$(basename "$(dirname "$brif_dir")")"
for member_link in "$brif_dir"/*; do
[[ -e "$member_link" ]] || continue
member="$(basename "$member_link")"
bridge_for["$member"]="$bridge"
done
done
# Iterate over each physical net device
for dev_path in /sys/class/net/*; do
ifname="$(basename "$dev_path")"
case "$ifname" in
lo|veth*|tap*|fwln*|fwbr*|fwpr*|vmbr*|bond*) continue ;;
esac
# Bridges and bonds we record as their own thing; PHY interfaces only here.
# Detect virtual interfaces (no device symlink → virtual)
[[ ! -e "$dev_path/device" ]] && continue
mac="$(cat "$dev_path/address" 2>/dev/null || echo "")"
[[ -z "$mac" ]] && continue
operstate="$(cat "$dev_path/operstate" 2>/dev/null | tr '[:lower:]' '[:upper:]' || echo "UNKNOWN")"
case "$operstate" in
UP|DOWN) ;;
*) operstate="UNKNOWN" ;;
esac
kernel_driver="$(basename "$(readlink "$dev_path/device/driver" 2>/dev/null || echo "")")"
# Wireless detection
if [[ -d "$dev_path/wireless" ]] || [[ -d "$dev_path/phy80211" ]]; then
wireless_array="$(jq --argjson acc "$wireless_array" \
--arg ifname "$ifname" \
--arg mac "$mac" \
-n '$acc + [{ifname: $ifname, mac: $mac}]')"
continue
fi
# Bridge membership: which vmbr* contains this NIC?
in_bridges_json='[]'
if [[ -n "${bridge_for[$ifname]:-}" ]]; then
in_bridges_json="$(jq -n --arg b "${bridge_for[$ifname]}" '[$b]')"
fi
nic_array="$(jq --argjson acc "$nic_array" \
--arg ifname "$ifname" \
--arg mac "$mac" \
--arg kernel_driver "$kernel_driver" \
--argjson in_bridges "$in_bridges_json" \
--arg operstate "$operstate" \
-n '
$acc + [{
ifname: $ifname,
mac: $mac,
kernel_driver: (if $kernel_driver == "" then null else $kernel_driver end),
in_bridges: $in_bridges,
operstate: $operstate
}]
')"
done
# Compose the final object
jq -n \
--argjson gpu "$gpu_array" \
--argjson tpu "$tpu_array" \
--argjson nic "$nic_array" \
--argjson wireless "$wireless_array" \
'{ gpu: $gpu, tpu: $tpu, nic: $nic, wireless: $wireless }'

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux backup manifest collector — kernel_params
# ==========================================================
# /proc/cmdline (filtered to user-meaningful extras), /etc/modules,
# and /etc/modprobe.d/ files with custom directives. Read-only.
# Schema: scripts/backup_restore/schema/manifest.schema.json
# ==========================================================
set -euo pipefail
# ── cmdline_extra ──
# /proc/cmdline contains the kernel command line the bootloader passed.
# We strip the boring boilerplate (BOOT_IMAGE, initrd, root, ro, rw, quiet,
# splash, boot=zfs, rootflags) so the manifest captures only the user-
# meaningful tweaks (intel_iommu, iommu=pt, hugepages, pcie_acs_override,
# acpi=off, etc.). These are the bits a restore wizard cares about.
cmdline_extra='[]'
if [[ -r /proc/cmdline ]]; then
raw_cmdline="$(cat /proc/cmdline)"
for token in $raw_cmdline; do
case "$token" in
BOOT_IMAGE=*|initrd=*|root=*|ro|rw|quiet|splash|boot=*|rootflags=*)
;; # boilerplate, drop
*)
cmdline_extra="$(jq --argjson acc "$cmdline_extra" --arg t "$token" -n '$acc + [$t]')"
;;
esac
done
fi
# ── modules_loaded_at_boot ──
# /etc/modules lists modules systemd-modules-load.service inserts on boot.
modules_at_boot='[]'
if [[ -r /etc/modules ]]; then
while IFS= read -r mod; do
# Strip comments and inline comments
mod="${mod%%#*}"
mod="$(printf '%s' "$mod" | xargs)"
[[ -z "$mod" ]] && continue
modules_at_boot="$(jq --argjson acc "$modules_at_boot" --arg m "$mod" -n '$acc + [$m]')"
done < /etc/modules
fi
# ── modprobe_d_files ──
# /etc/modprobe.d/*.conf files. We emit the path of every file that
# contains at least one `options`, `blacklist`, `install`, `alias`, or
# `softdep` directive — i.e. anything that has actual effect. Files that
# are empty or pure comments aren't worth tracking.
modprobe_files='[]'
if [[ -d /etc/modprobe.d ]]; then
for f in /etc/modprobe.d/*.conf; do
[[ -r "$f" ]] || continue
if grep -qE '^[[:space:]]*(options|blacklist|install|alias|softdep)[[:space:]]' "$f" 2>/dev/null; then
modprobe_files="$(jq --argjson acc "$modprobe_files" --arg p "$f" -n '$acc + [$p]')"
fi
done
fi
jq -n \
--argjson cmdline_extra "$cmdline_extra" \
--argjson modules_loaded "$modules_at_boot" \
--argjson modprobe_files "$modprobe_files" \
'{
cmdline_extra: $cmdline_extra,
modules_loaded_at_boot: $modules_loaded,
modprobe_d_files: $modprobe_files
}'

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux backup manifest collector — proxmenux_installed_components
# ==========================================================
# Reads ProxMenux's managed_installs registry + post-install
# tools marker file and emits the installed components array.
# Read-only. Schema:
# scripts/backup_restore/schema/manifest.schema.json
# ==========================================================
set -euo pipefail
REGISTRY="/usr/local/share/proxmenux/managed_installs.json"
INSTALLED_TOOLS="/usr/local/share/proxmenux/installed_tools.json"
components='[]'
# ── managed_installs registry ──
# Each entry already carries the installer path under `menu_script`,
# so we trust the registry as the single source of truth. We skip LXC
# entries because containers are restored via vzdump, not via the
# host-config restore path.
if [[ -r "$REGISTRY" ]]; then
while IFS= read -r item; do
[[ -z "$item" ]] && continue
id="$(printf '%s' "$item" | jq -r '.id')"
type="$(printf '%s' "$item" | jq -r '.type // ""')"
version="$(printf '%s' "$item" | jq -r '.current_version // ""')"
# menu_script in the registry is null for components that handle their
# own update lifecycle (e.g. OCI apps via the secure-gateway runtime).
# We keep that null forward: restore won't try to reinstall those —
# the user reconfigures them after restore.
installer="$(printf '%s' "$item" | jq -r '.menu_script // ""')"
components="$(jq --argjson acc "$components" \
--arg id "$id" --arg type "$type" --arg version "$version" --arg installer "$installer" \
-n '
$acc + [{
id: $id,
type: $type,
version_at_backup: (if $version == "" then null else $version end),
proxmenux_installer: (if $installer == "" then null else $installer end),
applied_settings: []
}]
')"
done < <(jq -c '.items[]? | select(.removed_at == null) | select(.type != "lxc")' "$REGISTRY" 2>/dev/null || true)
fi
# ── installed_tools.json (post-install optimizations) ──
# Format: array of {name: ..., installed_at: ...} or similar. The exact
# shape varies across ProxMenux versions; we emit one synthetic component
# named "post_install_optimizations" with the applied_settings list.
if [[ -r "$INSTALLED_TOOLS" ]]; then
applied_settings="$(jq -c '
if type == "object" then
(.tools // .installed // [] | map(.name // .id // tostring))
elif type == "array" then
map(.name // .id // tostring)
else []
end
' "$INSTALLED_TOOLS" 2>/dev/null || echo '[]')"
# Only emit if we have at least one applied setting — otherwise the
# component would be noise.
count="$(printf '%s' "$applied_settings" | jq 'length' 2>/dev/null || echo 0)"
if [[ "${count:-0}" -gt 0 ]]; then
components="$(jq --argjson acc "$components" --argjson s "$applied_settings" \
-n '
$acc + [{
id: "post_install_optimizations",
type: "proxmenux_post_install",
version_at_backup: null,
proxmenux_installer: "scripts/post_install/customizable_post_install.sh",
applied_settings: $s
}]
')"
fi
fi
# Output: bare array (not wrapped in an object — the orchestrator places
# this under .proxmenux_installed_components).
printf '%s\n' "$components"

View File

@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux backup manifest collector — source_host
# ==========================================================
# Emits the `source_host` section of the manifest as JSON to
# stdout. Read-only; no side effects. Schema:
# scripts/backup_restore/schema/manifest.schema.json
# ==========================================================
set -euo pipefail
# ── pve_version_full / pve_version ──
# pveversion's first line is like:
# pve-manager/9.2.2/b9984c6d90a4bd80 (running kernel: 7.0.2-6-pve)
pve_version_full=""
pve_version=""
if command -v pveversion >/dev/null 2>&1; then
pve_version_full="$(pveversion 2>/dev/null | head -1 || true)"
# Extract the X.Y.Z between "pve-manager/" and "/"
pve_version="$(printf '%s\n' "$pve_version_full" | sed -nE 's@^pve-manager/([0-9.]+)/.*@\1@p')"
fi
# ── pbs_version ──
# PBS is a separate package. If proxmox-backup-manager exists, host has PBS role.
pbs_version=""
if command -v proxmox-backup-manager >/dev/null 2>&1; then
pbs_version="$(proxmox-backup-manager versions 2>/dev/null | awk '/^proxmox-backup-server/{print $2; exit}' || true)"
fi
# ── roles ──
roles_json='[]'
if [[ -n "$pve_version" && -n "$pbs_version" ]]; then
roles_json='["pve","pbs"]'
elif [[ -n "$pve_version" ]]; then
roles_json='["pve"]'
elif [[ -n "$pbs_version" ]]; then
roles_json='["pbs"]'
else
# No PVE, no PBS — exit with the unknown sentinel. Caller decides
# whether to abort or generate a system-only manifest.
roles_json='[]'
fi
# ── kernel, boot_mode, root_fs ──
kernel="$(uname -r)"
if [[ -d /sys/firmware/efi ]]; then
boot_mode="efi"
else
boot_mode="bios"
fi
root_fs="$(findmnt -no FSTYPE / 2>/dev/null || echo ext4)"
# ── CPU model / arch ──
cpu_model="$(lscpu 2>/dev/null | awk -F: '/^Model name/{sub(/^[ \t]+/, "", $2); print $2; exit}')"
cpu_arch="$(uname -m)"
# Normalize to schema enum
case "$cpu_arch" in
x86_64|amd64) cpu_arch="x86_64" ;;
aarch64|arm64) cpu_arch="aarch64" ;;
esac
# ── memory_kb ──
memory_kb="$(awk '/^MemTotal:/{print $2; exit}' /proc/meminfo 2>/dev/null || echo 0)"
# ── subscription_status ──
subscription_status=""
if command -v pvesubscription >/dev/null 2>&1; then
subscription_status="$(pvesubscription get 2>/dev/null | awk -F: '/^status:/{sub(/^[ \t]+/,"",$2); print $2; exit}')"
fi
# Build JSON. Use --arg for strings (always quoted), --argjson for
# numbers/arrays/null. Empty strings → null per schema convention.
jq -n \
--arg hostname "$(hostname)" \
--arg pve_version "$pve_version" \
--arg pve_version_full "$pve_version_full" \
--arg pbs_version "$pbs_version" \
--argjson roles "$roles_json" \
--arg kernel "$kernel" \
--arg boot_mode "$boot_mode" \
--arg root_fs "$root_fs" \
--arg cpu_model "$cpu_model" \
--arg cpu_arch "$cpu_arch" \
--argjson memory_kb "$memory_kb" \
--arg subscription_status "$subscription_status" \
'{
hostname: $hostname,
pve_version: (if $pve_version == "" then null else $pve_version end),
pve_version_full: (if $pve_version_full == "" then null else $pve_version_full end),
pbs_version: (if $pbs_version == "" then null else $pbs_version end),
roles: $roles,
kernel: $kernel,
boot_mode: $boot_mode,
root_fs: $root_fs,
cpu_model: $cpu_model,
cpu_arch: $cpu_arch,
memory_kb: $memory_kb,
subscription_status: (if $subscription_status == "" then null else $subscription_status end)
}'

View File

@@ -0,0 +1,252 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux backup manifest collector — storage_inventory
# ==========================================================
# ZFS pools (with stable by-id devices), LVM VGs + thin pools,
# physical disks, PVE storage.cfg, and external mounts.
# Read-only. Schema:
# scripts/backup_restore/schema/manifest.schema.json
# ==========================================================
set -euo pipefail
# ── ZFS pools ──
zfs_pools='[]'
if command -v zpool >/dev/null 2>&1; then
while IFS= read -r pool; do
[[ -z "$pool" ]] && continue
# type: parse zpool status — first vdev line after 'config:' header.
# Single-device pool shows the device directly; mirror/raidz prefix the
# vdev type. We look at the indented children list.
pool_type="single"
devices='[]'
# `zpool status -P` outputs full /dev/disk/by-id/... paths for the
# member disks. We isolate the first whitespace-delimited token on
# each child line and decide:
# - vdev type lines (mirror-0, raidz1-0, stripe, ...) → pool type
# - leaf device lines (/dev/disk/by-id/* or /dev/sd*) → membership
while IFS= read -r vdev_line; do
token="$(printf '%s' "$vdev_line" | awk '{print $1}')"
[[ -z "$token" || "$token" == "NAME" || "$token" == "$pool" ]] && continue
case "$token" in
mirror-*) pool_type="mirror" ;;
raidz1-*) pool_type="raidz1" ;;
raidz2-*) pool_type="raidz2" ;;
raidz3-*) pool_type="raidz3" ;;
stripe-*) pool_type="stripe" ;;
/dev/disk/by-id/*)
# Strip the /dev/disk/by-id/ prefix for the schema field;
# leave any -partN suffix in place — the restore wizard uses
# the exact same string to look the disk back up.
dev_name="${token#/dev/disk/by-id/}"
devices="$(jq --argjson acc "$devices" --arg d "$dev_name" -n '$acc + [$d]')"
;;
/dev/*)
# Fallback: ZFS pool created with raw /dev/sdX paths. Record
# them as-is; restore will need to remap manually.
devices="$(jq --argjson acc "$devices" --arg d "$token" -n '$acc + [$d]')"
;;
esac
done < <(zpool status -P "$pool" 2>/dev/null | awk '/^config:/{flag=1; next} /^errors:/{flag=0} flag')
size_bytes="$(zpool list -H -p -o size "$pool" 2>/dev/null || echo 0)"
health="$(zpool list -H -o health "$pool" 2>/dev/null || echo UNKNOWN)"
compression="$(zfs get -H -o value compression "$pool" 2>/dev/null || echo "")"
mountpoint="$(zfs get -H -o value mountpoint "$pool" 2>/dev/null || echo "")"
zfs_pools="$(jq --argjson acc "$zfs_pools" \
--arg name "$pool" \
--arg type "$pool_type" \
--argjson devices "$devices" \
--arg mountpoint "$mountpoint" \
--arg compression "$compression" \
--argjson size_bytes "${size_bytes:-0}" \
--arg health "$health" \
-n '
$acc + [{
name: $name,
type: $type,
devices_by_id: $devices,
mountpoint: $mountpoint,
compression: $compression,
size_bytes: $size_bytes,
health: $health
}]
')"
done < <(zpool list -H -o name 2>/dev/null || true)
fi
# ── LVM VGs + thin pools ──
lvm_vgs='[]'
if command -v vgs >/dev/null 2>&1; then
# vgs --reportformat json --units b is reliable in lvm2 ≥ 2.02
vg_json="$(vgs --reportformat json --units b --noheadings -o vg_name,vg_size 2>/dev/null || echo '{}')"
while IFS= read -r vg_name; do
[[ -z "$vg_name" || "$vg_name" == "null" ]] && continue
vg_size="$(printf '%s' "$vg_json" | jq -r --arg n "$vg_name" '.report[0].vg[]? | select(.vg_name == $n) | .vg_size' | sed 's/[Bb]$//' | head -1)"
# Thin pools in this VG
thin_pools='[]'
while IFS= read -r lv_line; do
[[ -z "$lv_line" ]] && continue
lv_name="$(printf '%s' "$lv_line" | awk '{print $1}')"
lv_size="$(printf '%s' "$lv_line" | awk '{print $2}' | sed 's/[Bb]$//')"
thin_pools="$(jq --argjson acc "$thin_pools" \
--arg n "$lv_name" --argjson s "${lv_size:-0}" \
-n '$acc + [{lv_name: $n, size_bytes: $s}]')"
done < <(lvs --noheadings --units b -o lv_name,lv_size --select "vg_name=$vg_name && lv_attr=~^t" 2>/dev/null || true)
lvm_vgs="$(jq --argjson acc "$lvm_vgs" \
--arg n "$vg_name" --argjson s "${vg_size:-0}" --argjson tp "$thin_pools" \
-n '$acc + [{name: $n, size_bytes: $s, thin_pools: $tp}]')"
done < <(printf '%s' "$vg_json" | jq -r '.report[0].vg[]?.vg_name' 2>/dev/null || true)
fi
# ── Physical disks (by-id resolution) ──
physical_disks='[]'
# Build name → by-id map by walking /dev/disk/by-id/. A single block
# device usually has multiple by-id symlinks (ata-*, wwn-*, scsi-*, …).
# We prefer the most human-readable identifier in this order:
# ata-* → nvme-* → scsi-* → usb-* → wwn-*
# This also makes the manifest consistent with what `zpool status -P`
# reports (zpool defaults to ata-* / wwn-* depending on bus).
declare -A by_id_for
declare -A by_id_priority_for
priority_for_id() {
case "$1" in
ata-*) echo 1 ;;
nvme-*) echo 2 ;;
scsi-*) echo 3 ;;
usb-*) echo 4 ;;
wwn-*) echo 5 ;;
*) echo 9 ;;
esac
}
if [[ -d /dev/disk/by-id ]]; then
for link in /dev/disk/by-id/*; do
[[ -L "$link" ]] || continue
by_id="$(basename "$link")"
# Skip partition symlinks — we want whole-disk only.
[[ "$by_id" == *-part* ]] && continue
target="$(basename "$(readlink -f "$link")")"
[[ -z "$target" ]] && continue
new_prio="$(priority_for_id "$by_id")"
cur_prio="${by_id_priority_for[$target]:-99}"
if (( new_prio < cur_prio )); then
by_id_for["$target"]="$by_id"
by_id_priority_for["$target"]="$new_prio"
fi
done
fi
# lsblk -d -b -J for whole disks
lsblk_json="$(lsblk -d -b -o NAME,MODEL,SIZE,TYPE -J 2>/dev/null || echo '{}')"
while IFS= read -r disk_line; do
[[ -z "$disk_line" ]] && continue
name="$(printf '%s' "$disk_line" | jq -r '.name')"
model="$(printf '%s' "$disk_line" | jq -r '.model // ""')"
size="$(printf '%s' "$disk_line" | jq -r '.size // 0')"
type="$(printf '%s' "$disk_line" | jq -r '.type')"
# Only PHYSICAL disks.
# - skip non-disk types (rom, loop)
# - skip zd* (ZFS zvols backing VMs)
# - skip dm-* (LVM-mapped devices)
# - skip loop* (defensive — type filter usually catches it)
[[ "$type" != "disk" ]] && continue
case "$name" in
zd*|dm-*|loop*) continue ;;
esac
by_id="${by_id_for[$name]:-}"
physical_disks="$(jq --argjson acc "$physical_disks" \
--arg n "$name" --arg m "$model" --argjson s "${size:-0}" --arg bid "$by_id" \
-n '
$acc + [{
name: $n,
model: (if $m == "" then null else $m end),
size_bytes: $s,
by_id: (if $bid == "" then null else $bid end)
}]
')"
done < <(printf '%s' "$lsblk_json" | jq -c '.blockdevices[]?' 2>/dev/null || true)
# ── PVE storage.cfg ──
# Format is whitespace-key-value with blank-line separators:
# <type>: <id>
# key value
# key value
pve_storage='[]'
if [[ -r /etc/pve/storage.cfg ]]; then
current_type=""; current_id=""; current_extra='{}'
flush() {
if [[ -n "$current_id" ]]; then
pve_storage="$(jq --argjson acc "$pve_storage" \
--arg id "$current_id" --arg t "$current_type" --argjson e "$current_extra" \
-n '$acc + [(($e) + {id: $id, type: $t})]')"
fi
current_type=""; current_id=""; current_extra='{}'
}
while IFS= read -r line; do
if [[ -z "${line// }" ]]; then
flush; continue
fi
if [[ "$line" =~ ^([a-z]+):[[:space:]]+([A-Za-z0-9_.-]+) ]]; then
flush
current_type="${BASH_REMATCH[1]}"
current_id="${BASH_REMATCH[2]}"
elif [[ "$line" =~ ^[[:space:]]+([a-z_]+)[[:space:]]+(.*)$ ]]; then
key="${BASH_REMATCH[1]}"
val="${BASH_REMATCH[2]}"
case "$key" in
# `content` is a comma-separated list — split into JSON array
content)
content_array="$(printf '%s\n' "$val" | tr ',' '\n' | jq -R . | jq -s .)"
current_extra="$(jq --argjson e "$current_extra" --argjson c "$content_array" -n '$e + {content: $c}')"
;;
*)
current_extra="$(jq --argjson e "$current_extra" --arg k "$key" --arg v "$val" -n '$e + {($k): $v}')"
;;
esac
fi
done < /etc/pve/storage.cfg
flush
fi
# ── External mounts (NFS/CIFS/etc.) ──
# Filter on filesystem types we care about for the manifest. Drop FUSE
# pmxcfs (/etc/pve), tmpfs, devtmpfs, autofs, ZFS internals already
# accounted for. NFS, CIFS, ISO mount points are the interesting ones.
mounts='[]'
if command -v findmnt >/dev/null 2>&1; then
while IFS= read -r mline; do
[[ -z "$mline" ]] && continue
target="$(printf '%s' "$mline" | jq -r '.target')"
source="$(printf '%s' "$mline" | jq -r '.source')"
fstype="$(printf '%s' "$mline" | jq -r '.fstype')"
options="$(printf '%s' "$mline" | jq -r '.options // ""')"
mounts="$(jq --argjson acc "$mounts" \
--arg t "$target" --arg s "$source" --arg f "$fstype" --arg o "$options" \
-n '
$acc + [{
target: $t,
source: $s,
fstype: $f,
options: (if $o == "" then null else $o end)
}]
')"
done < <(findmnt -t nfs,nfs4,cifs,smbfs,fuseblk,fuse.glusterfs -J 2>/dev/null \
| jq -c '.. | objects | select(.target?)' 2>/dev/null \
| grep -vE '"target":"/etc/pve"' || true)
fi
# Compose
jq -n \
--argjson zfs_pools "$zfs_pools" \
--argjson lvm_vgs "$lvm_vgs" \
--argjson physical_disks "$physical_disks" \
--argjson pve_storage "$pve_storage" \
--argjson mounts "$mounts" \
'{
zfs_pools: $zfs_pools,
lvm: { vgs: $lvm_vgs },
physical_disks: $physical_disks,
pve_storage_cfg: $pve_storage,
mounts: $mounts
}'