mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-06-14 12:27:02 +00:00
update beta 1.2.2.2
This commit is contained in:
98
scripts/backup_restore/restore/parse_manifest.sh
Normal file
98
scripts/backup_restore/restore/parse_manifest.sh
Normal 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
|
||||
239
scripts/backup_restore/restore/preflight_checks.sh
Normal file
239
scripts/backup_restore/restore/preflight_checks.sh
Normal 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)"
|
||||
124
scripts/backup_restore/restore/reinstall_drivers.sh
Normal file
124
scripts/backup_restore/restore/reinstall_drivers.sh
Normal 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
|
||||
105
scripts/backup_restore/restore/remap_network.sh
Normal file
105
scripts/backup_restore/restore/remap_network.sh
Normal 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 }'
|
||||
220
scripts/backup_restore/restore/restore_modes.sh
Normal file
220
scripts/backup_restore/restore/restore_modes.sh
Normal 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
|
||||
184
scripts/backup_restore/restore/run_restore.sh
Normal file
184
scripts/backup_restore/restore/run_restore.sh
Normal 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"
|
||||
148
scripts/backup_restore/restore/validate_storage.sh
Normal file
148
scripts/backup_restore/restore/validate_storage.sh
Normal 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 }'
|
||||
Reference in New Issue
Block a user