mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-26 18:38:30 +00:00
update 1.2.2.2 beta
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
||||
ad6a863fc2e344e5fc5ffeb66b09de8339bfc6b8d3dfcaa8020b4fe89abb4d2e ProxMenux-1.2.2.2-beta.AppImage
|
||||
6a3aefbea44ba7260ee0a69685504ecc66c3d45ae1ddaf98deb6940975ce8e58 ProxMenux-1.2.2.2-beta.AppImage
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,12 @@ interface ScriptTerminalModalProps {
|
||||
description: string
|
||||
scriptName?: string
|
||||
params?: Record<string, string>
|
||||
// Optional callback fired when the script's WebSocket closes
|
||||
// (script_runner sends an exit code and then closes). Lets the
|
||||
// parent auto-dismiss the modal — used by host-backup's Restore
|
||||
// flow so "Press Enter to close" in the bash script actually
|
||||
// closes the modal without an extra click. Other callers ignore.
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
export function ScriptTerminalModal({
|
||||
@@ -58,6 +64,7 @@ export function ScriptTerminalModal({
|
||||
title,
|
||||
description,
|
||||
params = { EXECUTION_MODE: "web" },
|
||||
onComplete,
|
||||
}: ScriptTerminalModalProps) {
|
||||
const termRef = useRef<any>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
@@ -95,6 +102,13 @@ export function ScriptTerminalModal({
|
||||
paramsRef.current = params
|
||||
}, [params])
|
||||
|
||||
// Same trick for onComplete — we want the latest callback inside
|
||||
// the ws.onclose handler without re-running the connection effect.
|
||||
const onCompleteRef = useRef<(() => void) | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
onCompleteRef.current = onComplete
|
||||
}, [onComplete])
|
||||
|
||||
const attemptReconnect = useCallback(() => {
|
||||
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
|
||||
return
|
||||
@@ -195,6 +209,7 @@ const initMessage = {
|
||||
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
|
||||
} else {
|
||||
setIsComplete(true)
|
||||
onCompleteRef.current?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,6 +397,7 @@ const initMessage = {
|
||||
|
||||
if (!isComplete) {
|
||||
setIsComplete(true)
|
||||
onCompleteRef.current?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ from flask_notification_routes import notification_bp # noqa: E402
|
||||
from flask_oci_routes import oci_bp # noqa: E402
|
||||
from notification_manager import notification_manager # noqa: E402
|
||||
import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates
|
||||
from jwt_middleware import require_auth # noqa: E402
|
||||
from jwt_middleware import require_auth, require_auth_or_ticket # noqa: E402
|
||||
import auth_manager # noqa: E402
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
@@ -15826,7 +15826,7 @@ def api_host_backups_remote_archive_export_status(task_id):
|
||||
|
||||
|
||||
@app.route('/api/host-backups/remote-archives/export/<task_id>/download', methods=['GET'])
|
||||
@require_auth
|
||||
@require_auth_or_ticket
|
||||
def api_host_backups_remote_archive_export_download(task_id):
|
||||
"""Stream the packed export to the browser. Same auth + send_file
|
||||
pattern as the local archive download. Cleans up the tarball after
|
||||
@@ -16121,6 +16121,22 @@ def _inspect_compose_json(staging: str) -> dict:
|
||||
out['files_truncated'] = truncated
|
||||
out['files_total_count'] = len(files)
|
||||
|
||||
# ── Rollback plan ──
|
||||
# Compare backup state vs current host to surface VMs/LXCs that
|
||||
# would be removed and components that would be uninstalled by
|
||||
# a "restore to backup state" operation. Read-only here — the
|
||||
# actual rollback is executed by apply_cluster_postboot.sh after
|
||||
# the operator confirms in the Restore flow.
|
||||
rollback_script = f'{scripts_dir}/compute_rollback_plan.sh'
|
||||
if os.path.isfile(rollback_script):
|
||||
try:
|
||||
r = subprocess.run(['bash', rollback_script, staging],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
out['rollback_plan'] = json.loads(r.stdout)
|
||||
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# ── Bundle the loose metadata sidecars for quick reference ──
|
||||
if os.path.isdir(metadata):
|
||||
meta_view: dict = {}
|
||||
@@ -16197,6 +16213,166 @@ def api_host_backups_inspect_archive():
|
||||
pass
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Restore prepare/cleanup — Monitor → shell TUI handoff
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# The "Restore" button in the Inspect modal goes through TWO steps:
|
||||
# 1. POST /restore/prepare → backend extracts the snapshot to a
|
||||
# persistent staging dir under _RESTORE_STAGING_ROOT. Returns
|
||||
# the staging path so the next step can find it.
|
||||
# 2. The frontend launches a ScriptTerminalModal pointing at
|
||||
# restore/monitor_apply.sh with that staging path + selected
|
||||
# mode/components. monitor_apply.sh sources backup_host.sh and
|
||||
# calls _rs_run_complete_guided / _rs_run_custom_restore — the
|
||||
# exact same apply path the TUI uses.
|
||||
#
|
||||
# Staging dirs auto-expire: every new prepare() removes any leftover
|
||||
# from previous runs older than _RESTORE_STAGING_TTL_SECONDS so a
|
||||
# crashed Monitor session doesn't accumulate gigabytes on /tmp.
|
||||
|
||||
_RESTORE_STAGING_ROOT = '/tmp'
|
||||
_RESTORE_STAGING_PREFIX = 'pmnx-restore-'
|
||||
_RESTORE_STAGING_TTL_SECONDS = 6 * 3600 # 6 hours
|
||||
|
||||
def _restore_staging_gc():
|
||||
"""Remove stale Monitor restore staging dirs. Best-effort; any
|
||||
OSError silently skips the entry so a permission glitch on one
|
||||
item doesn't block the new prepare."""
|
||||
import time
|
||||
now = time.time()
|
||||
try:
|
||||
entries = os.listdir(_RESTORE_STAGING_ROOT)
|
||||
except OSError:
|
||||
return
|
||||
for entry in entries:
|
||||
if not entry.startswith(_RESTORE_STAGING_PREFIX):
|
||||
continue
|
||||
full = os.path.join(_RESTORE_STAGING_ROOT, entry)
|
||||
try:
|
||||
age = now - os.path.getmtime(full)
|
||||
except OSError:
|
||||
continue
|
||||
if age > _RESTORE_STAGING_TTL_SECONDS:
|
||||
try:
|
||||
shutil.rmtree(full, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@app.route('/api/host-backups/restore/prepare', methods=['POST'])
|
||||
@require_auth
|
||||
def api_host_backups_restore_prepare():
|
||||
"""Extract a snapshot to a persistent staging directory. The
|
||||
frontend follows up by launching monitor_apply.sh against that
|
||||
staging via ScriptTerminalModal. Body is the same shape as
|
||||
/inspect-archive: {source, repo_name?, snapshot?, path?}.
|
||||
Returns: {staging_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:
|
||||
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}
|
||||
|
||||
_restore_staging_gc()
|
||||
|
||||
import tempfile
|
||||
staging = tempfile.mkdtemp(prefix=_RESTORE_STAGING_PREFIX, dir=_RESTORE_STAGING_ROOT)
|
||||
try:
|
||||
ok, err = _inspect_extract_to_staging(source, repo_dict, snapshot, staging)
|
||||
if not ok:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
return jsonify({'error': err}), 500
|
||||
if not _inspect_normalize_layout(staging):
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
return jsonify({'error': 'archive layout not recognized — no rootfs/ found'}), 500
|
||||
# List the real backup paths so the Custom restore checklist
|
||||
# only offers what's actually present (default profile + the
|
||||
# operator's extras). Mirrors what _rs_select_component_paths
|
||||
# in backup_host.sh now does after the paths-not-components
|
||||
# refactor — perfect backup↔restore parity.
|
||||
paths_available: list = []
|
||||
list_script = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/restore/list_paths.sh'
|
||||
if os.path.isfile(list_script):
|
||||
try:
|
||||
r = subprocess.run(['bash', list_script, staging],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
paths_available = json.loads(r.stdout.strip())
|
||||
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
rollback_plan: dict = {}
|
||||
rollback_script = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/restore/compute_rollback_plan.sh'
|
||||
if os.path.isfile(rollback_script):
|
||||
try:
|
||||
r = subprocess.run(['bash', rollback_script, staging],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
rollback_plan = json.loads(r.stdout)
|
||||
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
'staging_path': staging,
|
||||
'paths_available': paths_available,
|
||||
'rollback_plan': rollback_plan,
|
||||
})
|
||||
except Exception as e:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/host-backups/restore/cleanup', methods=['POST'])
|
||||
@require_auth
|
||||
def api_host_backups_restore_cleanup():
|
||||
"""Explicit cleanup for a staging dir. Called by the UI after the
|
||||
ScriptTerminalModal closes. Best-effort: the GC in prepare() will
|
||||
catch anything missed here within _RESTORE_STAGING_TTL_SECONDS."""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
staging = (payload.get('staging_path') or '').strip()
|
||||
# Defense in depth: only allow paths under the staging root with
|
||||
# the expected prefix. Anything else could be the operator trying
|
||||
# to coerce the endpoint into rm-ing arbitrary files.
|
||||
if (not staging
|
||||
or not staging.startswith(os.path.join(_RESTORE_STAGING_ROOT, _RESTORE_STAGING_PREFIX))
|
||||
or '..' in staging.split(os.sep)):
|
||||
return jsonify({'error': 'invalid staging_path'}), 400
|
||||
try:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
except OSError as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return jsonify({'status': 'ok'})
|
||||
|
||||
|
||||
@app.route('/api/host-backups/archives/<path:archive_id>/manifest', methods=['GET'])
|
||||
@require_auth
|
||||
def api_host_backups_archive_manifest(archive_id):
|
||||
@@ -16341,7 +16517,7 @@ def api_host_backups_archive_prepare_restore(archive_id):
|
||||
|
||||
|
||||
@app.route('/api/host-backups/archives/<path:archive_id>/download', methods=['GET'])
|
||||
@require_auth
|
||||
@require_auth_or_ticket
|
||||
def api_host_backups_archive_download(archive_id):
|
||||
"""Stream the .tar.zst archive back to the operator with a sane
|
||||
Content-Disposition. send_file handles range requests + streaming
|
||||
|
||||
@@ -66,6 +66,47 @@ def require_auth(f):
|
||||
return decorated_function
|
||||
|
||||
|
||||
def require_auth_or_ticket(f):
|
||||
"""Like `require_auth` but ALSO accepts a single-use `?ticket=...`
|
||||
query parameter (same tickets `/api/terminal/ticket` issues for
|
||||
WebSockets). Use on endpoints that the browser needs to invoke
|
||||
from an <a download href="..."> tag — anchor tags can't send the
|
||||
Authorization header, so the caller fetches a ticket first and
|
||||
appends it to the URL.
|
||||
|
||||
Use only for streaming downloads where adding `?ticket=...` is
|
||||
the only practical way to authenticate; everything else stays
|
||||
on plain Bearer-token auth."""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
config = load_auth_config()
|
||||
if not config.get("enabled", False) or config.get("declined", False):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# First try the Bearer header (fetch from JS uses this).
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header:
|
||||
parts = auth_header.split()
|
||||
if len(parts) == 2 and parts[0].lower() == 'bearer':
|
||||
if verify_token(parts[1]):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Fall through to single-use ticket (works for <a download>).
|
||||
try:
|
||||
from flask_terminal_routes import _consume_terminal_ticket
|
||||
if _consume_terminal_ticket(request.args.get('ticket', '')):
|
||||
return f(*args, **kwargs)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
"error": "Authentication required",
|
||||
"message": "Provide a Bearer token in the Authorization header or a fresh ?ticket=... from /api/terminal/ticket"
|
||||
}), 401
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def require_admin_scope(f):
|
||||
"""Like `require_auth` but ALSO requires the token's `scope == full_admin`.
|
||||
|
||||
|
||||
@@ -378,6 +378,40 @@ POSTBOOT_END_EPOCH=$(date +%s)
|
||||
POSTBOOT_DURATION=$((POSTBOOT_END_EPOCH - $(stat -c %Y "$LOG_FILE")))
|
||||
POSTBOOT_DURATION_FMT=$(printf '%dm%02ds' $((POSTBOOT_DURATION / 60)) $((POSTBOOT_DURATION % 60)))
|
||||
|
||||
# ── Rollback delta report (read-only) ──────────────────────────
|
||||
# If _rs_prepare_pending_restore left a rollback.json in the
|
||||
# pending dir, surface the deltas that a full rollback would
|
||||
# touch: VMs/LXCs created after the backup that are still here,
|
||||
# extra components not present in the backup. The operator
|
||||
# decides what to do with them — we don't destroy guests or
|
||||
# uninstall packages automatically (left to a future R2.D fase 2
|
||||
# once every installer ships an --auto-uninstall mode).
|
||||
ROLLBACK_PLAN_FILE=""
|
||||
[[ -n "$PENDING_DIR" && -f "$PENDING_DIR/rollback.json" ]] && \
|
||||
ROLLBACK_PLAN_FILE="$PENDING_DIR/rollback.json"
|
||||
if [[ -n "$ROLLBACK_PLAN_FILE" ]] && command -v jq >/dev/null 2>&1; then
|
||||
rb_vm_extras=$(jq -r '.vms_to_remove | join(", ") // ""' "$ROLLBACK_PLAN_FILE" 2>/dev/null)
|
||||
rb_lxc_extras=$(jq -r '.lxcs_to_remove | join(", ") // ""' "$ROLLBACK_PLAN_FILE" 2>/dev/null)
|
||||
rb_comp_extras=$(jq -r '.components_to_uninstall | join(", ") // ""' "$ROLLBACK_PLAN_FILE" 2>/dev/null)
|
||||
if [[ -n "$rb_vm_extras" || -n "$rb_lxc_extras" || -n "$rb_comp_extras" ]]; then
|
||||
echo ""
|
||||
echo "── Rollback delta report ──"
|
||||
echo "These entries exist on the host but were NOT in the restored backup."
|
||||
echo "Review them and remove manually if a clean rollback is desired:"
|
||||
[[ -n "$rb_vm_extras" ]] && echo " • VMs created after the backup: $rb_vm_extras"
|
||||
[[ -n "$rb_lxc_extras" ]] && echo " • LXCs created after the backup: $rb_lxc_extras"
|
||||
[[ -n "$rb_comp_extras" ]] && echo " • Components installed after the backup: $rb_comp_extras"
|
||||
echo ""
|
||||
echo "Manual cleanup commands (run only if you want to truly roll back):"
|
||||
for _id in $(echo "$rb_vm_extras" | tr ',' ' '); do
|
||||
[[ -n "$_id" ]] && echo " qm stop $_id 2>/dev/null; qm destroy $_id --purge"
|
||||
done
|
||||
for _id in $(echo "$rb_lxc_extras" | tr ',' ' '); do
|
||||
[[ -n "$_id" ]] && echo " pct stop $_id 2>/dev/null; pct destroy $_id --purge"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Notify ProxMenux Monitor that we're done ───────────────────
|
||||
# Routes through the user's configured channels (Telegram, Discord,
|
||||
# ntfy, etc.). Localhost-only endpoint, no auth needed. We try
|
||||
|
||||
0
scripts/backup_restore/apply_pending_restore.sh
Normal file → Executable file
0
scripts/backup_restore/apply_pending_restore.sh
Normal file → Executable file
367
scripts/backup_restore/backup_host.sh
Normal file → Executable file
367
scripts/backup_restore/backup_host.sh
Normal file → Executable file
@@ -1456,8 +1456,16 @@ _rs_apply() {
|
||||
done
|
||||
|
||||
elapsed=$((SECONDS - t_start))
|
||||
[[ "$group" == "hot" || "$group" == "all" ]] && \
|
||||
# Skip `systemctl daemon-reload` when invoked from the Monitor
|
||||
# (HB_MONITOR_FLOW=1). The reload itself doesn't restart the
|
||||
# Monitor's unit, but it marks units as "needs restart" and a
|
||||
# later systemctl call against the Monitor would cut the WS
|
||||
# session. The restored unit files are already on disk — they
|
||||
# take effect at the next reboot, which the Monitor flow asks
|
||||
# for explicitly at the end.
|
||||
if [[ "$group" == "hot" || "$group" == "all" ]] && [[ "${HB_MONITOR_FLOW:-0}" != "1" ]]; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo -e ""
|
||||
echo -e "${TAB}${BOLD}$(translate "Restore applied:")${CL}"
|
||||
@@ -1603,6 +1611,15 @@ _rs_prompt_zfs_opt_in() {
|
||||
|
||||
_rs_finish_flow() {
|
||||
echo -e ""
|
||||
if [[ "${HB_MONITOR_FLOW:-0}" == "1" ]]; then
|
||||
# Same UX as the TUI but the prompt explicitly says "close"
|
||||
# — the Monitor's ScriptTerminalModal sees isComplete=true on
|
||||
# WS close and dismisses (via the onComplete prop) so the
|
||||
# operator doesn't need to click the Close button.
|
||||
msg_success "$(translate "Press Enter to close...")"
|
||||
read -r
|
||||
return 0
|
||||
fi
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
}
|
||||
@@ -1661,6 +1678,10 @@ _rs_offer_reboot_after_pending() {
|
||||
|
||||
local prompt="$(translate "Pending restore prepared. A reboot is required to complete it.")"$'\n\n'"${bg_block}$(translate "Reboot now?")"
|
||||
|
||||
# Same dialog for TUI and Monitor — mirrors the post-install
|
||||
# flow (Yes → reboot, No → "You can reboot later manually").
|
||||
# The Monitor's PTY backs whiptail correctly, so the operator
|
||||
# sees the same Yes/No prompt as in the shell.
|
||||
if whiptail --title "$(translate "Reboot Required")" \
|
||||
--yesno "$prompt" 22 90; then
|
||||
msg_warn "$(translate "Rebooting the system...")"
|
||||
@@ -1800,6 +1821,19 @@ _rs_prepare_pending_restore() {
|
||||
|
||||
[[ -d "$staging_root/metadata" ]] && cp -a "$staging_root/metadata/." "$pending_dir/metadata/" 2>/dev/null || true
|
||||
|
||||
# Persist the rollback plan so apply_cluster_postboot.sh can
|
||||
# surface a clear "what differs vs backup" report after boot.
|
||||
# Read-only here — the script just informs the operator about
|
||||
# VMs/LXCs/components that exist now but not in the backup.
|
||||
# Actual destroy is left manual until --auto-uninstall lands
|
||||
# on every installer.
|
||||
local _rb_script="${SCRIPT_DIR}/restore/compute_rollback_plan.sh"
|
||||
[[ ! -x "$_rb_script" ]] && _rb_script="${LOCAL_SCRIPTS:-/usr/local/share/proxmenux/scripts}/backup_restore/restore/compute_rollback_plan.sh"
|
||||
if [[ -x "$_rb_script" ]]; then
|
||||
bash "$_rb_script" "$staging_root" > "$pending_dir/rollback.json" 2>/dev/null || true
|
||||
[[ ! -s "$pending_dir/rollback.json" ]] && rm -f "$pending_dir/rollback.json"
|
||||
fi
|
||||
|
||||
cat > "$pending_dir/plan.env" <<EOF
|
||||
RESTORE_ID=${restore_id}
|
||||
CREATED_AT=${created_at}
|
||||
@@ -2061,18 +2095,22 @@ _rs_component_is_available() {
|
||||
}
|
||||
|
||||
_rs_unique_paths() {
|
||||
local __out_var="$1"
|
||||
# All locals prefixed with `_rup_` so the nameref doesn't bind
|
||||
# to one of OUR locals when a caller passes the same array name
|
||||
# (e.g. `_rs_unique_paths uniq "$@"` — bash would otherwise
|
||||
# resolve `__out_ref → uniq` to our local instead of the caller's).
|
||||
local _rup_out_var="$1"
|
||||
shift
|
||||
local -A seen=()
|
||||
local -a uniq=()
|
||||
local p
|
||||
for p in "$@"; do
|
||||
[[ -z "$p" || -n "${seen[$p]}" ]] && continue
|
||||
seen["$p"]=1
|
||||
uniq+=("$p")
|
||||
local -A _rup_seen=()
|
||||
local -a _rup_uniq=()
|
||||
local _rup_p
|
||||
for _rup_p in "$@"; do
|
||||
[[ -z "$_rup_p" || -n "${_rup_seen[$_rup_p]}" ]] && continue
|
||||
_rup_seen["$_rup_p"]=1
|
||||
_rup_uniq+=("$_rup_p")
|
||||
done
|
||||
local -n __out_ref="$__out_var"
|
||||
__out_ref=("${uniq[@]}")
|
||||
local -n _rup_out_ref="$_rup_out_var"
|
||||
_rup_out_ref=("${_rup_uniq[@]}")
|
||||
}
|
||||
|
||||
_rs_collect_stats_for_paths() {
|
||||
@@ -2125,52 +2163,95 @@ _rs_warn_dangerous_paths() {
|
||||
|
||||
_rs_select_component_paths() {
|
||||
local staging_root="$1"
|
||||
local __out_var="$2"
|
||||
local -n __out_ref="$__out_var"
|
||||
# IMPORTANT: every local inside this function must use a unique
|
||||
# prefix (`_rsp_`). Bash nameref resolution walks the dynamic
|
||||
# scope outwards and stops at the first matching name — if we
|
||||
# declare `local -a selected_paths=()` while the caller's array
|
||||
# is also called `selected_paths`, _rs_unique_paths's
|
||||
# `local -n __out_ref="selected_paths"` would resolve to OUR
|
||||
# local one and the caller's array silently stays empty (which
|
||||
# surfaced as the "Selected paths produced no entries to apply"
|
||||
# dialog despite a non-empty selection).
|
||||
local _rsp_out_var="$2"
|
||||
local -n _rsp_out_ref="$_rsp_out_var"
|
||||
|
||||
local -a component_ids=(
|
||||
network ssh_access host_identity cron_jobs apt_repos kernel_boot
|
||||
systemd_custom scripts root_config root_ssh zfs_cfg postfix_cfg cluster_cfg
|
||||
)
|
||||
local -a checklist=()
|
||||
local comp_id
|
||||
for comp_id in "${component_ids[@]}"; do
|
||||
_rs_component_is_available "$staging_root" "$comp_id" || continue
|
||||
checklist+=("$comp_id" "$(_rs_component_label "$comp_id")" "off")
|
||||
done
|
||||
|
||||
if [[ ${#checklist[@]} -eq 0 ]]; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate "No components available")" \
|
||||
--msgbox "$(translate "No restorable components were detected in this backup.")" 8 68
|
||||
return 1
|
||||
# ── Build the list of paths actually present in this backup ──
|
||||
local -a _rsp_backup=()
|
||||
local _rsp_selected_file="$staging_root/metadata/selected_paths.txt"
|
||||
if [[ -f "$_rsp_selected_file" ]]; then
|
||||
local _rsp_rel
|
||||
while IFS= read -r _rsp_rel; do
|
||||
[[ -z "$_rsp_rel" ]] && continue
|
||||
_rsp_rel="${_rsp_rel#/}"
|
||||
[[ -e "$staging_root/rootfs/$_rsp_rel" ]] && _rsp_backup+=("$_rsp_rel")
|
||||
done < "$_rsp_selected_file"
|
||||
else
|
||||
# Pre-metadata backup: walk rootfs/ at depth 1-2.
|
||||
local _rsp_entry
|
||||
while IFS= read -r _rsp_entry; do
|
||||
[[ -z "$_rsp_entry" ]] && continue
|
||||
_rsp_entry="${_rsp_entry#./}"
|
||||
_rsp_backup+=("$_rsp_entry")
|
||||
done < <(cd "$staging_root/rootfs" 2>/dev/null \
|
||||
&& find . -mindepth 1 -maxdepth 2 -print 2>/dev/null)
|
||||
fi
|
||||
|
||||
local selected
|
||||
selected=$(dialog --backtitle "ProxMenux" --separate-output \
|
||||
--title "$(translate "Custom restore by components")" \
|
||||
--checklist "\n$(translate "Select components to restore:")" \
|
||||
24 94 14 "${checklist[@]}" 3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
if [[ -z "$selected" ]]; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate "No components selected")" \
|
||||
--msgbox "$(translate "Select at least one component to continue.")" 8 66
|
||||
return 1
|
||||
fi
|
||||
|
||||
local -a selected_paths=()
|
||||
while IFS= read -r comp_id; do
|
||||
[[ -z "$comp_id" ]] && continue
|
||||
local rel
|
||||
while IFS= read -r rel; do
|
||||
[[ -n "$rel" && -e "$staging_root/rootfs/$rel" ]] && selected_paths+=("$rel")
|
||||
done < <(_rs_component_paths "$comp_id")
|
||||
done <<< "$selected"
|
||||
|
||||
_rs_unique_paths "$__out_var" "${selected_paths[@]}"
|
||||
|
||||
if [[ ${#__out_ref[@]} -eq 0 ]]; then
|
||||
if [[ ${#_rsp_backup[@]} -eq 0 ]]; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate "No paths available")" \
|
||||
--msgbox "$(translate "Selected components have no matching paths in this backup.")" 8 72
|
||||
--msgbox "$(translate "This backup does not contain any restorable paths.")" 8 70
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── Non-interactive bypass for the Monitor ──
|
||||
local _rsp_selected=""
|
||||
if [[ -n "${HB_PRESELECTED_PATHS:-}" ]]; then
|
||||
local IFS=','
|
||||
local _rsp_pp
|
||||
for _rsp_pp in $HB_PRESELECTED_PATHS; do
|
||||
_rsp_pp="${_rsp_pp#"${_rsp_pp%%[![:space:]]*}"}"
|
||||
_rsp_pp="${_rsp_pp%"${_rsp_pp##*[![:space:]]}"}"
|
||||
[[ -z "$_rsp_pp" ]] && continue
|
||||
_rsp_pp="${_rsp_pp#/}"
|
||||
local _rsp_known
|
||||
for _rsp_known in "${_rsp_backup[@]}"; do
|
||||
[[ "$_rsp_pp" == "$_rsp_known" ]] && { _rsp_selected+="${_rsp_pp}"$'\n'; break; }
|
||||
done
|
||||
done
|
||||
unset IFS
|
||||
fi
|
||||
|
||||
# ── Dialog checklist (one entry per real backup path) ──
|
||||
if [[ -z "$_rsp_selected" ]]; then
|
||||
local -a _rsp_checklist=()
|
||||
local _rsp_p
|
||||
for _rsp_p in "${_rsp_backup[@]}"; do
|
||||
_rsp_checklist+=("$_rsp_p" "/$_rsp_p" "off")
|
||||
done
|
||||
|
||||
_rsp_selected=$(dialog --backtitle "ProxMenux" --separate-output \
|
||||
--title "$(translate "Custom restore by paths")" \
|
||||
--checklist "\n$(translate "Select the paths to restore (one per backup entry):")" \
|
||||
24 94 16 "${_rsp_checklist[@]}" 3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
if [[ -z "$_rsp_selected" ]]; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate "Nothing selected")" \
|
||||
--msgbox "$(translate "Select at least one path to continue.")" 8 66
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
local -a _rsp_picked=()
|
||||
local _rsp_line
|
||||
while IFS= read -r _rsp_line; do
|
||||
[[ -z "$_rsp_line" ]] && continue
|
||||
_rsp_picked+=("$_rsp_line")
|
||||
done <<< "$_rsp_selected"
|
||||
|
||||
_rs_unique_paths "$_rsp_out_var" "${_rsp_picked[@]}"
|
||||
|
||||
if [[ ${#_rsp_out_ref[@]} -eq 0 ]]; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate "Nothing to restore")" \
|
||||
--msgbox "$(translate "Selected paths produced no entries to apply.")" 8 70
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
@@ -2183,127 +2264,74 @@ _rs_run_custom_restore() {
|
||||
_rs_select_component_paths "$staging_root" selected_paths || return 1
|
||||
_rs_collect_stats_for_paths "${selected_paths[@]}"
|
||||
|
||||
while true; do
|
||||
local choice
|
||||
choice=$(dialog --backtitle "ProxMenux" \
|
||||
--title "$(translate "Custom restore")" \
|
||||
--menu "\n$(translate "Selected component paths:") ${RS_SEL_TOTAL}" \
|
||||
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
|
||||
1 "$(translate "Apply safe changes now") (${RS_SEL_HOT})" \
|
||||
2 "$(translate "Apply safe + reboot-required") ($((RS_SEL_HOT + RS_SEL_REBOOT)))" \
|
||||
3 "$(translate "Apply all selected now (advanced)") (${RS_SEL_TOTAL})" \
|
||||
4 "$(translate "Reselect components")" \
|
||||
5 "$(translate "Apply safe now + schedule remaining for next boot")" \
|
||||
6 "$(translate "Schedule selected components for next boot (no live apply)")" \
|
||||
0 "$(translate "Return")" \
|
||||
3>&1 1>&2 2>&3) || return 1
|
||||
# ── Smart auto-strategy (no 6-option menu any more) ──
|
||||
# The classifier already split every selected path into:
|
||||
# RS_SEL_HOT — applyable live, no risk
|
||||
# RS_SEL_REBOOT — needs reboot to take effect
|
||||
# RS_SEL_DANGEROUS — applyable live but risky (e.g. network
|
||||
# from a SSH session)
|
||||
# The operator doesn't need to know that taxonomy. We just:
|
||||
# 1. Show ONE confirmation summarizing what happens.
|
||||
# 2. Apply hot paths now.
|
||||
# 3. Schedule reboot + dangerous paths for next boot.
|
||||
# 4. Tell the operator a reboot is needed when there's pending.
|
||||
|
||||
case "$choice" in
|
||||
1)
|
||||
if [[ "$RS_SEL_HOT" -eq 0 ]]; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate "Nothing to apply")" \
|
||||
--msgbox "$(translate "No safe-now paths in selected components.")" 8 60
|
||||
continue
|
||||
fi
|
||||
if ! whiptail --title "$(translate "Confirm")" \
|
||||
--yesno "$(translate "Apply safe changes from selected components now?")" 9 72; then
|
||||
continue
|
||||
fi
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "Applying selected safe changes")"
|
||||
_rs_apply "$staging_root" hot "${selected_paths[@]}"
|
||||
[[ "$RS_SEL_REBOOT" -gt 0 || "$RS_SEL_DANGEROUS" -gt 0 ]] && \
|
||||
msg_warn "$(translate "Some selected paths were not applied in safe mode.")"
|
||||
_rs_finish_flow
|
||||
return 0
|
||||
;;
|
||||
if [[ "$RS_SEL_TOTAL" -eq 0 ]]; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate "Nothing to restore")" \
|
||||
--msgbox "$(translate "Selected paths produced no entries to apply.")" 8 70
|
||||
return 1
|
||||
fi
|
||||
|
||||
2)
|
||||
if [[ $((RS_SEL_HOT + RS_SEL_REBOOT)) -eq 0 ]]; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate "Nothing to apply")" \
|
||||
--msgbox "$(translate "No safe/reboot paths in selected components.")" 8 64
|
||||
continue
|
||||
fi
|
||||
if ! whiptail --title "$(translate "Confirm")" \
|
||||
--yesno "$(translate "Apply safe + reboot-required paths from selected components now?")"$'\n\n'"$(translate "Risky live paths will be skipped.")" \
|
||||
11 78; then
|
||||
continue
|
||||
fi
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "Applying selected safe + reboot changes")"
|
||||
[[ "$RS_SEL_HOT" -gt 0 ]] && _rs_apply "$staging_root" hot "${selected_paths[@]}"
|
||||
[[ "$RS_SEL_REBOOT" -gt 0 ]] && _rs_apply "$staging_root" reboot "${selected_paths[@]}"
|
||||
[[ "$RS_SEL_DANGEROUS" -gt 0 ]] && \
|
||||
msg_warn "$(translate "Risky selected paths were skipped in this mode.")"
|
||||
_rs_finish_flow
|
||||
return 0
|
||||
;;
|
||||
# SSH/network protection — if the operator is on a SSH session
|
||||
# and selected network paths, offer to move EVERYTHING to next
|
||||
# boot (avoids disconnection mid-restore). _rs_handle_ssh_network_risk
|
||||
# returns 2 when it has already scheduled for boot — in that case
|
||||
# we're done.
|
||||
local ssh_network_rc
|
||||
_rs_handle_ssh_network_risk "$staging_root" "${selected_paths[@]}"
|
||||
ssh_network_rc=$?
|
||||
[[ $ssh_network_rc -eq 2 ]] && return 0
|
||||
[[ $ssh_network_rc -ne 0 ]] && return 1
|
||||
|
||||
3)
|
||||
local ssh_network_rc
|
||||
_rs_handle_ssh_network_risk "$staging_root" "${selected_paths[@]}"
|
||||
ssh_network_rc=$?
|
||||
[[ $ssh_network_rc -eq 2 ]] && return 0
|
||||
[[ $ssh_network_rc -ne 0 ]] && continue
|
||||
local hot_count="$RS_SEL_HOT"
|
||||
local pending_count=$(( RS_SEL_REBOOT + RS_SEL_DANGEROUS ))
|
||||
|
||||
[[ "$RS_SEL_DANGEROUS" -gt 0 ]] && _rs_warn_dangerous_paths "${selected_paths[@]}"
|
||||
if ! whiptail --title "$(translate "Final confirmation")" \
|
||||
--yesno "$(translate "Apply ALL selected component paths now? This can include risky paths.")" \
|
||||
10 78; then
|
||||
continue
|
||||
fi
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "Applying all selected component paths")"
|
||||
_rs_apply "$staging_root" all "${selected_paths[@]}"
|
||||
_rs_finish_flow
|
||||
return 0
|
||||
;;
|
||||
local body
|
||||
body="\Zb$(translate "About to restore") ${RS_SEL_TOTAL} $(translate "selected path(s):")\ZB"$'\n\n'
|
||||
if (( hot_count > 0 )); then
|
||||
body+=" • $(translate "Apply") \Zb\Z4${hot_count}\Zn $(translate "now")"$'\n'
|
||||
fi
|
||||
if (( pending_count > 0 )); then
|
||||
body+=" • $(translate "Schedule") \Zb\Z4${pending_count}\Zn $(translate "for next boot (needs reboot to take effect)")"$'\n'
|
||||
fi
|
||||
if (( pending_count > 0 )); then
|
||||
body+=$'\n'"\Zb\Z4$(translate "A reboot will be required to complete the restore.")\Zn"$'\n'
|
||||
fi
|
||||
body+=$'\n'"\Zb$(translate "Continue?")\ZB"
|
||||
|
||||
4)
|
||||
_rs_select_component_paths "$staging_root" selected_paths || continue
|
||||
_rs_collect_stats_for_paths "${selected_paths[@]}"
|
||||
;;
|
||||
if ! dialog --backtitle "ProxMenux" --colors \
|
||||
--title "$(translate "Confirm custom restore")" \
|
||||
--yesno "$body" 14 82; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
5)
|
||||
if ! whiptail --title "$(translate "Confirm")" \
|
||||
--yesno "$(translate "Apply safe selected paths now and schedule remaining selected paths for next boot?")" \
|
||||
10 82; then
|
||||
continue
|
||||
fi
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "Applying safe selected paths and preparing pending restore")"
|
||||
[[ "$RS_SEL_HOT" -gt 0 ]] && _rs_apply "$staging_root" hot "${selected_paths[@]}"
|
||||
local -a pending_paths=()
|
||||
mapfile -t pending_paths < <(_rs_collect_pending_paths remaining_after_hot "${selected_paths[@]}")
|
||||
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
|
||||
msg_warn "$(translate "Reboot is required to complete the pending restore.")"
|
||||
fi
|
||||
_rs_finish_flow
|
||||
return 0
|
||||
;;
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "Applying custom restore")"
|
||||
|
||||
6)
|
||||
if ! whiptail --title "$(translate "Confirm")" \
|
||||
--yesno "$(translate "Schedule selected component paths for next boot without applying live changes now?")" \
|
||||
10 82; then
|
||||
continue
|
||||
fi
|
||||
local -a pending_paths=()
|
||||
mapfile -t pending_paths < <(_rs_collect_pending_paths all_selected "${selected_paths[@]}")
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "Preparing selected pending restore")"
|
||||
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
|
||||
msg_warn "$(translate "Reboot is required to apply the scheduled restore.")"
|
||||
fi
|
||||
_rs_finish_flow
|
||||
return 0
|
||||
;;
|
||||
if (( hot_count > 0 )); then
|
||||
_rs_apply "$staging_root" hot "${selected_paths[@]}"
|
||||
fi
|
||||
|
||||
0)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if (( pending_count > 0 )); then
|
||||
local -a pending_paths=()
|
||||
mapfile -t pending_paths < <(_rs_collect_pending_paths remaining_after_hot "${selected_paths[@]}")
|
||||
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
|
||||
msg_warn "$(translate "Reboot is required to complete the pending restore.")"
|
||||
fi
|
||||
fi
|
||||
|
||||
_rs_finish_flow
|
||||
return 0
|
||||
}
|
||||
|
||||
# Extras the Complete restore runs INLINE after applying file
|
||||
@@ -2562,4 +2590,9 @@ main_menu() {
|
||||
done
|
||||
}
|
||||
|
||||
main_menu
|
||||
# Only spawn the TUI when invoked directly. Sourcing the file
|
||||
# (e.g. from restore/monitor_apply.sh) imports all the functions
|
||||
# without triggering the main menu.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
main_menu
|
||||
fi
|
||||
|
||||
0
scripts/backup_restore/backup_scheduler.sh
Normal file → Executable file
0
scripts/backup_restore/backup_scheduler.sh
Normal file → Executable file
0
scripts/backup_restore/collectors/build_manifest.sh
Normal file → Executable file
0
scripts/backup_restore/collectors/build_manifest.sh
Normal file → Executable file
0
scripts/backup_restore/collectors/collect_guests.sh
Normal file → Executable file
0
scripts/backup_restore/collectors/collect_guests.sh
Normal file → Executable file
0
scripts/backup_restore/collectors/collect_hardware.sh
Normal file → Executable file
0
scripts/backup_restore/collectors/collect_hardware.sh
Normal file → Executable file
0
scripts/backup_restore/collectors/collect_kernel.sh
Normal file → Executable file
0
scripts/backup_restore/collectors/collect_kernel.sh
Normal file → Executable file
0
scripts/backup_restore/collectors/collect_proxmenux_state.sh
Normal file → Executable file
0
scripts/backup_restore/collectors/collect_proxmenux_state.sh
Normal file → Executable file
0
scripts/backup_restore/collectors/collect_source_host.sh
Normal file → Executable file
0
scripts/backup_restore/collectors/collect_source_host.sh
Normal file → Executable file
0
scripts/backup_restore/collectors/collect_storage.sh
Normal file → Executable file
0
scripts/backup_restore/collectors/collect_storage.sh
Normal file → Executable file
0
scripts/backup_restore/lib_host_backup_common.sh
Normal file → Executable file
0
scripts/backup_restore/lib_host_backup_common.sh
Normal file → Executable file
144
scripts/backup_restore/restore/compute_rollback_plan.sh
Executable file
144
scripts/backup_restore/restore/compute_rollback_plan.sh
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==========================================================
|
||||
# ProxMenux — compute rollback plan (read-only)
|
||||
# ==========================================================
|
||||
# Compares a staged restore vs the current host state and emits
|
||||
# a JSON document describing what a "rollback to backup state"
|
||||
# would do:
|
||||
#
|
||||
# {
|
||||
# "backup_time": "...",
|
||||
# "vms_in_backup": [100, 101],
|
||||
# "vms_in_host": [100, 101, 105],
|
||||
# "vms_to_remove": [105], // exist now, not in backup
|
||||
# "vms_to_restore": [100, 101], // present in both → reapplied
|
||||
# "lxcs_in_backup": [201],
|
||||
# "lxcs_in_host": [201, 202],
|
||||
# "lxcs_to_remove": [202],
|
||||
# "lxcs_to_restore": [201],
|
||||
# "components_to_uninstall": ["coral_driver"], // installed now, missing in backup
|
||||
# "components_to_reinstall": ["nvidia_driver"] // installed in backup
|
||||
# }
|
||||
#
|
||||
# No side effects whatsoever — pure inspection. The actual
|
||||
# rollback is applied later by apply_cluster_postboot.sh after
|
||||
# the operator confirms in the UI.
|
||||
#
|
||||
# Usage:
|
||||
# compute_rollback_plan.sh <staging-directory>
|
||||
#
|
||||
# The staging directory must already be the post-extract layout
|
||||
# (rootfs/ + metadata/) — list_paths.sh's normalization should
|
||||
# have run first.
|
||||
# ==========================================================
|
||||
set -e
|
||||
|
||||
STAGING="${1:-}"
|
||||
if [[ -z "$STAGING" || ! -d "$STAGING" ]]; then
|
||||
printf 'compute_rollback_plan: usage: %s <staging-directory>\n' "$0" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
# ── Discover guest configs (VMs + LXCs) on both sides ──
|
||||
# /etc/pve/nodes/<node>/qemu-server/<id>.conf for VMs
|
||||
# /etc/pve/nodes/<node>/lxc/<id>.conf for LXCs
|
||||
# We collect just the numeric IDs; the rollback only needs to
|
||||
# know "exists in A, exists in B" — actual config rewrite is
|
||||
# done by the standard restore path for /etc/pve.
|
||||
|
||||
_collect_ids() {
|
||||
# $1 = root directory, $2 = subdir under /etc/pve/nodes/<n>/
|
||||
local root="$1" sub="$2"
|
||||
if [[ -d "$root/etc/pve/nodes" ]]; then
|
||||
find "$root/etc/pve/nodes" -mindepth 3 -maxdepth 3 -path "*/$sub/*.conf" -print 2>/dev/null \
|
||||
| sed -E "s|.*/([0-9]+)\\.conf$|\\1|" | sort -un
|
||||
fi
|
||||
}
|
||||
|
||||
mapfile -t vms_backup < <(_collect_ids "$STAGING/rootfs" qemu-server)
|
||||
mapfile -t vms_host < <(_collect_ids "/" qemu-server)
|
||||
mapfile -t lxcs_backup < <(_collect_ids "$STAGING/rootfs" lxc)
|
||||
mapfile -t lxcs_host < <(_collect_ids "/" lxc)
|
||||
|
||||
_diff() {
|
||||
# Echoes lines from A that are NOT in B.
|
||||
local -n a_ref=$1
|
||||
local -n b_ref=$2
|
||||
local x y found
|
||||
for x in "${a_ref[@]}"; do
|
||||
found=0
|
||||
for y in "${b_ref[@]}"; do
|
||||
[[ "$x" == "$y" ]] && { found=1; break; }
|
||||
done
|
||||
(( found == 0 )) && echo "$x"
|
||||
done
|
||||
}
|
||||
|
||||
mapfile -t vms_to_remove < <(_diff vms_host vms_backup)
|
||||
mapfile -t vms_to_restore < <(_diff vms_backup vms_host || true; for x in "${vms_backup[@]}"; do for y in "${vms_host[@]}"; do [[ "$x" == "$y" ]] && echo "$x"; done; done | sort -un)
|
||||
mapfile -t vms_to_restore < <(printf '%s\n' "${vms_backup[@]}")
|
||||
mapfile -t lxcs_to_remove < <(_diff lxcs_host lxcs_backup)
|
||||
mapfile -t lxcs_to_restore < <(printf '%s\n' "${lxcs_backup[@]}")
|
||||
|
||||
# ── Components: parse JSON on both sides, list deltas ──
|
||||
backup_components_file="$STAGING/rootfs/usr/local/share/proxmenux/components_status.json"
|
||||
host_components_file="/usr/local/share/proxmenux/components_status.json"
|
||||
|
||||
_installed_keys() {
|
||||
# Echoes one component key per line for `status == "installed"`.
|
||||
local f="$1"
|
||||
[[ ! -f "$f" ]] && return 0
|
||||
command -v jq >/dev/null 2>&1 || { return 0; }
|
||||
jq -r 'to_entries[] | select(.value.status == "installed") | .key' "$f" 2>/dev/null | sort -u
|
||||
}
|
||||
|
||||
mapfile -t comps_backup < <(_installed_keys "$backup_components_file")
|
||||
mapfile -t comps_host < <(_installed_keys "$host_components_file")
|
||||
mapfile -t comps_to_uninstall < <(_diff comps_host comps_backup)
|
||||
mapfile -t comps_to_reinstall < <(printf '%s\n' "${comps_backup[@]}")
|
||||
|
||||
# ── Backup timestamp from metadata for the UI ──
|
||||
backup_time=""
|
||||
if [[ -f "$STAGING/metadata/run_info.env" ]]; then
|
||||
backup_time=$(grep -m1 '^generated_at=' "$STAGING/metadata/run_info.env" 2>/dev/null \
|
||||
| cut -d= -f2-)
|
||||
fi
|
||||
|
||||
# ── Emit JSON. Pure printf — no jq required for the writer ──
|
||||
_json_arr_int() {
|
||||
printf '['
|
||||
local first=1 v
|
||||
for v in "$@"; do
|
||||
[[ -z "$v" ]] && continue
|
||||
if (( first )); then printf '%s' "$v"; first=0
|
||||
else printf ',%s' "$v"
|
||||
fi
|
||||
done
|
||||
printf ']'
|
||||
}
|
||||
_json_arr_str() {
|
||||
printf '['
|
||||
local first=1 v esc
|
||||
for v in "$@"; do
|
||||
[[ -z "$v" ]] && continue
|
||||
esc="${v//\\/\\\\}"; esc="${esc//\"/\\\"}"
|
||||
if (( first )); then printf '"%s"' "$esc"; first=0
|
||||
else printf ',"%s"' "$esc"
|
||||
fi
|
||||
done
|
||||
printf ']'
|
||||
}
|
||||
|
||||
printf '{'
|
||||
printf '"backup_time":"%s"' "${backup_time//\"/\\\"}"
|
||||
printf ',"vms_in_backup":'; _json_arr_int "${vms_backup[@]}"
|
||||
printf ',"vms_in_host":'; _json_arr_int "${vms_host[@]}"
|
||||
printf ',"vms_to_remove":'; _json_arr_int "${vms_to_remove[@]}"
|
||||
printf ',"vms_to_restore":'; _json_arr_int "${vms_to_restore[@]}"
|
||||
printf ',"lxcs_in_backup":'; _json_arr_int "${lxcs_backup[@]}"
|
||||
printf ',"lxcs_in_host":'; _json_arr_int "${lxcs_host[@]}"
|
||||
printf ',"lxcs_to_remove":'; _json_arr_int "${lxcs_to_remove[@]}"
|
||||
printf ',"lxcs_to_restore":'; _json_arr_int "${lxcs_to_restore[@]}"
|
||||
printf ',"components_to_uninstall":'; _json_arr_str "${comps_to_uninstall[@]}"
|
||||
printf ',"components_to_reinstall":'; _json_arr_str "${comps_to_reinstall[@]}"
|
||||
printf '}\n'
|
||||
68
scripts/backup_restore/restore/list_paths.sh
Executable file
68
scripts/backup_restore/restore/list_paths.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==========================================================
|
||||
# ProxMenux — list backup paths actually present in staging
|
||||
# ==========================================================
|
||||
# Reads <staging>/metadata/selected_paths.txt (the canonical
|
||||
# record of WHICH paths went into this backup) and emits a JSON
|
||||
# array of those that still exist under <staging>/rootfs/.
|
||||
#
|
||||
# This replaces the 13-component grouping used previously: the
|
||||
# Custom restore checklist now shows one entry per real path
|
||||
# (default profile + any operator-added extras), so what you
|
||||
# backed up is exactly what you can restore.
|
||||
#
|
||||
# Usage:
|
||||
# list_paths.sh <staging-directory>
|
||||
#
|
||||
# Output: JSON array of paths with a leading slash, e.g.:
|
||||
# ["/etc/pve","/etc/network","/opt/mistuff"]
|
||||
# ==========================================================
|
||||
set -e
|
||||
|
||||
STAGING="${1:-}"
|
||||
if [[ -z "$STAGING" || ! -d "$STAGING" ]]; then
|
||||
printf 'list_paths: usage: %s <staging-directory>\n' "$0" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SELECTED="$STAGING/metadata/selected_paths.txt"
|
||||
ROOTFS="$STAGING/rootfs"
|
||||
|
||||
paths=()
|
||||
if [[ -f "$SELECTED" ]]; then
|
||||
# The file stores paths without the leading slash (etc/pve,
|
||||
# var/lib/pve-cluster, ...). Filter to only those that are
|
||||
# actually present in the staged rootfs/ — anything in
|
||||
# missing_paths.txt at backup time would otherwise show up
|
||||
# here even though there's nothing to restore.
|
||||
while IFS= read -r rel; do
|
||||
[[ -z "$rel" ]] && continue
|
||||
rel="${rel#/}"
|
||||
if [[ -e "$ROOTFS/$rel" ]]; then
|
||||
paths+=("/$rel")
|
||||
fi
|
||||
done < "$SELECTED"
|
||||
else
|
||||
# No selected_paths.txt sidecar — backup might be from a
|
||||
# pre-metadata era. Walk rootfs/ at depth 1-2 and use those
|
||||
# as the path list. Best-effort fallback.
|
||||
while IFS= read -r entry; do
|
||||
[[ -z "$entry" ]] && continue
|
||||
paths+=("/${entry#./}")
|
||||
done < <(cd "$ROOTFS" 2>/dev/null && find . -mindepth 1 -maxdepth 2 -print 2>/dev/null | sed 's|^\./||')
|
||||
fi
|
||||
|
||||
# Emit JSON array — keep it dependency-free (no jq).
|
||||
printf '['
|
||||
first=1
|
||||
for p in "${paths[@]}"; do
|
||||
esc="${p//\\/\\\\}"
|
||||
esc="${esc//\"/\\\"}"
|
||||
if (( first )); then
|
||||
printf '"%s"' "$esc"
|
||||
first=0
|
||||
else
|
||||
printf ',"%s"' "$esc"
|
||||
fi
|
||||
done
|
||||
printf ']\n'
|
||||
107
scripts/backup_restore/restore/monitor_apply.sh
Executable file
107
scripts/backup_restore/restore/monitor_apply.sh
Executable file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==========================================================
|
||||
# ProxMenux Monitor restore wrapper
|
||||
# ==========================================================
|
||||
# Bridges the Monitor's "Restore" button to the TUI restore
|
||||
# functions in backup_host.sh. The Monitor takes care of the
|
||||
# extraction (snapshot → staging directory + manifest), then
|
||||
# launches this script in a ScriptTerminalModal so the operator
|
||||
# sees the same dialog flow used by the shell TUI.
|
||||
#
|
||||
# The Monitor's script_runner only forwards env vars (no argv).
|
||||
# Inputs (env):
|
||||
# STAGING — absolute path to the prepared staging dir
|
||||
# MODE — "full" or "custom"
|
||||
# PATHS — CSV of absolute backup paths, only when MODE=custom
|
||||
# (e.g. "/etc/pve,/etc/network,/opt/mistuff").
|
||||
# Each must be present in <staging>/metadata/selected_paths.txt
|
||||
# or the wrapper drops it. Omit to let the TUI checklist
|
||||
# pop up so the operator can pick interactively.
|
||||
# ==========================================================
|
||||
# Note: NO `set -e` here. backup_host.sh + its sourced utils/lib
|
||||
# emit a few non-zero exit codes during load (locale checks,
|
||||
# optional commands missing, etc.) — under set -e those abort the
|
||||
# wrapper silently and the operator only sees "Connection closed"
|
||||
# in the Monitor terminal with no clue why. We rely on explicit
|
||||
# error handling inside _rs_run_complete_guided / _rs_run_custom_restore.
|
||||
|
||||
STAGING="${STAGING:-}"
|
||||
MODE="${MODE:-full}"
|
||||
PATHS="${PATHS:-}"
|
||||
|
||||
# Also accept --staging / --mode / --paths positional args for
|
||||
# direct CLI use outside the Monitor (handy for debugging).
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--staging) shift; STAGING="${1:-}" ;;
|
||||
--mode) shift; MODE="${1:-full}" ;;
|
||||
--paths) shift; PATHS="${1:-}" ;;
|
||||
*) ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ -z "$STAGING" || ! -d "$STAGING" ]]; then
|
||||
printf 'monitor_apply: STAGING missing or not a directory (%s)\n' "$STAGING" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
HB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# Source the library + the TUI. backup_host.sh carries a guard so
|
||||
# sourcing it does NOT spawn the main menu — only the functions
|
||||
# (_rs_check_layout, _rs_run_complete_guided, _rs_run_custom_restore,
|
||||
# _rs_apply, ...) get loaded.
|
||||
# shellcheck source=/dev/null
|
||||
. "$HB_DIR/lib_host_backup_common.sh"
|
||||
# shellcheck source=/dev/null
|
||||
. "$HB_DIR/backup_host.sh"
|
||||
|
||||
# Normalize the staging layout to the canonical rootfs/+metadata/
|
||||
# shape. The Monitor's Python extract path already does this, but
|
||||
# rerunning here keeps the wrapper safe when invoked directly.
|
||||
if ! _rs_check_layout "$STAGING"; then
|
||||
printf 'monitor_apply: archive layout not recognized in %s\n' "$STAGING" >&2
|
||||
exit 65
|
||||
fi
|
||||
|
||||
# Tell the apply helpers that we're running under the Monitor.
|
||||
# Two effects:
|
||||
# • _rs_apply skips its final `systemctl daemon-reload` so the
|
||||
# Monitor service unit isn't marked "needs restart" and the
|
||||
# WebSocket survives the apply (the restored unit file IS
|
||||
# written to disk — it'll take effect at the next reboot).
|
||||
# • _rs_run_complete_guided / _rs_run_custom_restore skip the
|
||||
# second "Reboot now?" dialog and instead emit one final
|
||||
# "Restore prepared — reboot when ready" line. The operator
|
||||
# closes the modal in the web UI and reboots from the host
|
||||
# panel; no need for two consecutive yes/no prompts.
|
||||
export HB_MONITOR_FLOW=1
|
||||
|
||||
case "$MODE" in
|
||||
full)
|
||||
# The TUI path runs `_rs_collect_plan_stats` before calling
|
||||
# `_rs_run_complete_guided`. We were skipping it here and that's
|
||||
# why the confirm dialog showed "0 rutas seguras ahora /
|
||||
# 0 rutas para el próximo arranque" when launched from the Monitor.
|
||||
_rs_collect_plan_stats "$STAGING"
|
||||
_rs_run_complete_guided "$STAGING"
|
||||
;;
|
||||
custom)
|
||||
if [[ -n "$PATHS" ]]; then
|
||||
HB_PRESELECTED_PATHS="$PATHS" _rs_run_custom_restore "$STAGING"
|
||||
else
|
||||
_rs_run_custom_restore "$STAGING"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
printf 'monitor_apply: unknown MODE %s (expected: full | custom)\n' "$MODE" >&2
|
||||
exit 66
|
||||
;;
|
||||
esac
|
||||
|
||||
# Clean exit. _rs_finish_flow under HB_MONITOR_FLOW already returns
|
||||
# without waiting for Enter, so falling off the end here closes the
|
||||
# PTY → the WS closes → the modal sees `isComplete=true` (script_runner
|
||||
# sends "[Script exited with code 0]" on normal termination) so the
|
||||
# ScriptTerminalModal won't try to auto-reconnect.
|
||||
0
scripts/backup_restore/restore/parse_manifest.sh
Normal file → Executable file
0
scripts/backup_restore/restore/parse_manifest.sh
Normal file → Executable file
0
scripts/backup_restore/restore/preflight_checks.sh
Normal file → Executable file
0
scripts/backup_restore/restore/preflight_checks.sh
Normal file → Executable file
0
scripts/backup_restore/restore/reinstall_drivers.sh
Normal file → Executable file
0
scripts/backup_restore/restore/reinstall_drivers.sh
Normal file → Executable file
0
scripts/backup_restore/restore/remap_network.sh
Normal file → Executable file
0
scripts/backup_restore/restore/remap_network.sh
Normal file → Executable file
0
scripts/backup_restore/restore/restore_modes.sh
Normal file → Executable file
0
scripts/backup_restore/restore/restore_modes.sh
Normal file → Executable file
0
scripts/backup_restore/restore/run_restore.sh
Normal file → Executable file
0
scripts/backup_restore/restore/run_restore.sh
Normal file → Executable file
0
scripts/backup_restore/restore/validate_storage.sh
Normal file → Executable file
0
scripts/backup_restore/restore/validate_storage.sh
Normal file → Executable file
0
scripts/backup_restore/run_scheduled_backup.sh
Normal file → Executable file
0
scripts/backup_restore/run_scheduled_backup.sh
Normal file → Executable file
@@ -1629,8 +1629,28 @@ auto_reinstall_from_state() {
|
||||
echo "No NVIDIA GPU detected on this host — skipping reinstall" | tee -a "$LOG_FILE"
|
||||
return 0
|
||||
fi
|
||||
# Skip if the GPU is bound to vfio-pci (reserved for VM passthrough);
|
||||
# installing the host driver would conflict with the passthrough config.
|
||||
# Skip when the host is configured for VFIO passthrough. Two
|
||||
# signals — config wins over runtime:
|
||||
#
|
||||
# 1. modprobe.d declares the nvidia* modules blacklisted (the
|
||||
# file ProxMenux drops when switching the GPU to VM mode is
|
||||
# `proxmenux-nvidia-vfio-blacklist.conf`, but any blacklist
|
||||
# file counts).
|
||||
# 2. The PCI device is bound to vfio-pci.
|
||||
#
|
||||
# The config check has to come FIRST because right after a host
|
||||
# restore + reboot, the binding may not yet be effective (the
|
||||
# GPU temporarily shows "no driver" while udev/initramfs settle).
|
||||
# Reinstalling NVIDIA in that window forces the module onto a
|
||||
# PCI device vfio-pci has already reserved → "NVRM: Try unloading
|
||||
# the conflicting kernel module" and exit 1.
|
||||
if [[ -f /etc/modprobe.d/proxmenux-nvidia-vfio-blacklist.conf ]] \
|
||||
|| grep -qrhE '^[[:space:]]*blacklist[[:space:]]+nvidia([[:space:]_]|$)' \
|
||||
/etc/modprobe.d/ 2>/dev/null; then
|
||||
echo "NVIDIA blacklisted (host is VFIO-configured) — skipping host driver reinstall" \
|
||||
| tee -a "$LOG_FILE"
|
||||
return 0
|
||||
fi
|
||||
local _vfio_dev
|
||||
for _vfio_dev in /sys/bus/pci/devices/*; do
|
||||
[[ "$(cat "$_vfio_dev/vendor" 2>/dev/null)" == "0x10de" ]] || continue
|
||||
|
||||
Reference in New Issue
Block a user