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,98 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux backup restore — manifest reader
# ==========================================================
# Reads the JSON manifest from a ProxMenux host backup. Supports:
# - A loose manifest.json file path
# - A backup archive (.tar.gz / .tar.zst / .tar)
# - A pre-extracted backup directory
#
# Emits the manifest's `proxmenux_backup_manifest` sub-object as
# JSON to stdout (i.e. unwraps the top-level key) so downstream
# scripts can use `jq '.source_host'` directly. Exit 0 on success,
# non-zero with a message on stderr if the manifest can't be found.
#
# Usage:
# parse_manifest.sh <archive-or-dir-or-manifest> [--with-wrapper]
#
# --with-wrapper keeps the outer { proxmenux_backup_manifest: { ... } }
# wrap (useful when piping to jsonschema validation).
# ==========================================================
set -euo pipefail
SOURCE="${1:-}"
KEEP_WRAPPER=0
shift || true
while [[ $# -gt 0 ]]; do
case "$1" in
--with-wrapper) KEEP_WRAPPER=1 ;;
esac
shift
done
if [[ -z "$SOURCE" ]]; then
printf 'parse_manifest: missing source path\n' >&2
exit 64
fi
# Locate the manifest. Three input shapes:
manifest_json=""
case "$SOURCE" in
*.tar.gz|*.tgz|*.tar.zst|*.tar)
# Archive — extract just the manifest entry to stdout. We tolerate
# the manifest sitting at the root OR under any meta/ subdirectory.
extractor=()
case "$SOURCE" in
*.tar.zst) extractor=(zstd -d --long=27 -c "$SOURCE") ;;
*.tar.gz|*.tgz) extractor=(gzip -dc "$SOURCE") ;;
*.tar) extractor=(cat "$SOURCE") ;;
esac
# Use --wildcards so the manifest is found at any depth. We extract
# to stdout and stop at the first match.
if ! manifest_json="$("${extractor[@]}" | tar -xO --wildcards '*manifest.json' 2>/dev/null | head -c 4194304)"; then
printf 'parse_manifest: no manifest.json found inside %s\n' "$SOURCE" >&2
exit 65
fi
;;
*)
if [[ -f "$SOURCE" ]]; then
manifest_json="$(cat "$SOURCE")"
elif [[ -d "$SOURCE" ]]; then
# Pre-extracted directory — try common paths first, then a search.
for candidate in "$SOURCE/manifest.json" "$SOURCE/meta/manifest.json"; do
if [[ -f "$candidate" ]]; then
manifest_json="$(cat "$candidate")"; break
fi
done
if [[ -z "$manifest_json" ]]; then
found="$(find "$SOURCE" -maxdepth 3 -name 'manifest.json' -print -quit 2>/dev/null || true)"
[[ -n "$found" ]] && manifest_json="$(cat "$found")"
fi
if [[ -z "$manifest_json" ]]; then
printf 'parse_manifest: no manifest.json under %s\n' "$SOURCE" >&2
exit 65
fi
else
printf 'parse_manifest: %s is neither archive, dir, nor file\n' "$SOURCE" >&2
exit 66
fi
;;
esac
# Verify it's at least valid JSON before unwrapping.
if ! printf '%s' "$manifest_json" | jq -e 'type == "object"' >/dev/null 2>&1; then
printf 'parse_manifest: contents are not a JSON object\n' >&2
exit 67
fi
# Check the wrapper key is present.
if ! printf '%s' "$manifest_json" | jq -e '.proxmenux_backup_manifest' >/dev/null 2>&1; then
printf 'parse_manifest: missing proxmenux_backup_manifest key (not a ProxMenux manifest?)\n' >&2
exit 68
fi
if [[ "$KEEP_WRAPPER" == 1 ]]; then
printf '%s' "$manifest_json"
else
printf '%s' "$manifest_json" | jq '.proxmenux_backup_manifest'
fi

View File

@@ -0,0 +1,239 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux backup restore — pre-flight compatibility checks
# ==========================================================
# Runs every pre-flight check against the destination host's current
# state and emits a JSON report. The orchestrator (run_restore.sh)
# decides go/no-go based on whether any check has severity=fail.
#
# Severity levels:
# pass — green, restore can proceed for this dimension
# warn — proceed but operator should know (e.g. RAM lower than source,
# NIC MAC absent, PBS role missing but PVE present)
# fail — must address before proceeding (e.g. CPU arch mismatch,
# PVE version older than backup)
#
# Usage:
# preflight_checks.sh <manifest-json-path-or-archive>
#
# Stdout: JSON {checks: [...], summary: {pass: N, warn: N, fail: N}}.
# Exit code: 0 if all checks pass or warn; 1 if any fail.
# ==========================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE="${1:-}"
if [[ -z "$SOURCE" ]]; then
printf 'preflight_checks: missing manifest source\n' >&2
exit 64
fi
manifest="$(bash "$SCRIPT_DIR/parse_manifest.sh" "$SOURCE")"
# Collect "current host" facts up-front so the checks themselves
# stay declarative.
cur_hostname="$(hostname)"
cur_pve_full="$(pveversion 2>/dev/null | head -1 || true)"
cur_pve_ver="$(printf '%s\n' "$cur_pve_full" | sed -nE 's@^pve-manager/([0-9.]+)/.*@\1@p')"
cur_pbs_present=0
command -v proxmox-backup-manager >/dev/null 2>&1 && cur_pbs_present=1
cur_kernel="$(uname -r)"
cur_boot_mode="$([ -d /sys/firmware/efi ] && echo efi || echo bios)"
cur_root_fs="$(findmnt -no FSTYPE / 2>/dev/null || echo unknown)"
cur_cpu_arch="$(uname -m)"
case "$cur_cpu_arch" in x86_64|amd64) cur_cpu_arch=x86_64 ;; aarch64|arm64) cur_cpu_arch=aarch64 ;; esac
cur_memory_kb="$(awk '/^MemTotal:/{print $2; exit}' /proc/meminfo 2>/dev/null || echo 0)"
# Manifest-side facts
m_source="$(printf '%s' "$manifest" | jq -c '.source_host')"
m_pve="$(printf '%s' "$m_source" | jq -r '.pve_version // ""')"
m_pbs="$(printf '%s' "$m_source" | jq -r '.pbs_version // ""')"
m_roles="$(printf '%s' "$m_source" | jq -c '.roles')"
m_boot_mode="$(printf '%s' "$m_source" | jq -r '.boot_mode')"
m_root_fs="$(printf '%s' "$m_source" | jq -r '.root_fs // ""')"
m_cpu_arch="$(printf '%s' "$m_source" | jq -r '.cpu_arch')"
m_memory_kb="$(printf '%s' "$m_source" | jq -r '.memory_kb')"
m_hostname="$(printf '%s' "$m_source" | jq -r '.hostname')"
checks='[]'
# Helper to compare semver-style strings as tuples. Returns 0 if $1 ≥ $2.
ver_ge() {
# Pad both to (major,minor,patch) and compare numerically.
local a b
IFS='.' read -ra a <<< "$1"
IFS='.' read -ra b <<< "$2"
for i in 0 1 2; do
local av="${a[$i]:-0}" bv="${b[$i]:-0}"
av="${av%%-*}"; bv="${bv%%-*}" # strip pre-release suffixes
av="$(printf '%d' "$av" 2>/dev/null || echo 0)"
bv="$(printf '%d' "$bv" 2>/dev/null || echo 0)"
if (( av > bv )); then return 0
elif (( av < bv )); then return 1
fi
done
return 0
}
add_check() {
local id="$1" severity="$2" message="$3" details="${4:-null}"
checks="$(jq --argjson acc "$checks" \
--arg id "$id" --arg sev "$severity" --arg msg "$message" --argjson det "$details" \
-n '$acc + [{id: $id, severity: $sev, message: $msg, details: $det}]')"
}
# ── Check 1: CPU arch must match ──
if [[ "$cur_cpu_arch" == "$m_cpu_arch" ]]; then
add_check cpu_arch_match pass "CPU arch matches ($cur_cpu_arch)"
else
add_check cpu_arch_match fail \
"Source $m_cpu_arch ≠ destination $cur_cpu_arch — backup is not portable across architectures" \
"$(jq -n --arg s "$m_cpu_arch" --arg d "$cur_cpu_arch" '{source: $s, destination: $d}')"
fi
# ── Check 2: Boot mode (efi vs bios) ──
if [[ "$cur_boot_mode" == "$m_boot_mode" ]]; then
add_check boot_mode_match pass "Boot mode matches ($cur_boot_mode)"
else
add_check boot_mode_match warn \
"Source $m_boot_mode ≠ destination $cur_boot_mode. Bootloader config from the backup will not apply." \
"$(jq -n --arg s "$m_boot_mode" --arg d "$cur_boot_mode" '{source: $s, destination: $d}')"
fi
# ── Check 3: Root filesystem family ──
if [[ -n "$m_root_fs" ]]; then
if [[ "$cur_root_fs" == "$m_root_fs" ]]; then
add_check root_fs_match pass "Root filesystem matches ($cur_root_fs)"
else
add_check root_fs_match warn \
"Source root_fs=$m_root_fs vs destination $cur_root_fs. Fine for config-only restore, but ZFS-specific paths from the backup may need manual adjustment." \
"$(jq -n --arg s "$m_root_fs" --arg d "$cur_root_fs" '{source: $s, destination: $d}')"
fi
fi
# ── Check 4: PVE version ──
if [[ -n "$m_pve" ]]; then
if [[ -z "$cur_pve_ver" ]]; then
add_check pve_version fail \
"Source had PVE $m_pve but destination has no PVE installed" \
"$(jq -n --arg s "$m_pve" '{source_version: $s, destination_version: null}')"
elif ver_ge "$cur_pve_ver" "$m_pve"; then
add_check pve_version pass \
"Destination PVE $cur_pve_ver ≥ source $m_pve"
else
add_check pve_version warn \
"Destination PVE $cur_pve_ver is OLDER than source $m_pve. New config files may reference fields the older PVE doesn't recognise." \
"$(jq -n --arg s "$m_pve" --arg d "$cur_pve_ver" '{source: $s, destination: $d}')"
fi
fi
# ── Check 5: PBS role ──
roles_have_pbs="$(printf '%s' "$m_roles" | jq 'index("pbs") != null')"
if [[ "$roles_have_pbs" == "true" ]]; then
if [[ "$cur_pbs_present" == 1 ]]; then
add_check pbs_role pass "Destination has PBS — manifest's pbs role can be restored"
else
add_check pbs_role warn \
"Source had PBS role but destination has no PBS installed. PBS-related configs will be ignored unless you install proxmox-backup-server first."
fi
fi
# ── Check 6: Memory ──
if [[ "$m_memory_kb" -gt 0 && "$cur_memory_kb" -gt 0 ]]; then
# 80% rule — destination must have at least 80% of source RAM.
threshold_kb=$(( m_memory_kb * 80 / 100 ))
if [[ "$cur_memory_kb" -ge "$m_memory_kb" ]]; then
add_check memory pass "Destination $(( cur_memory_kb / 1024 ))MB ≥ source $(( m_memory_kb / 1024 ))MB"
elif [[ "$cur_memory_kb" -ge "$threshold_kb" ]]; then
add_check memory warn \
"Destination $(( cur_memory_kb / 1024 ))MB is below source $(( m_memory_kb / 1024 ))MB but within 80% threshold. VMs may need memory limits reduced."
else
add_check memory fail \
"Destination $(( cur_memory_kb / 1024 ))MB is below 80% of source $(( m_memory_kb / 1024 ))MB. VMs from the backup will likely refuse to start." \
"$(jq -n --argjson s "$m_memory_kb" --argjson d "$cur_memory_kb" '{source_kb: $s, destination_kb: $d}')"
fi
fi
# ── Check 7: Required by-id disks present ──
required_disks="$(printf '%s' "$manifest" | jq -r '
[.storage_inventory.zfs_pools[]?.devices_by_id[]?]
+ [.storage_inventory.physical_disks[]?.by_id // empty]
| unique[]
' | grep -v '^$' || true)"
missing_disks='[]'
present_disks='[]'
while IFS= read -r dev; do
[[ -z "$dev" ]] && continue
if [[ -e "/dev/disk/by-id/$dev" ]]; then
present_disks="$(jq --argjson acc "$present_disks" --arg d "$dev" -n '$acc + [$d]')"
else
missing_disks="$(jq --argjson acc "$missing_disks" --arg d "$dev" -n '$acc + [$d]')"
fi
done <<< "$required_disks"
missing_count="$(printf '%s' "$missing_disks" | jq 'length')"
present_count="$(printf '%s' "$present_disks" | jq 'length')"
total_count=$(( missing_count + present_count ))
if [[ "$total_count" == 0 ]]; then
add_check disk_inventory pass "Manifest declares no by-id disks (no ZFS pools to import)"
elif [[ "$missing_count" == 0 ]]; then
add_check disk_inventory pass "All $present_count required by-id disks present" \
"$(jq -n --argjson p "$present_disks" '{present: $p}')"
else
add_check disk_inventory warn \
"$missing_count of $total_count required by-id disks are missing. Affected ZFS pools / storages cannot auto-import." \
"$(jq -n --argjson m "$missing_disks" --argjson p "$present_disks" '{missing: $m, present: $p}')"
fi
# ── Check 8: NIC MACs present ──
required_macs="$(printf '%s' "$manifest" | jq -r '.hardware_inventory.nic[]?.mac // empty')"
current_macs="$(ip -j link 2>/dev/null | jq -r '.[].address' 2>/dev/null | sort -u)"
missing_macs='[]'
matched_macs='[]'
while IFS= read -r mac; do
[[ -z "$mac" ]] && continue
if printf '%s\n' "$current_macs" | grep -qFx "$mac"; then
matched_macs="$(jq --argjson acc "$matched_macs" --arg m "$mac" -n '$acc + [$m]')"
else
missing_macs="$(jq --argjson acc "$missing_macs" --arg m "$mac" -n '$acc + [$m]')"
fi
done <<< "$required_macs"
mac_missing="$(printf '%s' "$missing_macs" | jq 'length')"
mac_total=$(( mac_missing + $(printf '%s' "$matched_macs" | jq 'length') ))
if [[ "$mac_total" == 0 ]]; then
add_check nic_macs pass "Manifest declares no NICs"
elif [[ "$mac_missing" == 0 ]]; then
add_check nic_macs pass "All $mac_total NIC MACs from source present on destination"
else
add_check nic_macs warn \
"$mac_missing of $mac_total source NIC MACs are absent. Bridge memberships referencing those interfaces will need manual remap." \
"$(jq -n --argjson m "$missing_macs" --argjson p "$matched_macs" '{missing: $m, matched: $p}')"
fi
# ── Check 9: Hostname collision ──
if [[ "$cur_hostname" == "$m_hostname" ]]; then
add_check hostname pass "Hostname unchanged ($cur_hostname)"
else
add_check hostname warn \
"Source hostname '$m_hostname' ≠ destination '$cur_hostname'. Restoring /etc/hostname will change it; this affects PVE cluster identity and some certs." \
"$(jq -n --arg s "$m_hostname" --arg d "$cur_hostname" '{source: $s, destination: $d}')"
fi
# Compose final report
summary="$(printf '%s' "$checks" | jq '
reduce .[] as $c ({pass: 0, warn: 0, fail: 0};
.[$c.severity] += 1)
')"
fail_count="$(printf '%s' "$summary" | jq '.fail')"
jq -n \
--argjson checks "$checks" \
--argjson summary "$summary" \
'{ checks: $checks, summary: $summary }'
exit "$([ "$fail_count" -eq 0 ] && echo 0 || echo 1)"

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux backup restore — driver reinstaller
# ==========================================================
# Walks the manifest's proxmenux_installed_components list and
# emits a plan (--dry-run, default) or actually invokes the
# installers (--apply). Each installer is called with:
#
# bash <installer> --auto-from-manifest \
# --version <version_at_backup> \
# --id <component_id>
#
# The installers themselves are responsible for honoring those
# flags and running non-interactively. This script does NOT touch
# the host directly — it only delegates to the existing installers.
#
# Usage:
# reinstall_drivers.sh <manifest> [--apply]
#
# Output: JSON {plan: [...], applied: [...] (only with --apply)}.
# ==========================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROXMENUX_ROOT="/usr/local/share/proxmenux" # where the installers live at runtime
SOURCE="${1:-}"
APPLY=0
shift || true
while [[ $# -gt 0 ]]; do
case "$1" in
--apply) APPLY=1 ;;
--root) shift; PROXMENUX_ROOT="$1" ;;
esac
shift
done
[[ -z "$SOURCE" ]] && { printf 'reinstall_drivers: missing manifest source\n' >&2; exit 64; }
manifest="$(bash "$SCRIPT_DIR/parse_manifest.sh" "$SOURCE")"
plan='[]'
applied='[]'
while IFS= read -r comp; do
[[ -z "$comp" ]] && continue
id="$(printf '%s' "$comp" | jq -r '.id')"
type="$(printf '%s' "$comp" | jq -r '.type // ""')"
version="$(printf '%s' "$comp" | jq -r '.version_at_backup // ""')"
installer_rel="$(printf '%s' "$comp" | jq -r '.proxmenux_installer // ""')"
# Components without an installer are reinstalled manually by the
# operator after restore (e.g. OCI apps like Tailscale). We still
# surface them in the plan so the operator has the full list.
if [[ -z "$installer_rel" ]]; then
plan="$(jq --argjson acc "$plan" \
--arg id "$id" --arg type "$type" --arg version "$version" \
-n '$acc + [{
component_id: $id,
type: $type,
version: $version,
installer: null,
action: "manual_reinstall_required",
reason: "component has no installer mapping — operator must reinstall manually"
}]')"
continue
fi
installer_abs="$PROXMENUX_ROOT/$installer_rel"
if [[ ! -f "$installer_abs" ]]; then
plan="$(jq --argjson acc "$plan" \
--arg id "$id" --arg type "$type" --arg version "$version" --arg ir "$installer_rel" \
-n '$acc + [{
component_id: $id,
type: $type,
version: $version,
installer: $ir,
action: "installer_missing",
reason: "installer script not present on this host — ProxMenux installation incomplete?"
}]')"
continue
fi
plan="$(jq --argjson acc "$plan" \
--arg id "$id" --arg type "$type" --arg version "$version" --arg ir "$installer_rel" \
-n '$acc + [{
component_id: $id,
type: $type,
version: $version,
installer: $ir,
action: "will_invoke_installer",
reason: "bash <installer> --auto-from-manifest --version <V> --id <ID>"
}]')"
if [[ "$APPLY" == 1 ]]; then
started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if bash "$installer_abs" --auto-from-manifest --version "$version" --id "$id" \
>/tmp/proxmenux-restore-install-"$id".log 2>&1; then
result="ok"; exit_code=0
else
exit_code=$?
result="failed"
fi
finished_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
applied="$(jq --argjson acc "$applied" \
--arg id "$id" --arg result "$result" --argjson ec "$exit_code" \
--arg s "$started_at" --arg f "$finished_at" \
-n '$acc + [{
component_id: $id,
result: $result,
exit_code: $ec,
started_at: $s,
finished_at: $f,
log: ("/tmp/proxmenux-restore-install-" + $id + ".log")
}]')"
fi
done < <(printf '%s' "$manifest" | jq -c '.proxmenux_installed_components[]?')
if [[ "$APPLY" == 1 ]]; then
jq -n --argjson plan "$plan" --argjson applied "$applied" '{plan: $plan, applied: $applied}'
else
jq -n --argjson plan "$plan" '{plan: $plan}'
fi

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux backup restore — NIC remap by MAC
# ==========================================================
# Compares the manifest's NIC list (ifname + MAC + bridges) against
# the destination's current state and produces a remap table.
#
# Decision rules per NIC:
# - MAC found on the SAME ifname → keep (no action)
# - MAC found on a DIFFERENT ifname → rename or rewrite bridge config
# - MAC NOT found at all → orphan: bridge member needs
# human decision
# - Destination has a NIC not in manifest → new hardware: no action
# needed for restore, but
# operator may want to add
# to a bridge afterwards
#
# Usage:
# remap_network.sh <manifest-json-path-or-archive>
#
# Output: JSON {keep: [...], remap: [...], orphan: [...], new: [...]}.
# ==========================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE="${1:-}"
[[ -z "$SOURCE" ]] && { printf 'remap_network: missing manifest source\n' >&2; exit 64; }
manifest="$(bash "$SCRIPT_DIR/parse_manifest.sh" "$SOURCE")"
# Snapshot destination NICs.
dest_nics='[]'
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
[[ -e "$dev_path/device" ]] || continue
mac="$(cat "$dev_path/address" 2>/dev/null || true)"
[[ -z "$mac" ]] && continue
dest_nics="$(jq --argjson acc "$dest_nics" --arg n "$ifname" --arg m "$mac" \
-n '$acc + [{ifname: $n, mac: $m}]')"
done
# Manifest NICs
manifest_nics="$(printf '%s' "$manifest" | jq -c '.hardware_inventory.nic // []')"
keep='[]'
remap='[]'
orphan='[]'
# Iterate manifest NICs
while IFS= read -r src_nic; do
[[ -z "$src_nic" ]] && continue
src_if="$(printf '%s' "$src_nic" | jq -r '.ifname')"
src_mac="$(printf '%s' "$src_nic" | jq -r '.mac')"
src_bridges="$(printf '%s' "$src_nic" | jq -c '.in_bridges // []')"
# Look up the same MAC on destination
match="$(printf '%s' "$dest_nics" | jq -c --arg m "$src_mac" '.[] | select(.mac == $m)' | head -1)"
if [[ -z "$match" ]]; then
# MAC not found at all → orphan
orphan="$(jq --argjson acc "$orphan" \
--arg if "$src_if" --arg mac "$src_mac" --argjson b "$src_bridges" \
-n '$acc + [{
source_ifname: $if,
source_mac: $mac,
in_bridges: $b
}]')"
continue
fi
dest_if="$(printf '%s' "$match" | jq -r '.ifname')"
if [[ "$dest_if" == "$src_if" ]]; then
keep="$(jq --argjson acc "$keep" \
--arg if "$src_if" --arg mac "$src_mac" \
-n '$acc + [{ifname: $if, mac: $mac}]')"
else
remap="$(jq --argjson acc "$remap" \
--arg si "$src_if" --arg di "$dest_if" --arg mac "$src_mac" --argjson b "$src_bridges" \
-n '$acc + [{
source_ifname: $si,
destination_ifname: $di,
mac: $mac,
in_bridges: $b
}]')"
fi
done < <(printf '%s' "$manifest_nics" | jq -c '.[]')
# Destination NICs that weren't in the manifest at all → new hardware
manifest_macs="$(printf '%s' "$manifest_nics" | jq -r '.[].mac')"
new='[]'
while IFS= read -r dest_nic; do
[[ -z "$dest_nic" ]] && continue
dest_mac="$(printf '%s' "$dest_nic" | jq -r '.mac')"
if ! printf '%s\n' "$manifest_macs" | grep -qFx "$dest_mac"; then
new="$(jq --argjson acc "$new" --argjson n "$dest_nic" -n '$acc + [$n]')"
fi
done < <(printf '%s' "$dest_nics" | jq -c '.[]')
jq -n \
--argjson keep "$keep" \
--argjson remap "$remap" \
--argjson orphan "$orphan" \
--argjson new "$new" \
'{ keep: $keep, remap: $remap, orphan: $orphan, new: $new }'

View File

@@ -0,0 +1,220 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux backup restore — mode presets
# ==========================================================
# Defines the five canonical restore modes. Each mode is a
# declarative filter over the manifest:
#
# full — restore everything from the backup
# storage_only — only PVE storages, ZFS pools, mounts
# network_only — only /etc/network, hostname, hosts, firewall
# base — full minus network (operator keeps current LAN)
# custom — pass-through; the caller decides paths/components
#
# Each mode takes the manifest on stdin and prints a plan JSON
# to stdout. The plan tells run_restore.sh which paths to extract,
# which components to reinstall, and whether to apply storage /
# network actions.
#
# Plan schema:
# {
# mode: "full" | ... ,
# paths_include: [string, ...], // paths to extract
# paths_exclude: [string, ...], // paths to skip
# components_include: [string, ...], // component ids to reinstall
# storage_apply: bool,
# network_apply: bool,
# hostname_apply: bool
# }
#
# Usage as a library:
# source restore_modes.sh
# plan="$(mode_plan_full < manifest.json)"
#
# Usage as a CLI:
# restore_modes.sh <mode> <manifest>
#
# Modes consume the manifest's paths_archived list — they don't
# invent paths. Anything you didn't archive can't be restored.
# ==========================================================
set -euo pipefail
# Paths that belong to the "network" concern, used by base/network_only
# modes. We match prefixes (e.g. /etc/network covers everything under it).
_NETWORK_PATH_PREFIXES=(
"/etc/network"
"/etc/hosts"
"/etc/hostname"
"/etc/resolv.conf"
"/etc/pve/firewall"
"/etc/pve/nodes"
"/etc/pve/.members"
)
# Paths that belong to the "storage" concern.
_STORAGE_PATH_PREFIXES=(
"/etc/pve/storage.cfg"
"/etc/pve/priv/storage"
"/etc/fstab"
"/etc/iscsi"
"/etc/multipath"
"/etc/multipath.conf"
"/etc/zfs"
"/etc/lvm"
)
# Internal: returns 0 if $1 starts with any of the prefixes in the
# named array.
_path_matches_any() {
local path="$1"; shift
local prefix
for prefix in "$@"; do
case "$path" in
"$prefix"|"$prefix"/*) return 0 ;;
esac
done
return 1
}
# Internal: emit a JSON array of paths from paths_archived that pass the
# given path predicate function name.
_filter_paths() {
local predicate="$1" manifest="$2"
local out='[]'
while IFS= read -r p; do
[[ -z "$p" ]] && continue
if $predicate "$p"; then
out="$(jq --argjson acc "$out" --arg p "$p" -n '$acc + [$p]')"
fi
done < <(printf '%s' "$manifest" | jq -r '.backup_metadata.paths_archived[]?')
printf '%s' "$out"
}
_is_network_path() { _path_matches_any "$1" "${_NETWORK_PATH_PREFIXES[@]}"; }
_is_storage_path() { _path_matches_any "$1" "${_STORAGE_PATH_PREFIXES[@]}"; }
_is_not_network() { ! _is_network_path "$1"; }
# Internal: emit the component-ids array, optionally filtered.
# Args:
# $1 = manifest JSON
# $2 = "all" | "none"
_components_for_mode() {
local manifest="$1" policy="$2"
case "$policy" in
all)
printf '%s' "$manifest" | jq '[.proxmenux_installed_components[]?.id]'
;;
none)
echo '[]'
;;
esac
}
# Public: emit a plan JSON for the requested mode given the manifest
# on stdin or as $1.
emit_plan() {
local mode="$1" manifest="$2"
local include exclude components storage_apply network_apply hostname_apply
case "$mode" in
full)
include="$(printf '%s' "$manifest" | jq '.backup_metadata.paths_archived // []')"
exclude='[]'
components="$(_components_for_mode "$manifest" all)"
storage_apply=true; network_apply=true; hostname_apply=true
;;
storage_only)
include="$(_filter_paths _is_storage_path "$manifest")"
exclude='[]'
components='[]'
storage_apply=true; network_apply=false; hostname_apply=false
;;
network_only)
include="$(_filter_paths _is_network_path "$manifest")"
exclude='[]'
components='[]'
storage_apply=false; network_apply=true; hostname_apply=true
;;
base)
# everything except the network paths
include="$(_filter_paths _is_not_network "$manifest")"
# Explicitly enumerate excluded prefixes so the operator sees them
exclude="$(printf '%s\n' "${_NETWORK_PATH_PREFIXES[@]}" | jq -R . | jq -s .)"
components="$(_components_for_mode "$manifest" all)"
storage_apply=true; network_apply=false; hostname_apply=false
;;
custom)
# Pass-through: include nothing, exclude nothing — caller fills in.
include='[]'
exclude='[]'
components='[]'
storage_apply=false; network_apply=false; hostname_apply=false
;;
*)
printf 'restore_modes: unknown mode "%s" (expected full|storage_only|network_only|base|custom)\n' "$mode" >&2
return 64
;;
esac
jq -n \
--arg mode "$mode" \
--argjson include "$include" \
--argjson exclude "$exclude" \
--argjson components "$components" \
--argjson storage_apply "$storage_apply" \
--argjson network_apply "$network_apply" \
--argjson hostname_apply "$hostname_apply" \
'{
mode: $mode,
paths_include: $include,
paths_exclude: $exclude,
components_include: $components,
storage_apply: $storage_apply,
network_apply: $network_apply,
hostname_apply: $hostname_apply
}'
}
# Public: human-friendly label per mode, used by CLI/UI.
mode_label() {
case "$1" in
full) echo "Full restore — apply everything from the backup" ;;
storage_only) echo "Storage only — PVE storages, ZFS, fstab, iSCSI, multipath" ;;
network_only) echo "Network only — interfaces, hosts, hostname, firewall" ;;
base) echo "Base (no network) — everything except network changes" ;;
custom) echo "Custom — operator picks paths and components manually" ;;
*) echo "Unknown mode" ;;
esac
}
# CLI mode if called directly
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
MODE="${1:-}"
SOURCE="${2:-}"
if [[ -z "$MODE" || -z "$SOURCE" ]]; then
cat <<EOF >&2
restore_modes.sh — restore mode preset definitions
Usage:
restore_modes.sh <mode> <manifest-or-archive>
Modes:
full — $(mode_label full)
storage_only — $(mode_label storage_only)
network_only — $(mode_label network_only)
base — $(mode_label base)
custom — $(mode_label custom)
EOF
exit 64
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
manifest="$(bash "$SCRIPT_DIR/parse_manifest.sh" "$SOURCE")"
emit_plan "$MODE" "$manifest"
fi

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux backup restore — orchestrator
# ==========================================================
# Composes the four manifest-aware tools into a single restore
# workflow:
#
# 1. parse manifest (parse_manifest.sh)
# 2. preflight checks (preflight_checks.sh) ← can fail
# 3. validate storage (validate_storage.sh) ← reports
# 4. network remap plan (remap_network.sh) ← reports
# 5. driver reinstall plan (reinstall_drivers.sh) ← reports
#
# By default it runs the four AS A DRY-RUN and prints the combined
# report. With --apply it executes the file extraction (delegated to
# the existing _rs_apply from backup_host.sh — placeholder for now)
# and then runs the driver reinstaller with --apply.
#
# Usage:
# run_restore.sh <backup-archive-or-dir> [options]
#
# --mode <mode> Restore mode preset (default: full)
# full | storage_only | network_only | base | custom
# --json Machine-readable combined report (default)
# --text Human-friendly summary on stderr + JSON report on stdout
# --apply Actually perform the restore (refuses if preflight fails)
# ==========================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE="${1:-}"
FORMAT="json"
APPLY=0
MODE="full"
shift || true
while [[ $# -gt 0 ]]; do
case "$1" in
--json) FORMAT="json" ;;
--text) FORMAT="text" ;;
--apply) APPLY=1 ;;
--mode) shift; MODE="${1:-full}" ;;
esac
shift
done
[[ -z "$SOURCE" ]] && { printf 'run_restore: usage: %s <backup-archive-or-dir> [--apply]\n' "$0" >&2; exit 64; }
# ── Step 1: Parse manifest ──
manifest="$(bash "$SCRIPT_DIR/parse_manifest.sh" "$SOURCE")"
# ── Step 2: Resolve mode preset (which paths/components/actions apply) ──
mode_plan="$(bash "$SCRIPT_DIR/restore_modes.sh" "$MODE" "$SOURCE")"
# ── Step 3: Pre-flight checks (gate) ──
preflight="$(bash "$SCRIPT_DIR/preflight_checks.sh" "$SOURCE" || true)"
fail_count="$(printf '%s' "$preflight" | jq '.summary.fail')"
# ── Step 4: Storage validation ──
# Only report storage if the mode actually applies storage changes;
# otherwise we still surface the info but mark it as "not in mode".
storage_apply_in_mode="$(printf '%s' "$mode_plan" | jq -r '.storage_apply')"
storage="$(bash "$SCRIPT_DIR/validate_storage.sh" "$SOURCE")"
# ── Step 5: NIC remap plan ──
network_apply_in_mode="$(printf '%s' "$mode_plan" | jq -r '.network_apply')"
network="$(bash "$SCRIPT_DIR/remap_network.sh" "$SOURCE")"
# ── Step 6: Driver reinstaller plan ──
# In modes that don't include components (storage_only, network_only,
# custom-without-explicit), we narrow the driver plan to nothing.
components_in_mode="$(printf '%s' "$mode_plan" | jq -c '.components_include')"
drivers_full_plan="$(bash "$SCRIPT_DIR/reinstall_drivers.sh" "$SOURCE")"
drivers_plan="$(printf '%s' "$drivers_full_plan" | jq --argjson ids "$components_in_mode" '
if ($ids | length) == 0 then
.plan |= []
else
.plan |= map(select(.component_id as $id | $ids | index($id) != null))
end
')"
drivers_applied='null'
apply_done=false
abort_reason=""
if [[ "$APPLY" == 1 ]]; then
if [[ "$fail_count" -gt 0 ]]; then
abort_reason="preflight has $fail_count failing check(s) — refusing --apply"
else
# Driver reinstall only runs if the selected mode includes components.
# Modes that don't (storage_only, network_only) keep drivers untouched.
if [[ "$(printf '%s' "$components_in_mode" | jq 'length')" -gt 0 ]]; then
drivers_full="$(bash "$SCRIPT_DIR/reinstall_drivers.sh" "$SOURCE" --apply)"
# Narrow to components selected by the mode
drivers_applied="$(printf '%s' "$drivers_full" | jq --argjson ids "$components_in_mode" '
.applied | map(select(.component_id as $id | $ids | index($id) != null))
')"
else
drivers_applied='[]'
fi
# TODO(13D): delegate the actual file extraction (paths_include /
# paths_exclude from $mode_plan) + storage_apply / network_apply
# decisions to backup_host.sh's _rs_apply(). This is the integration
# seam between the manifest-aware tooling and the existing extraction
# engine.
apply_done=true
fi
fi
# Decorate sections that aren't part of the selected mode so the report
# is honest about what would actually be touched.
storage_for_report="$(jq -n --argjson s "$storage" --argjson in_mode "$storage_apply_in_mode" \
'$s + {in_selected_mode: $in_mode}')"
network_for_report="$(jq -n --argjson n "$network" --argjson in_mode "$network_apply_in_mode" \
'$n + {in_selected_mode: $in_mode}')"
report="$(jq -n \
--argjson manifest_source_host "$(printf '%s' "$manifest" | jq '.source_host')" \
--argjson mode_plan "$mode_plan" \
--argjson preflight "$preflight" \
--argjson storage "$storage_for_report" \
--argjson network "$network_for_report" \
--argjson drivers_plan "$(printf '%s' "$drivers_plan" | jq '.plan')" \
--argjson drivers_applied "$drivers_applied" \
--argjson apply_done "$apply_done" \
--arg abort_reason "$abort_reason" \
'{
source_host_at_backup: $manifest_source_host,
selected_mode: $mode_plan,
preflight: $preflight,
storage: $storage,
network: $network,
driver_reinstall: {
plan: $drivers_plan,
applied: $drivers_applied
},
applied: $apply_done,
abort_reason: (if $abort_reason == "" then null else $abort_reason end)
}')"
if [[ "$FORMAT" == "text" ]]; then
# Brief human summary on stderr; the JSON still goes to stdout so the
# caller can pipe it elsewhere.
{
printf '─────────────────────────────────────────────\n'
printf 'ProxMenux Restore — dry-run report\n'
printf '─────────────────────────────────────────────\n'
printf 'Source host : %s (PVE %s)\n' \
"$(printf '%s' "$report" | jq -r '.source_host_at_backup.hostname')" \
"$(printf '%s' "$report" | jq -r '.source_host_at_backup.pve_version // "-"')"
printf 'Mode : %s — %s paths in, %s components\n' \
"$MODE" \
"$(printf '%s' "$report" | jq -r '.selected_mode.paths_include | length')" \
"$(printf '%s' "$report" | jq -r '.selected_mode.components_include | length')"
printf 'Pre-flight : %s pass · %s warn · %s fail\n' \
"$(printf '%s' "$report" | jq -r '.preflight.summary.pass')" \
"$(printf '%s' "$report" | jq -r '.preflight.summary.warn')" \
"$(printf '%s' "$report" | jq -r '.preflight.summary.fail')"
printf 'Storage : %s pools / %s LVM VGs / %s PVE storages [in mode: %s]\n' \
"$(printf '%s' "$report" | jq -r '.storage.zfs | length')" \
"$(printf '%s' "$report" | jq -r '.storage.lvm | length')" \
"$(printf '%s' "$report" | jq -r '.storage.pve_storage | length')" \
"$(printf '%s' "$report" | jq -r '.storage.in_selected_mode')"
printf 'Network : %s keep / %s remap / %s orphan / %s new [in mode: %s]\n' \
"$(printf '%s' "$report" | jq -r '.network.keep | length')" \
"$(printf '%s' "$report" | jq -r '.network.remap | length')" \
"$(printf '%s' "$report" | jq -r '.network.orphan | length')" \
"$(printf '%s' "$report" | jq -r '.network.new | length')" \
"$(printf '%s' "$report" | jq -r '.network.in_selected_mode')"
printf 'Drivers : %s in plan\n' \
"$(printf '%s' "$report" | jq -r '.driver_reinstall.plan | length')"
if [[ "$APPLY" == 1 ]]; then
printf '─── APPLY ───\n'
if [[ -n "$abort_reason" ]]; then
printf 'ABORTED: %s\n' "$abort_reason"
else
printf 'Drivers applied: %s\n' \
"$(printf '%s' "$report" | jq -r '.driver_reinstall.applied | length')"
fi
fi
printf '─────────────────────────────────────────────\n'
} >&2
fi
printf '%s\n' "$report"

View File

@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux backup restore — storage validation
# ==========================================================
# Walks the manifest's storage_inventory and reports per-pool /
# per-storage whether it can be auto-restored on this host. Builds
# the "what's safe to import vs needs manual work" picture that
# the orchestrator turns into actionable steps.
#
# Usage:
# validate_storage.sh <manifest-json-path-or-archive>
#
# Output: JSON {zfs: [...], lvm: [...], pve_storage: [...]} with
# per-item action (auto_import / partial / manual_required / present)
# and the disks/parameters that drove the decision.
# ==========================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE="${1:-}"
[[ -z "$SOURCE" ]] && { printf 'validate_storage: missing manifest source\n' >&2; exit 64; }
manifest="$(bash "$SCRIPT_DIR/parse_manifest.sh" "$SOURCE")"
# ── ZFS pools ──
zfs_report='[]'
while IFS= read -r pool_json; do
[[ -z "$pool_json" ]] && continue
name="$(printf '%s' "$pool_json" | jq -r '.name')"
needed_devs="$(printf '%s' "$pool_json" | jq -r '.devices_by_id[]?')"
present=()
missing=()
while IFS= read -r dev; do
[[ -z "$dev" ]] && continue
if [[ -e "/dev/disk/by-id/$dev" ]]; then
present+=("$dev")
else
missing+=("$dev")
fi
done <<< "$needed_devs"
# Already imported?
if zpool list -H -o name 2>/dev/null | grep -qFx "$name"; then
action="present"
elif [[ ${#missing[@]} -eq 0 ]]; then
action="auto_import"
elif [[ ${#present[@]} -gt 0 ]]; then
action="partial"
else
action="manual_required"
fi
present_json="$(printf '%s\n' "${present[@]:-}" | jq -R . | jq -s 'map(select(. != ""))')"
missing_json="$(printf '%s\n' "${missing[@]:-}" | jq -R . | jq -s 'map(select(. != ""))')"
zfs_report="$(jq --argjson acc "$zfs_report" \
--arg name "$name" \
--arg action "$action" \
--argjson present "$present_json" \
--argjson missing "$missing_json" \
-n '$acc + [{
name: $name,
action: $action,
present: $present,
missing: $missing
}]')"
done < <(printf '%s' "$manifest" | jq -c '.storage_inventory.zfs_pools[]?')
# ── LVM volume groups ──
lvm_report='[]'
while IFS= read -r vg_json; do
[[ -z "$vg_json" ]] && continue
name="$(printf '%s' "$vg_json" | jq -r '.name')"
if command -v vgs >/dev/null 2>&1 && vgs --noheadings -o vg_name 2>/dev/null | grep -qE "^[[:space:]]*${name}[[:space:]]*$"; then
action="present"
else
action="manual_required"
fi
lvm_report="$(jq --argjson acc "$lvm_report" \
--arg name "$name" --arg action "$action" \
-n '$acc + [{ name: $name, action: $action }]')"
done < <(printf '%s' "$manifest" | jq -c '.storage_inventory.lvm.vgs[]?')
# ── PVE storage.cfg entries ──
# For each storage entry in the manifest we report whether it currently
# exists in the destination's storage.cfg (no action needed), whether the
# backing resource is reachable (e.g. NFS server pings), and what kind of
# follow-up is required if the storage.cfg is being restored.
pve_report='[]'
existing_pve_ids='[]'
if [[ -r /etc/pve/storage.cfg ]]; then
existing_pve_ids="$(awk '/^[a-z]+:[[:space:]]+/{print $2}' /etc/pve/storage.cfg | jq -R . | jq -s .)"
fi
while IFS= read -r st_json; do
[[ -z "$st_json" ]] && continue
id="$(printf '%s' "$st_json" | jq -r '.id')"
type="$(printf '%s' "$st_json" | jq -r '.type')"
server="$(printf '%s' "$st_json" | jq -r '.server // ""')"
pool="$(printf '%s' "$st_json" | jq -r '.pool // ""')"
already_present="$(printf '%s' "$existing_pve_ids" | jq -r --arg i "$id" 'any(. == $i)')"
reachable_note=""
case "$type" in
nfs|cifs)
if [[ -n "$server" ]]; then
if ping -c 1 -W 1 "$server" >/dev/null 2>&1; then
reachable_note="reachable"
else
reachable_note="server_unreachable"
fi
fi
;;
zfspool)
# The pool name in storage.cfg is e.g. "rpool/data" — only valid
# if the parent pool is imported.
parent_pool="${pool%%/*}"
if [[ -n "$parent_pool" ]] && zpool list -H -o name 2>/dev/null | grep -qFx "$parent_pool"; then
reachable_note="pool_imported"
else
reachable_note="pool_not_imported"
fi
;;
esac
if [[ "$already_present" == "true" ]]; then
action="present"
else
action="will_be_restored"
fi
pve_report="$(jq --argjson acc "$pve_report" \
--arg id "$id" --arg type "$type" --arg action "$action" --arg note "$reachable_note" \
-n '$acc + [{
id: $id,
type: $type,
action: $action,
note: (if $note == "" then null else $note end)
}]')"
done < <(printf '%s' "$manifest" | jq -c '.storage_inventory.pve_storage_cfg[]?')
# Compose
jq -n \
--argjson zfs "$zfs_report" \
--argjson lvm "$lvm_report" \
--argjson pve "$pve_report" \
'{ zfs: $zfs, lvm: $lvm, pve_storage: $pve }'