From 8e92df5bd778f054120f42869e4249ff6c239c3d Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sun, 21 Jun 2026 00:35:22 +0200 Subject: [PATCH] update 1.2.2.2 beta --- AppImage/components/host-backup.tsx | 740 ++++++++++++++++-- AppImage/scripts/flask_server.py | 363 ++++++++- scripts/backup_restore/backup_host.sh | 6 +- .../backup_restore/lib_host_backup_common.sh | 27 + .../backup_restore/run_scheduled_backup.sh | 21 +- 5 files changed, 1058 insertions(+), 99 deletions(-) diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index 12a9679f..48064d1c 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -36,6 +36,13 @@ import { Power, RefreshCw, Lock, + Eye, + Network, + Cpu, + Package, + ShieldCheck, + Info, + ListTree, } from "lucide-react" import { fetchApi, getApiUrl } from "../lib/api-config" import { formatStorage } from "../lib/utils" @@ -676,12 +683,14 @@ export function HostBackup() { const localJobId = u.local?.job_id const localHost = u.local?.source_hostname const localPath = u.local?.path + // Same palette as the DestinationRow accents so each + // backend reads identically across the two cards. const sourceBadgeCls = u.source === "pbs" - ? "text-purple-300 border-purple-400/40 bg-purple-500/5" + ? "text-purple-400 border-purple-500/20 bg-purple-500/10" : u.source === "borg" - ? "text-cyan-300 border-cyan-400/40 bg-cyan-500/5" - : "text-emerald-300 border-emerald-400/40 bg-emerald-500/5" + ? "text-fuchsia-400 border-fuchsia-500/20 bg-fuchsia-500/10" + : "text-blue-400 border-blue-500/20 bg-blue-500/10" return ( +
+ + +
{exportTask && ( @@ -1247,7 +1267,7 @@ function InspectModal({
{exportTask.error}
)} {exportTask.state === "completed" && exportTask.size_bytes > 0 && ( -
Packed size: {formatStorage(exportTask.size_bytes)}
+
Packed size: {formatBytes(exportTask.size_bytes)}
)} )} @@ -1356,6 +1376,14 @@ function InspectModal({ )} Delete + + + ) : ( + <> +

+ With a recovery passphrase, an encrypted copy of the keyfile is uploaded to PBS with every backup. If you lose this host, you can recover the keyfile on a fresh install with just the passphrase. Without it, losing the local keyfile means the encrypted backups become unrecoverable forever. +

+
+ + setPbsRecoveryPass(e.target.value)} + placeholder="Long random string — write it down somewhere safe" + className="font-mono mt-1 h-8 text-xs" + /> +
+
+ + setPbsRecoveryPass2(e.target.value)} + placeholder="Type it again" + className="font-mono mt-1 h-8 text-xs" + /> + {pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && ( +

Passphrases don't match.

+ )} +
+ {pbsRecoveryStatus?.has_recovery && pbsRecoveryChange && ( + + )} + + )} )} @@ -2893,8 +2983,19 @@ function CreateJobDialog({ {destResp?.borg?.length ? (
- - + + {/* Manual render: SelectValue's default mode + duplicates the item children and cuts + them in the middle on mobile. We render + "name — repository" ourselves with a + proper trailing ellipsis. */} + {(() => { + const sel = destResp.pbs.find((r) => r.repository === pbsRepository) + if (!sel) { + return Pick a PBS repository + } + return ( + + {sel.name} + — {sel.repository} + + ) + })()} {destResp.pbs.map((r) => ( @@ -3478,35 +3601,62 @@ function ManualBackupDialog({ Recovery passphrase (strongly recommended)
-

- With a recovery passphrase an encrypted copy of the keyfile is uploaded to PBS on every backup. Lets you recover it on a fresh host with just the passphrase. - {pbsRecoveryStatus?.has_recovery && " A recovery blob is already configured — leave blank to keep it."} -

-
- - setPbsRecoveryPass(e.target.value)} - placeholder={pbsRecoveryStatus?.has_recovery ? "(unchanged — type to replace)" : "Long random string — write it down somewhere safe"} - className="font-mono mt-1 h-8 text-xs" - /> -
-
- - setPbsRecoveryPass2(e.target.value)} - placeholder="Type it again" - className="font-mono mt-1 h-8 text-xs" - /> - {pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && ( -

Passphrases don't match.

- )} -
+ {pbsRecoveryStatus?.has_recovery && !pbsRecoveryChange ? ( +
+ +
+
Recovery escrow already configured for this host.
+
This backup will reuse the existing passphrase. No need to type it again.
+
+ +
+ ) : ( + <> +

+ With a recovery passphrase an encrypted copy of the keyfile is uploaded to PBS on every backup. Lets you recover it on a fresh host with just the passphrase. +

+
+ + setPbsRecoveryPass(e.target.value)} + placeholder="Long random string — write it down somewhere safe" + className="font-mono mt-1 h-8 text-xs" + /> +
+
+ + setPbsRecoveryPass2(e.target.value)} + placeholder="Type it again" + className="font-mono mt-1 h-8 text-xs" + /> + {pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && ( +

Passphrases don't match.

+ )} +
+ {pbsRecoveryStatus?.has_recovery && pbsRecoveryChange && ( + + )} + + )} )} @@ -3567,8 +3717,19 @@ function ManualBackupDialog({ {destResp?.borg?.length ? (
setQuery(e.target.value)} + placeholder="Filter paths…" + className="h-8 text-xs" + /> +
+
    + {filtered.slice(0, 2000).map((f) => ( +
  • + {f.path} + {formatBytes(f.size)} +
  • + ))} +
+
+
+ Showing {Math.min(filtered.length, 2000)} of {filtered.length}{query ? " filtered" : ""}{filtered.length !== files.length ? ` (total ${files.length})` : ""} + {truncated && · list truncated at 5000 — open the snapshot manually to see the rest} +
+
+ ) +} diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index bfd38d76..2b488705 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -13372,6 +13372,13 @@ def api_pbs_recovery_available(): if not bid.startswith('proxmenux-keyrecovery-'): continue t = it.get('backup-time', 0) + # ISO timestamp — proxmox-backup-client rejects the + # raw epoch with "unable to parse backup snapshot path". + from datetime import datetime, timezone + try: + iso = datetime.fromtimestamp(int(t), tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + except (ValueError, OSError): + iso = str(t) if bid not in latest or t > latest[bid]['backup_time']: latest[bid] = { 'repo_name': repo['name'], @@ -13379,7 +13386,7 @@ def api_pbs_recovery_available(): 'backup_id': bid, 'source_host': bid[len('proxmenux-keyrecovery-'):], 'backup_time': t, - 'snapshot': f"host/{bid}/{t}", + 'snapshot': f"host/{bid}/{iso}", } out.extend(latest.values()) out.sort(key=lambda x: x['backup_time'], reverse=True) @@ -15358,10 +15365,22 @@ def _list_pbs_snapshots_for_repo(repo: dict, timeout: int = 15) -> tuple[list, s if backup_type != 'host': continue backup_id = it.get('backup-id', '') + # Hide internal keyrecovery escrow snapshots — they're a + # ProxMenux implementation detail, not user-facing backups. + # See _PBS_RECOVERY_ENC_PATH and the runner upload step. + if backup_id.startswith('proxmenux-keyrecovery-'): + continue backup_time = it.get('backup-time', 0) - # The canonical snapshot identifier the client accepts back — - # exactly what we'd pass to `proxmox-backup-client restore`. - snapshot = f"{backup_type}/{backup_id}/{backup_time}" + # proxmox-backup-client expects the timestamp portion as an + # ISO-8601 UTC string ("2026-06-20T20:23:17Z"), NOT the raw + # epoch — passing the epoch back makes it reject the path + # with "unable to parse backup snapshot path". + from datetime import datetime, timezone + try: + iso = datetime.fromtimestamp(int(backup_time), tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + except (ValueError, OSError): + iso = str(backup_time) + snapshot = f"{backup_type}/{backup_id}/{iso}" # A snapshot is "encrypted" when ANY of its files reports a # crypt-mode other than `none`. Used to render the lock badge. files = it.get('files', []) or [] @@ -15474,8 +15493,53 @@ def _list_borg_archives_for_target(target: dict, timeout: int = 30) -> tuple[lis except json.JSONDecodeError: return [], 'borg list output was not valid JSON' + archives = data.get('archives', []) or [] + + # `borg list --json` only carries archive name + start time. Pull + # the per-archive size with a parallel batch of `borg info --json + # repo::name` calls so the UI can render a real number instead of + # "0 MB". 4-way concurrency keeps the wall-clock acceptable on + # repos with dozens of archives. Results are returned alongside + # the listing so callers don't have to re-issue per-row probes. + from concurrent.futures import ThreadPoolExecutor + + def _info_size(name: str) -> int: + if not name: + return 0 + try: + ri = subprocess.run( + ['borg', 'info', '--json', f'{repo}::{name}'], + capture_output=True, text=True, timeout=15, env=env, + ) + except (subprocess.TimeoutExpired, OSError): + return 0 + if ri.returncode != 0: + return 0 + try: + info = json.loads(ri.stdout) + except (json.JSONDecodeError, ValueError): + return 0 + arcs = info.get('archives') or [] + if not arcs: + return 0 + stats = arcs[0].get('stats') or {} + # Prefer original (uncompressed, undeduplicated) — matches what + # users expect when comparing to PBS source-data size. + v = stats.get('original_size') or stats.get('compressed_size') or 0 + try: + return int(v) + except (TypeError, ValueError): + return 0 + + names = [a.get('name') or '' for a in archives] + sizes: dict[str, int] = {} + if names: + with ThreadPoolExecutor(max_workers=4) as ex: + for nm, sz in zip(names, ex.map(_info_size, names)): + sizes[nm] = sz + out: list = [] - for arc in data.get('archives', []): + for arc in archives: name = arc.get('name') or '' start_iso = arc.get('start') or arc.get('time') or '' # Borg timestamps come as ISO-8601 with microseconds — convert @@ -15485,7 +15549,6 @@ def _list_borg_archives_for_target(target: dict, timeout: int = 30) -> tuple[lis if start_iso: try: from datetime import datetime - # Drop microseconds if present cleaned = start_iso.split('.')[0] dt = datetime.strptime(cleaned, '%Y-%m-%dT%H:%M:%S') backup_time = int(dt.timestamp()) @@ -15495,21 +15558,17 @@ def _list_borg_archives_for_target(target: dict, timeout: int = 30) -> tuple[lis 'backend': 'borg', 'repo_name': target['name'], 'repo_repository': repo, - 'snapshot': name, # canonical id used to extract + 'snapshot': name, 'backup_type': 'archive', 'backup_id': name, 'backup_time': backup_time, - 'size_bytes': 0, # borg list omits size; info per-archive is expensive + 'size_bytes': sizes.get(name, 0), 'owner': arc.get('username'), 'protected': False, 'files': [], 'fingerprint': None, 'borg_id': arc.get('id'), 'borg_start_iso': start_iso, - # Encrypted iff the destination's repo mode is not "none". - # Borg archives inherit the encryption of the repo they - # live in (cannot mix per-archive), so the target's flag - # is authoritative. 'encrypted': (target.get('encrypt_mode') or 'repokey') != 'none', }) return out, None @@ -15858,6 +15917,286 @@ def api_host_backups_archives(): return jsonify({'archives': archives}) +# ────────────────────────────────────────────────────────────── +# Snapshot inspection — extract + analyze backend +# ────────────────────────────────────────────────────────────── +# The "View contents" button in the Available Archives modal needs +# to read manifest + restore plan + file list out of ANY backup — +# PBS, Borg, or local. The trick is that PBS and Borg snapshots +# aren't files: they have to be extracted to a staging directory +# first. These helpers + endpoint do exactly that, reuse the four +# restore-side shell scripts (parse_manifest / run_restore), and +# clean the staging tree before returning. +# Staging lives under /tmp/pmnx-inspect-XXX and is removed in the +# `finally` block — there's nothing to leak even if the request +# fails mid-flight. + +def _inspect_extract_to_staging(source: str, repo_dict: dict, snapshot: str, staging: str) -> tuple: + """Pull a snapshot into . Returns (ok, error_message). + PBS: pulls the `hostcfg.pxar` archive (the canonical name the + ProxMenux backup uses) and lets the client write straight into + staging — the pxar already wraps the rootfs/+metadata/ layout. + Borg: `borg extract` into staging; if the archive used absolute + paths, the wrapped tree is normalized by _inspect_normalize_layout. + Local: tar/zst/gz extract into staging.""" + if source == 'pbs': + pwd = _resolve_pbs_password(repo_dict['name']) + if not pwd: + return False, f"no password for PBS repo \"{repo_dict['name']}\"" + env = {**os.environ, 'PBS_PASSWORD': pwd} + fp = _resolve_pbs_fingerprint(repo_dict['name']) + if fp: + env['PBS_FINGERPRINT'] = fp + cmd = ['proxmox-backup-client', 'restore', + '--repository', repo_dict['repository'], + snapshot, 'hostcfg.pxar', staging] + if os.path.isfile(_PBS_KEYFILE_PATH): + cmd.extend(['--keyfile', _PBS_KEYFILE_PATH]) + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=900, env=env) + except (subprocess.TimeoutExpired, OSError) as e: + return False, str(e) + if r.returncode != 0: + return False, (r.stderr.strip() or r.stdout.strip() or 'pbs restore failed')[:500] + return True, None + + if source == 'borg': + env = _borg_env_for(repo_dict) + cmd = ['borg', 'extract', f"{repo_dict['repository']}::{snapshot}"] + try: + r = subprocess.run(cmd, cwd=staging, capture_output=True, text=True, timeout=900, env=env) + except (subprocess.TimeoutExpired, OSError) as e: + return False, str(e) + if r.returncode != 0: + return False, (r.stderr.strip() or r.stdout.strip() or 'borg extract failed')[:500] + return True, None + + if source == 'local': + archive_path = repo_dict.get('path') or '' + if not os.path.isfile(archive_path): + return False, f'local archive not found: {archive_path}' + if archive_path.endswith('.tar.zst'): + cmd = ['tar', '--zstd', '-xf', archive_path, '-C', staging] + elif archive_path.endswith(('.tar.gz', '.tgz')): + cmd = ['tar', '-xzf', archive_path, '-C', staging] + elif archive_path.endswith('.tar'): + cmd = ['tar', '-xf', archive_path, '-C', staging] + else: + return False, f'unknown archive type: {archive_path}' + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=900) + except (subprocess.TimeoutExpired, OSError) as e: + return False, str(e) + if r.returncode != 0: + return False, (r.stderr.strip() or 'tar extract failed')[:500] + return True, None + + return False, f'unknown source: {source}' + + +def _inspect_normalize_layout(staging: str) -> bool: + """Coerce arbitrary backup layouts into the canonical + rootfs/+metadata/ structure that parse_manifest.sh / run_restore.sh + expect. Mirrors backup_host.sh::_rs_check_layout without the dialogs.""" + if os.path.isdir(os.path.join(staging, 'rootfs')): + return True + # Case 2: rootfs/ nested one level deep (Borg with absolute paths). + try: + for entry in os.listdir(staging): + sub = os.path.join(staging, entry) + if not os.path.isdir(sub): + continue + inner_rootfs = os.path.join(sub, 'rootfs') + if os.path.isdir(inner_rootfs): + shutil.move(inner_rootfs, os.path.join(staging, 'rootfs')) + inner_meta = os.path.join(sub, 'metadata') + if os.path.isdir(inner_meta): + shutil.move(inner_meta, os.path.join(staging, 'metadata')) + return True + except OSError: + return False + # Case 3: flat layout — etc/, var/, root/, usr/ extracted at top. + flat_indicators = ('etc', 'var', 'root', 'usr') + if any(os.path.isdir(os.path.join(staging, x)) for x in flat_indicators): + import tempfile + tmp = tempfile.mkdtemp(prefix='.rootfs_wrap.', dir=staging) + for entry in os.listdir(staging): + src = os.path.join(staging, entry) + if src == tmp: + continue + try: + shutil.move(src, os.path.join(tmp, entry)) + except OSError: + pass + shutil.move(tmp, os.path.join(staging, 'rootfs')) + os.makedirs(os.path.join(staging, 'metadata'), exist_ok=True) + return True + return False + + +def _inspect_compose_json(staging: str) -> dict: + """Run the restore-aware scripts against the normalized staging and + merge their JSON outputs into one dict the UI can render. Best- + effort: a failure in one section reports an error in that section + only, the rest of the report still comes back.""" + scripts_dir = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/restore' + out: dict = {} + + # ── Manifest ── + # Many in-the-wild backups don't ship a manifest.json (the + # collectors that generate it are wired up but not invoked by + # the current backup_host.sh / run_scheduled_backup.sh). When + # that's the case, set `manifest_missing=True` so the UI shows + # a soft informational note instead of a red error — and skip + # run_restore.sh entirely since it would only repeat the + # "no manifest" message. + try: + r = subprocess.run(['bash', f'{scripts_dir}/parse_manifest.sh', staging], + capture_output=True, text=True, timeout=30) + if r.returncode == 0 and r.stdout.strip(): + try: + out['manifest'] = json.loads(r.stdout) + except json.JSONDecodeError: + out['manifest_error'] = 'parse_manifest output was not valid JSON' + else: + err = (r.stderr.strip() or 'parse_manifest failed')[:500] + if 'no manifest.json' in err.lower(): + out['manifest_missing'] = True + else: + out['manifest_error'] = err + except (subprocess.TimeoutExpired, OSError) as e: + out['manifest_error'] = str(e) + + # ── Full preflight (storage + network + drivers + plan) ── + # Only run when a manifest is actually present — run_restore.sh + # uses it as input, and without one the report is just a repeat + # of the parse_manifest failure (already surfaced above). + if out.get('manifest'): + run_script = f'{scripts_dir}/run_restore.sh' + try: + r = subprocess.run(['bash', run_script, staging, '--mode', 'full', '--json'], + capture_output=True, text=True, timeout=180) + if r.stdout.strip(): + try: + out['plan'] = json.loads(r.stdout) + except json.JSONDecodeError: + out['plan_raw_stderr'] = r.stderr[:1000] + out['plan_error'] = 'run_restore output was not valid JSON' + else: + out['plan_error'] = (r.stderr.strip() or 'no report from run_restore')[:500] + except (subprocess.TimeoutExpired, OSError) as e: + out['plan_error'] = str(e) + + # ── File listing (capped, for the Files tab) ── + rootfs = os.path.join(staging, 'rootfs') + metadata = os.path.join(staging, 'metadata') + if os.path.isdir(rootfs): + files: list = [] + limit = 5000 + truncated = False + for root, dirs, fnames in os.walk(rootfs): + rel_dir = os.path.relpath(root, rootfs) + if rel_dir == '.': + rel_dir = '' + depth = rel_dir.count(os.sep) + (1 if rel_dir else 0) + if depth > 6: + dirs[:] = [] + continue + for fn in fnames: + full = os.path.join(root, fn) + try: + sz = os.path.getsize(full) + except OSError: + sz = 0 + files.append({ + 'path': ('/' + rel_dir + '/' + fn) if rel_dir else ('/' + fn), + 'size': sz, + }) + if len(files) >= limit: + truncated = True + break + if truncated: + break + out['files'] = files + out['files_truncated'] = truncated + out['files_total_count'] = len(files) + + # ── Bundle the loose metadata sidecars for quick reference ── + if os.path.isdir(metadata): + meta_view: dict = {} + for fname in ('run_info.env', 'pveversion.txt', 'selected_paths.txt', + 'missing_paths.txt', 'sha256sums.txt'): + fp = os.path.join(metadata, fname) + if os.path.isfile(fp): + try: + with open(fp, encoding='utf-8', errors='replace') as f: + meta_view[fname] = f.read() + except OSError: + pass + out['metadata_files'] = meta_view + + return out + + +@app.route('/api/host-backups/inspect-archive', methods=['POST']) +@require_auth +def api_host_backups_inspect_archive(): + """One-shot 'View Contents' endpoint for any backend. Extracts the + snapshot to a temp staging dir, runs the restore-side tools, then + cleans up. Body: + {source: "pbs"|"borg"|"local", repo_name?, snapshot?, path?} + """ + payload = request.get_json(silent=True) or {} + source = (payload.get('source') or '').strip() + if source not in ('pbs', 'borg', 'local'): + return jsonify({'error': 'source must be pbs, borg, or local'}), 400 + + repo_dict: dict = {} + snapshot = '' + if source == 'pbs': + repo_name = (payload.get('repo_name') or '').strip() + snapshot = (payload.get('snapshot') or '').strip() + if not repo_name or not snapshot: + return jsonify({'error': 'repo_name and snapshot are required for pbs'}), 400 + for r in _list_pbs_destinations(): + if r.get('name') == repo_name: + repo_dict = r + break + if not repo_dict: + return jsonify({'error': f'PBS repo "{repo_name}" not configured'}), 404 + elif source == 'borg': + repo_name = (payload.get('repo_name') or '').strip() + snapshot = (payload.get('snapshot') or '').strip() + if not repo_name or not snapshot: + return jsonify({'error': 'repo_name and snapshot are required for borg'}), 400 + for r in _list_borg_destinations(): + if r.get('name') == repo_name: + repo_dict = r + break + if not repo_dict: + return jsonify({'error': f'Borg repo "{repo_name}" not configured'}), 404 + else: # local + path = (payload.get('path') or '').strip() + if not path or not os.path.isfile(path): + return jsonify({'error': f'local archive not found: {path}'}), 404 + repo_dict = {'path': path} + + import tempfile + staging = tempfile.mkdtemp(prefix='pmnx-inspect-') + try: + ok, err = _inspect_extract_to_staging(source, repo_dict, snapshot, staging) + if not ok: + return jsonify({'error': err}), 500 + if not _inspect_normalize_layout(staging): + return jsonify({'error': 'archive layout not recognized — no rootfs/ found'}), 500 + return jsonify(_inspect_compose_json(staging)) + finally: + try: + shutil.rmtree(staging, ignore_errors=True) + except OSError: + pass + + @app.route('/api/host-backups/archives//manifest', methods=['GET']) @require_auth def api_host_backups_archive_manifest(archive_id): diff --git a/scripts/backup_restore/backup_host.sh b/scripts/backup_restore/backup_host.sh index 8a7d524c..9cefe0c4 100644 --- a/scripts/backup_restore/backup_host.sh +++ b/scripts/backup_restore/backup_host.sh @@ -228,8 +228,12 @@ _bk_borg() { t_start=$SECONDS : > "$log_file" + # Include manifest.json (top-level) when present — without it, + # parse_manifest.sh can't read the schema'd manifest on restore. + local -a _bk_borg_paths=(rootfs metadata) + [[ -f "$staging_root/manifest.json" ]] && _bk_borg_paths+=(manifest.json) if (cd "$staging_root" && "$borg_bin" create --stats --progress \ - "$repo::$archive_name" rootfs metadata) 2>&1 | tee -a "$log_file"; then + "$repo::$archive_name" "${_bk_borg_paths[@]}") 2>&1 | tee -a "$log_file"; then elapsed=$((SECONDS - t_start)) # Extract compressed size from borg stats if available diff --git a/scripts/backup_restore/lib_host_backup_common.sh b/scripts/backup_restore/lib_host_backup_common.sh index 685d429b..a0abd8c5 100644 --- a/scripts/backup_restore/lib_host_backup_common.sh +++ b/scripts/backup_restore/lib_host_backup_common.sh @@ -627,6 +627,33 @@ hb_prepare_staging() { find . -type f -print0 | sort -z | xargs -0 sha256sum 2>/dev/null \ > "$meta/checksums.sha256" || true ) + + # ── Structured manifest.json (proxmenux_backup_manifest) ── + # build_manifest.sh composes the collectors output into the + # exact JSON shape parse_manifest.sh + run_restore.sh expect. + # Without it, every restore flow degrades to "no manifest found" + # and the Monitor's View Contents / restore preview shows nothing. + # The collectors live next to this library file. + local _hb_lib_dir _hb_collector_dir _hb_build_manifest + _hb_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + _hb_collector_dir="$_hb_lib_dir/collectors" + _hb_build_manifest="$_hb_collector_dir/build_manifest.sh" + if [[ -x "$_hb_build_manifest" ]]; then + local -a _hb_archived=() + if [[ -s "$meta/selected_paths.txt" ]]; then + mapfile -t _hb_archived < "$meta/selected_paths.txt" + fi + if (( ${#_hb_archived[@]} > 0 )); then + bash "$_hb_build_manifest" --paths-archived "${_hb_archived[@]}" \ + > "$staging_root/manifest.json" 2>/dev/null || true + else + bash "$_hb_build_manifest" \ + > "$staging_root/manifest.json" 2>/dev/null || true + fi + # Drop the file if the collectors emitted nothing useful so + # parse_manifest doesn't read a 0-byte JSON downstream. + [[ -s "$staging_root/manifest.json" ]] || rm -f "$staging_root/manifest.json" + fi } hb_load_restore_paths() { diff --git a/scripts/backup_restore/run_scheduled_backup.sh b/scripts/backup_restore/run_scheduled_backup.sh index 2161cf4c..9343ccdc 100644 --- a/scripts/backup_restore/run_scheduled_backup.sh +++ b/scripts/backup_restore/run_scheduled_backup.sh @@ -193,8 +193,13 @@ _sb_run_borg() { return 1 fi + # Include manifest.json (top-level) when present. Borg needs the + # paths spelled out explicitly — without this, parse_manifest can't + # find the schema'd manifest on a restored Borg archive. + local -a _borg_paths=(rootfs metadata) + [[ -f "$stage_root/manifest.json" ]] && _borg_paths+=(manifest.json) (cd "$stage_root" && "$borg_bin" create --stats \ - "${repo}::${archive_name}" rootfs metadata) 2>&1 || return 1 + "${repo}::${archive_name}" "${_borg_paths[@]}") 2>&1 || return 1 "$borg_bin" prune -v --list "$repo" \ ${KEEP_LAST:+--keep-last "$KEEP_LAST"} \ @@ -271,9 +276,14 @@ _sb_run_pbs() { local stage_root="$1" local backup_id="$2" local epoch="$3" + # Stage the WHOLE root (rootfs/ + metadata/ + manifest.json), not + # just rootfs/. Mirrors backup_host.sh::_bk_pbs (the TUI flow) — and + # parse_manifest.sh / run_restore.sh need metadata/ + manifest.json + # to compose a meaningful restore plan. With only rootfs/ in the + # pxar, View Contents reports "no manifest.json" forever. local -a cmd=( proxmox-backup-client backup - "hostcfg.pxar:${stage_root}/rootfs" + "hostcfg.pxar:${stage_root}" --repository "$PBS_REPOSITORY" --backup-type host --backup-id "$backup_id" @@ -378,7 +388,12 @@ main() { if [[ "${PROFILE_MODE:-default}" == "custom" && -f "${JOBS_DIR}/${job_id}.paths" ]]; then mapfile -t paths < "${JOBS_DIR}/${job_id}.paths" else - mapfile -t paths < <(hb_default_profile_paths) + # Default profile = base paths + operator-saved extras (the + # TUI flow does the same — see hb_resolve_paths_mode in + # lib_host_backup_common.sh). Without the extras line, the + # `backup-extra-paths.txt` set in Settings was silently + # ignored for scheduled / manual runs from the Monitor. + mapfile -t paths < <(hb_default_profile_paths; hb_load_extra_paths) fi if [[ ${#paths[@]} -eq 0 ]]; then