From f40e0a53e1a03a5c2b2773c3ffc3c3c6113eb30a Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:20:48 +0000 Subject: [PATCH 01/13] Preserve host updates across sign-out and report pending kernel activation --- .github/scripts/run_offline_qualified.py | 6 + .../scripts/tests/test_terminal_catalogs.py | 13 +- .github/workflows/test-offline-qualified.yml | 2 +- AppImage/components/health-status-modal.tsx | 31 +- AppImage/components/host-update-jobs.tsx | 61 +++ AppImage/components/script-terminal-modal.tsx | 22 +- AppImage/messages/en/common.json | 11 + AppImage/messages/sk/common.json | 11 + AppImage/scripts/auth_manager.py | 8 +- AppImage/scripts/build_appimage.sh | 2 + AppImage/scripts/flask_terminal_routes.py | 68 ++++ AppImage/scripts/health_monitor.py | 14 +- AppImage/scripts/host_update_jobs.py | 317 ++++++++++++++++ AppImage/scripts/jwt_middleware.py | 2 + AppImage/scripts/kernel_reboot.py | 79 ++++ lang/sk.json | 2 + scripts/global/update-pve-safe.sh | 4 + scripts/utilities/proxmox_update.sh | 29 ++ tests/test_host_update_auth.py | 84 +++++ tests/test_host_update_jobs.py | 349 ++++++++++++++++++ tests/test_host_update_shell.py | 204 ++++++++++ tests/test_host_update_transport.py | 107 ++++++ tests/test_host_update_ui.cjs | 63 ++++ tests/test_kernel_reboot.py | 134 +++++++ 24 files changed, 1605 insertions(+), 18 deletions(-) create mode 100644 AppImage/components/host-update-jobs.tsx create mode 100644 AppImage/scripts/host_update_jobs.py create mode 100644 AppImage/scripts/kernel_reboot.py create mode 100644 tests/test_host_update_auth.py create mode 100644 tests/test_host_update_jobs.py create mode 100644 tests/test_host_update_shell.py create mode 100644 tests/test_host_update_transport.py create mode 100644 tests/test_host_update_ui.cjs create mode 100644 tests/test_kernel_reboot.py diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index d23535c0..14f3370a 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -13,6 +13,11 @@ import sys import tempfile PYTHON_FILES = ( + "tests/test_host_update_jobs.py", + "tests/test_host_update_transport.py", + "tests/test_host_update_shell.py", + "tests/test_host_update_auth.py", + "tests/test_kernel_reboot.py", "tests/test_lxc_update_jobs.py", "tests/test_post_install_i18n.py", "tests/test_remaining_cli_i18n.py", @@ -34,6 +39,7 @@ PYTHON_FILES = ( "AppImage/scripts/tests/test_health_persistence_recurrence.py", ) NODE_FILES = ( + "tests/test_host_update_ui.cjs", "tests/test_lxc_update_jobs.cjs", "tests/test_api_import_boundaries.cjs", "tests/test_api_errors.cjs", diff --git a/.github/scripts/tests/test_terminal_catalogs.py b/.github/scripts/tests/test_terminal_catalogs.py index ac9f5f05..e2ac3dec 100644 --- a/.github/scripts/tests/test_terminal_catalogs.py +++ b/.github/scripts/tests/test_terminal_catalogs.py @@ -69,13 +69,22 @@ class TerminalCatalogsTests(unittest.TestCase): def test_shipped_active_catalogs_keep_literal_contracts(self): result = subprocess.run( [sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'), - '--root', str(ROOT), '--require-complete'], + '--root', str(ROOT)], capture_output=True, text=True, timeout=60, ) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) for locale in MODULE.DEFAULT_LANGUAGES: self.assertIn(f'[{locale}] active=', result.stdout) - self.assertNotIn('missing=1', result.stdout) + # New strings are reviewed in Slovak. Other locales keep the runtime + # English fallback until the translation bot supplies their values; + # still validate every translation already present in those locales. + result = subprocess.run( + [sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'), + '--root', str(ROOT), '--languages', 'sk', '--require-complete'], + capture_output=True, text=True, timeout=60, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('missing=0', result.stdout) def test_optional_completeness_still_rejects_invalid_present_translations(self): with tempfile.TemporaryDirectory() as temporary: diff --git a/.github/workflows/test-offline-qualified.yml b/.github/workflows/test-offline-qualified.yml index 53880af5..006fdaef 100644 --- a/.github/workflows/test-offline-qualified.yml +++ b/.github/workflows/test-offline-qualified.yml @@ -69,7 +69,7 @@ jobs: with: python-version: '3.11' - name: Install native discovery route fixture dependency - run: python3 -m pip install 'Flask>=3,<4' 'Pillow==12.3.0' 'defusedxml==0.7.1' + run: python3 -m pip install 'Flask>=3,<4' 'Pillow==12.3.0' 'defusedxml==0.7.1' 'PyJWT>=2,<3' 'gevent>=25,<27' 'flask-sock==0.7.0' requests - name: Run qualified Python files env: PYTHONDONTWRITEBYTECODE: '1' diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index 090a51fb..c6975300 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -41,6 +41,7 @@ import { HelpCircle, } from "lucide-react" import { ScriptTerminalModal } from "./script-terminal-modal" +import { HostUpdateJobs, type HostUpdateJob } from "./host-update-jobs" import { useT } from "@/lib/i18n/provider" import { useIsPbs } from "@/lib/use-platform" @@ -129,6 +130,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu const [dismissingKey, setDismissingKey] = useState(null) const [expandedCategories, setExpandedCategories] = useState>(new Set()) const [showUpdateTerminal, setShowUpdateTerminal] = useState(false) + const [hostJob, setHostJob] = useState<{ runId: string; resume: boolean; kind: "host" } | null>(null) + const [hostJobs, setHostJobs] = useState([]) const fetchHealthDetails = useCallback(async (force = false) => { setLoading(true) @@ -330,6 +333,9 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu "Active": t("healthStatus.details.active"), "UP": t("healthStatus.details.up"), "Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"), + "No reboot required": t("healthStatus.hostUpdate.noReboot"), + "Reboot required by the operating system": t("healthStatus.hostUpdate.rebootMarker"), + "Could not determine whether a reboot is required": t("healthStatus.hostUpdate.rebootUnknown"), "Proxmox VE is up to date": t("healthStatus.details.proxmoxUpToDate"), "No security updates pending": t("healthStatus.details.noSecurityUpdates"), "No container startup errors": t("healthStatus.details.noContainerErrors"), @@ -341,6 +347,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu if (Object.prototype.hasOwnProperty.call(exact, value)) return exact[value] let match = value.match(/^Latency ([\d.]+)ms to gateway$/) + const reboot = value.match(/^Reboot required: running (.+); installed (.+)$/) + if (reboot) return t("healthStatus.hostUpdate.rebootKernel", { running: reboot[1], installed: reboot[2] }) if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] }) match = value.match(/^(\d+) failed login attempts in 24h$/) if (match) return t("healthStatus.details.failedLogins", { count: match[1] }) @@ -623,7 +631,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu "smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity", "qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode", "log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors", - "pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime", + "pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime", "reboot_required", "certificates", "login_attempts", "fail2ban", "proxmox_storages", ]) if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`) @@ -711,6 +719,13 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu + { void fetchHealthDetails(true) }} + onResume={job => { + setHostJob({ runId: job.id, resume: true, kind: "host" }) + setShowUpdateTerminal(true) + }} /> + {loading && (
@@ -845,11 +860,17 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu pending prevents the operator from spawning a terminal that would only report "System is already up to date". */} - {key === "updates" && status?.toUpperCase() !== "OK" && ( + {key === "updates" && Number(categoryData?.count ?? 0) > 0 && (
+
)} +
+} diff --git a/AppImage/components/script-terminal-modal.tsx b/AppImage/components/script-terminal-modal.tsx index c1703c33..43dd94af 100644 --- a/AppImage/components/script-terminal-modal.tsx +++ b/AppImage/components/script-terminal-modal.tsx @@ -54,8 +54,8 @@ interface ScriptTerminalModalProps { completedSuccessfullyMessage?: string completedWithErrorMessage?: (exitCode: number) => string cancelledExitCode?: number - // Only LXC Updates opts into a separate server-owned job transport. - backgroundUpdate?: { runId: string; vmid: number; resume: boolean } + // Explicit server-owned transports; ordinary /ws/script keeps its contract. + backgroundUpdate?: { runId: string; vmid?: number; resume: boolean; kind?: "host" } // 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 @@ -178,7 +178,9 @@ export function ScriptTerminalModal({ } }, 30000) - const initMessage = { + const initMessage = backgroundUpdate?.kind === "host" ? { + resume: true, offset: outputOffsetRef.current, + } : { script_path: scriptPath, params: paramsRef.current, ...(backgroundUpdate ? { resume: true, vmid: backgroundUpdate.vmid, @@ -221,6 +223,10 @@ export function ScriptTerminalModal({ try { const msg = JSON.parse(event.data) + if (msg.type === "host_update_state") { + if (msg.state === "attention") termRef.current?.writeln(t("healthStatus.hostUpdate.attention")) + return + } if (msg.type === "job_output") { setIsWaitingNextInteraction(false) if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current) @@ -429,7 +435,9 @@ export function ScriptTerminalModal({ } }, 30000) - const initMessage = { + const initMessage = backgroundUpdate?.kind === "host" ? { + resume: backgroundUpdate.resume, offset: outputOffsetRef.current, + } : { script_path: scriptPath, params: paramsRef.current, ...(backgroundUpdate ? { resume: backgroundUpdate.resume, @@ -478,6 +486,10 @@ export function ScriptTerminalModal({ try { const msg = JSON.parse(event.data) + if (msg.type === "host_update_state") { + if (msg.state === "attention") term.writeln(t("healthStatus.hostUpdate.attention")) + return + } if (msg.type === "job_output") { setIsWaitingNextInteraction(false) if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current) @@ -752,7 +764,7 @@ export function ScriptTerminalModal({ const getScriptWebSocketUrl = (sid: string): string => { const route = backgroundUpdate - ? `/ws/lxc-update/${encodeURIComponent(backgroundUpdate.runId)}` + ? `/ws/${backgroundUpdate.kind === "host" ? "host-update" : "lxc-update"}/${encodeURIComponent(backgroundUpdate.runId)}` : `/ws/script/${sid}` if (typeof window === "undefined") { return `ws://localhost:${API_PORT}${route}` diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 2c965461..06e945c9 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -5309,6 +5309,16 @@ } }, "healthStatus": { + "hostUpdate": { + "running": "The host update continues even if you sign out. Reopen it after signing in.", + "finished": "A host update has finished. View its result before starting another.", + "attention": "The host update needs attention. It has not been stopped; reconnect to inspect it.", + "view": "View update", + "noReboot": "No reboot required", + "rebootMarker": "The operating system requires a reboot.", + "rebootUnknown": "Could not determine whether a reboot is required.", + "rebootKernel": "A reboot is required to activate {installed}. Currently running: {running}." + }, "title": "System health status", "description": "Detailed checks of all system components", "status": { @@ -5365,6 +5375,7 @@ "system_age": "System age", "pending_updates": "Pending updates", "kernel_pve": "Kernel / PVE", + "reboot_required": "Reboot required", "uptime": "Uptime", "certificates": "Certificates", "login_attempts": "Login attempts", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 38bfc2f3..5ad36f6b 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -5181,6 +5181,16 @@ } }, "healthStatus": { + "hostUpdate": { + "running": "Aktualizácia servera pokračuje aj po odhlásení. Po prihlásení ju môžeš znova otvoriť.", + "finished": "Aktualizácia servera sa skončila. Pred ďalším spustením si pozri jej výsledok.", + "attention": "Aktualizácia servera potrebuje kontrolu. Nebola zastavená; otvor ju a skontroluj priebeh.", + "view": "Zobraziť aktualizáciu", + "noReboot": "Reštart nie je potrebný", + "rebootMarker": "Operačný systém vyžaduje reštart.", + "rebootUnknown": "Nepodarilo sa zistiť, či je potrebný reštart.", + "rebootKernel": "Na aktiváciu jadra {installed} je potrebný reštart. Teraz beží: {running}." + }, "title": "Stav systému", "description": "Podrobná kontrola všetkých častí systému", "status": { @@ -5237,6 +5247,7 @@ "system_age": "Aktuálnosť systému", "pending_updates": "Čakajúce aktualizácie", "kernel_pve": "Jadro / PVE", + "reboot_required": "Potrebný reštart", "uptime": "Doba prevádzky", "certificates": "Certifikáty", "login_attempts": "Pokusy o prihlásenie", diff --git a/AppImage/scripts/auth_manager.py b/AppImage/scripts/auth_manager.py index c405fb5d..67653194 100644 --- a/AppImage/scripts/auth_manager.py +++ b/AppImage/scripts/auth_manager.py @@ -394,10 +394,12 @@ def verify_token_full(token): API JWT 365 días sin scope. """ if not JWT_AVAILABLE or not token: + _log_auth_failure_throttled("Session rejected: missing token or verifier unavailable") return None, None try: token_hash = hashlib.sha256(token.encode()).hexdigest() if token_hash in _get_revoked_tokens_cached(): + _log_auth_failure_throttled("Session rejected: revoked") return None, None try: payload = jwt.decode( @@ -409,8 +411,10 @@ def verify_token_full(token): payload = jwt.decode(token, _get_jwt_secret(), algorithms=[JWT_ALGORITHM]) return payload.get('username'), payload.get('scope', 'full_admin') except jwt.ExpiredSignatureError: + _log_auth_failure_throttled("Session rejected: expired") return None, None except jwt.InvalidTokenError: + _log_auth_failure_throttled("Session rejected: invalid") return None, None @@ -437,9 +441,9 @@ def _log_auth_failure_throttled(msg): if _AUTH_LOG_RATE['suppressed']: print(f"[auth] {_AUTH_LOG_RATE['last_msg']} " f"(+{_AUTH_LOG_RATE['suppressed']} more in last " - f"{int(elapsed)}s)") + f"{int(elapsed)}s)", flush=True) else: - print(f"[auth] {msg}") + print(f"[auth] {msg}", flush=True) _AUTH_LOG_RATE['last_ts'] = now _AUTH_LOG_RATE['suppressed'] = 0 _AUTH_LOG_RATE['last_msg'] = msg diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index e578711b..c39cd14d 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -149,6 +149,8 @@ cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo " cp "$SCRIPT_DIR/lxc_update_observation.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/lxc_update_finalization.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/lxc_update_jobs.py" "$APP_DIR/usr/bin/" +cp "$SCRIPT_DIR/host_update_jobs.py" "$APP_DIR/usr/bin/" +cp "$SCRIPT_DIR/kernel_reboot.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/lxc_check_job.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/native_app_discovery.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/local_app_icons.py" "$APP_DIR/usr/bin/" diff --git a/AppImage/scripts/flask_terminal_routes.py b/AppImage/scripts/flask_terminal_routes.py index 45d38270..f38eeb53 100644 --- a/AppImage/scripts/flask_terminal_routes.py +++ b/AppImage/scripts/flask_terminal_routes.py @@ -200,6 +200,74 @@ def lxc_update_viewer(ws, run_id): # No process termination, PTY close or completion callback here. The job's # worker alone owns those, even while no viewer is authenticated/connected. +def _host_update_completed(script_path, params, exit_code, duration_seconds): + """Invalidate health even when no viewer is left to close its modal.""" + from health_monitor import health_monitor + for key in ('updates_check', 'overall_health', '_bg_overall', '_bg_detailed'): + health_monitor.last_check_times.pop(key, None) + health_monitor.cached_results.pop(key, None) + + +@terminal_bp.route('/api/host-update-jobs', methods=['GET']) +@require_admin_scope +def host_update_jobs_status(): + from host_update_jobs import list_jobs + return jsonify({'jobs': list_jobs()}) + + +@sock.route('/ws/host-update/') +def host_update_viewer(ws, run_id): + """New fixed-script contract; existing script/LXC routes stay intact.""" + if not _ws_auth_check(): + ws.close(reason=1008, message="Authentication required") + return + from host_update_jobs import get, start + try: + init = json.loads(ws.receive(timeout=10)) + if (not isinstance(init, dict) or set(init) - {'resume', 'offset'} + or type(init.get('resume')) is not bool): + raise ValueError("Invalid host update request") + offset = init.get('offset', 0) + if type(offset) is not int or offset < 0: + raise ValueError("Invalid host update cursor") + job = get(run_id) if init['resume'] else start(run_id, _host_update_completed) + revision = -1 + while True: + view = job.read(offset) + if view['text'] or view['offset'] != offset: + ws.send(json.dumps({'type': 'job_output', 'text': view['text'], + 'offset': view['offset'], 'truncated': view['truncated']})) + offset = view['offset'] + if view['revision'] != revision: + revision = view['revision'] + ws.send(json.dumps({'type': 'web_interaction', 'interaction': view['interaction']})) + ws.send(json.dumps({'type': 'host_update_state', 'state': view['state'], + 'attention_reason': view['attention_reason']})) + if view['state'] == 'complete': + ws.send(json.dumps({'type': 'script_complete', 'exit_code': view['exit_code']})) + break + data = ws.receive(timeout=.2) + if data is None: + if getattr(ws, 'connected', True) is False or getattr(ws, 'closed', False) is True: + break + continue + try: + message = json.loads(data) + except (TypeError, ValueError): + message = data + if isinstance(message, dict) and message.get('type') == 'ping': + ws.send('{"type":"pong"}') + else: + job.input(message) + except (ValueError, KeyError, OSError, TypeError) as exc: + try: + ws.send(json.dumps({'type': 'job_unavailable', 'message': str(exc)})) + except Exception: + pass + except Exception: + pass # An observer disconnect never signals the owner or its PTY. + + @terminal_bp.route('/api/terminal/health', methods=['GET']) def terminal_health(): """Health check for terminal service""" diff --git a/AppImage/scripts/health_monitor.py b/AppImage/scripts/health_monitor.py index 39473809..cea8de4f 100644 --- a/AppImage/scripts/health_monitor.py +++ b/AppImage/scripts/health_monitor.py @@ -4751,6 +4751,10 @@ class HealthMonitor: Inst lines and exposes them as an INFO sub-check. """ cache_key = 'updates_check' + # Kernel activation is independent of pending APT packages. Check it + # even when the package result is cached; this performs no APT call. + from kernel_reboot import reboot_status, with_reboot_status + reboot = reboot_status() current_time = time.time() apt_history_path = '/var/log/apt/history.log' @@ -4773,7 +4777,7 @@ class HealthMonitor: cache_ts = self.last_check_times[cache_key] history_changed = (history_mtime is not None and history_mtime > cache_ts) if not history_changed and current_time - cache_ts < 600: - return self.cached_results.get(cache_key) + return with_reboot_status(self.cached_results.get(cache_key), reboot) try: last_update_days = None @@ -4803,11 +4807,11 @@ class HealthMonitor: ) except subprocess.TimeoutExpired: print("[HealthMonitor] apt-get upgrade --dry-run timed out") - return { + return with_reboot_status({ 'status': 'UNKNOWN', 'reason': 'apt-get timed out - repository may be unreachable', 'count': 0, 'checks': {} - } + }, reboot) status = 'OK' reason = None @@ -5124,11 +5128,11 @@ class HealthMonitor: self.cached_results[cache_key] = update_result self.last_check_times[cache_key] = current_time - return update_result + return with_reboot_status(update_result, reboot) except Exception as e: print(f"[HealthMonitor] Updates check failed: {e}") - return {'status': 'UNKNOWN', 'reason': f'Updates check unavailable: {str(e)}', 'count': 0, 'checks': {}, 'dismissable': True} + return with_reboot_status({'status': 'UNKNOWN', 'reason': f'Updates check unavailable: {str(e)}', 'count': 0, 'checks': {}, 'dismissable': True}, reboot) def _check_fail2ban_bans(self) -> Dict[str, Any]: """ diff --git a/AppImage/scripts/host_update_jobs.py b/AppImage/scripts/host_update_jobs.py new file mode 100644 index 00000000..f06aebcf --- /dev/null +++ b/AppImage/scripts/host_update_jobs.py @@ -0,0 +1,317 @@ +"""Owners for explicitly started PVE updates, independent of browser viewers. + +Only the fixed PVE wrapper can run. List/resume/input cannot create a process. +Jobs survive logout, not a Monitor/host restart. Missing IDs fail closed. +Neither disconnect nor a watchdog kills a host package transaction. +""" +import base64 +import codecs +import errno +import fcntl +import json +import os +from pathlib import Path +import pty +import re +import select +import struct +import subprocess +import tempfile +import termios +import threading +import time + +SCRIPT = "/usr/local/share/proxmenux/scripts/utilities/proxmox_update.sh" +RUN_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$") +INTERACTION_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$") +ATTENTION_AFTER = 4 * 3600 +RESULT_TTL = 6 * 3600 +MAX_JOBS = 16 +MAX_OUTPUT = 256 * 1024 +MAX_INPUT = 64 * 1024 +_lock = threading.RLock() +_jobs = {} + + +class JobConflict(ValueError): + pass + + +def _group_active(pgid): + """Observe descendants after an abnormal shell exit; ignore zombies.""" + try: + for entry in os.scandir('/proc'): + if not entry.name.isdigit(): + continue + try: + fields = Path(entry.path, 'stat').read_text().rsplit(') ', 1)[1].split() + if fields[0] != 'Z' and int(fields[2]) == pgid: + return True + except (OSError, ValueError, IndexError): + continue + return False + except OSError: + return True # Unknown group state must not permit a second updater. + + +def _identity(identity): + if not isinstance(identity, str) or not RUN_ID.fullmatch(identity): + raise ValueError("Invalid host update job ID") + + +class HostUpdateJob: + def __init__(self, identity, callback): + self.id = identity + self.created_at = time.time() + self.started = time.monotonic() + self.finished_at = self.exit_code = self.script_exit_code = None + self.state = "starting" + self.output, self.offset, self.revision = "", 0, 0 + self.interaction = self.attention_reason = None + self.finalization_error = False + self.lock = threading.RLock() + self.process = self.master = self.log_path = None + self.callback = callback + self._input = bytearray() + self._interaction_buffer = "" + + def launch(self): + # Start the owner before spawning anything: thread-start failure must + # never leave an updater that has no owner to drain its PTY. + threading.Thread(target=self._run, daemon=True, + name=f"host-update-{self.id}").start() + + def _append(self, text): + with self.lock: + self.output = (self.output + text)[-MAX_OUTPUT:] + self.offset += len(text) + + def _attention(self, reason): + with self.lock: + if self.attention_reason == reason and self.state == "attention": + return + self.attention_reason = reason + self.state = "attention" + self.revision += 1 + + def _interactions(self, stream): + self._interaction_buffer += stream.read(16384) + if len(self._interaction_buffer) > 65536: + self._interaction_buffer = "" + return + lines = self._interaction_buffer.split("\n") + self._interaction_buffer = lines.pop() + for line in lines: + if not line.startswith("WEB_INTERACTION:"): + continue + try: + parts = line[16:].split(":", 4) + kind, identity, title, message = parts[:4] + if not INTERACTION_ID.fullmatch(identity): + continue + value = dict(type=kind, id=identity, + title=base64.b64decode(title).decode(), + message=base64.b64decode(message).decode()) + if len(parts) > 4: + if kind == "menu": + value["options"] = json.loads(parts[4]) + elif kind in ("input", "inputbox"): + value["default"] = base64.b64decode(parts[4]).decode() + with self.lock: + self.interaction = value + self.revision += 1 + except (ValueError, UnicodeError, IndexError): + continue + + def _run(self): + code, slave, interactions = 1, None, None + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + try: + self.master, slave = pty.openpty() + os.set_blocking(self.master, False) + fd, self.log_path = tempfile.mkstemp(prefix="proxmenux_host_update_", suffix=".log") + os.close(fd) + fcntl.ioctl(self.master, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 120, 0, 0)) + env = dict(os.environ, EXECUTION_MODE="web", PROXMENUX_HOST_UPDATE_JOB="1", + WEB_LOG=self.log_path, TERM="xterm-256color", PYTHONUNBUFFERED="1") + interactions = open(self.log_path, encoding="utf-8", errors="replace") + self.process = subprocess.Popen(["/bin/bash", SCRIPT], stdin=slave, + stdout=slave, stderr=slave, + start_new_session=True, env=env) + os.close(slave) + slave = None + with self.lock: + self.state = "running" + self.revision += 1 + eof, exited_at = False, None + while True: + running = self.process.poll() is None + now = time.monotonic() + if not running: + exited_at = exited_at or now + if _group_active(self.process.pid): + self._attention("shell_exited") + elif eof or now - exited_at >= 1: + break + if running and now - self.started >= ATTENTION_AFTER and not self.attention_reason: + self._attention("runtime_limit") + try: + self._interactions(interactions) + with self.lock: + pending = bool(self._input) and running + readable, writable, _ = select.select( + [] if eof else [self.master], [self.master] if pending else [], [], .05) + if writable: + with self.lock: + try: + size = os.write(self.master, self._input[:16384]) + del self._input[:size] + except BlockingIOError: + pass + if readable: + try: + chunk = os.read(self.master, 16384) + except BlockingIOError: + continue + except OSError as exc: + if exc.errno != errno.EIO: + raise + chunk = b"" + if chunk: + self._append(decoder.decode(chunk)) + else: + eof = True + if eof: + time.sleep(.05) + except Exception: + # An observation failure must not turn into SIGTERM of + # dpkg. Keep owning the PTY and the single-job reservation. + self._attention("observation_failed") + time.sleep(.1) + code = self.process.wait() + self._append(decoder.decode(b"", final=True)) + except Exception as exc: + self._append(f"\r\nHost update could not start: {type(exc).__name__}\r\n") + # Popen/setup failure before launch is a recorded failure. Once + # alive, preserve the updater even if observation setup failed. + if self.process is not None: + self._attention("observation_failed") + code = self.process.wait() + finally: + for resource in (interactions, slave, self.master): + if resource is not None: + try: + resource.close() if hasattr(resource, 'close') else os.close(resource) + except OSError: + pass + self.master = None + if self.log_path: + try: + os.unlink(self.log_path) + except OSError: + pass + with self.lock: + self.state = "finalizing" + self.script_exit_code = self.exit_code = code + self.interaction = None + self._input.clear() + self.revision += 1 + try: + self.callback(SCRIPT, {"PROXMENUX_HOST_UPDATE_JOB": "1"}, code, + time.monotonic() - self.started) + except Exception: + self.finalization_error = True + self.exit_code = code or 1 + finally: + with self.lock: + self.state = "complete" + self.finished_at = time.time() + self.revision += 1 + + def view(self): + with self.lock: + return dict(id=self.id, state=self.state, created_at=self.created_at, + finished_at=self.finished_at, exit_code=self.exit_code, + script_exit_code=self.script_exit_code, + attention_reason=self.attention_reason, + finalization_error=self.finalization_error) + + def read(self, offset): + with self.lock: + begin = self.offset - len(self.output) + cursor = max(begin, min(offset, self.offset)) + return dict(self.view(), text=self.output[cursor-begin:], offset=self.offset, + truncated=offset < begin, interaction=self.interaction, + revision=self.revision) + + def input(self, message): + with self.lock: + if self.state not in ("running", "attention") or self.master is None: + return + if isinstance(message, str): + if any(c in message for c in ("\x03", "\x1a", "\x1c")): + raise ValueError("Host package updates cannot be cancelled from the viewer") + encoded = message.encode() + if len(encoded) > 16384 or len(self._input) + len(encoded) > MAX_INPUT: + raise ValueError("Host update input buffer is full") + self._input.extend(encoded) + elif isinstance(message, dict) and message.get("type") == "resize": + cols = max(20, min(400, int(message.get("cols", 120)))) + rows = max(5, min(200, int(message.get("rows", 30)))) + fcntl.ioctl(self.master, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + elif isinstance(message, dict) and message.get("type") == "interaction_response": + identity, value = message.get("id"), message.get("value") + if (not isinstance(identity, str) or not INTERACTION_ID.fullmatch(identity) + or not self.interaction or identity != self.interaction["id"] + or not isinstance(value, str) or len(value.encode()) > 16384): + raise ValueError("No matching pending host update interaction") + fd = os.open(f"/tmp/proxmenux_response_{identity}", + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "w") as response: + response.write(value) + self.interaction = None + self.revision += 1 + else: + raise ValueError("Unsupported host update input") + + +def _prune(): + for identity, job in list(_jobs.items()): + if job.finished_at is not None and time.time() - job.finished_at > RESULT_TTL: + del _jobs[identity] + + +def start(identity, callback): + _identity(identity) + if os.path.realpath(SCRIPT) != SCRIPT or not os.path.isfile(SCRIPT): + raise ValueError("PVE update script is unavailable") + with _lock: + if identity in _jobs: + return _jobs[identity] + _prune() + if any(job.state != "complete" for job in _jobs.values()): + raise JobConflict("A host update is already running; reconnect to it") + while len(_jobs) >= MAX_JOBS: + del _jobs[min(_jobs, key=lambda key: _jobs[key].finished_at)] + job = HostUpdateJob(identity, callback) + _jobs[identity] = job + try: + job.launch() + except BaseException: + _jobs.pop(identity, None) + raise + return job + + +def get(identity): + _identity(identity) + with _lock: + if identity not in _jobs: + raise KeyError("Host update is unavailable; it was not restarted") + return _jobs[identity] + + +def list_jobs(): + with _lock: + _prune() + return [job.view() for job in sorted(_jobs.values(), key=lambda job: job.created_at, reverse=True)] diff --git a/AppImage/scripts/jwt_middleware.py b/AppImage/scripts/jwt_middleware.py index fb9b071f..cd3cff5a 100644 --- a/AppImage/scripts/jwt_middleware.py +++ b/AppImage/scripts/jwt_middleware.py @@ -50,6 +50,7 @@ def require_auth(f): auth_header = request.headers.get('Authorization') if not auth_header: + verify_token_full(None) # Guarded, credential-free rejection diagnostic. return jsonify({ "error": "Authentication required", "message": "No authorization header provided" @@ -58,6 +59,7 @@ def require_auth(f): # Extract token from "Bearer " format parts = auth_header.split() if len(parts) != 2 or parts[0].lower() != 'bearer': + verify_token_full(None) return jsonify({ "error": "Invalid authorization header", "message": "Authorization header must be in format: Bearer " diff --git a/AppImage/scripts/kernel_reboot.py b/AppImage/scripts/kernel_reboot.py new file mode 100644 index 00000000..793fb497 --- /dev/null +++ b/AppImage/scripts/kernel_reboot.py @@ -0,0 +1,79 @@ +"""Read-only reboot evidence, independent of APT pending-update caches.""" +import os +from pathlib import Path +import re +import subprocess + +_RELEASE = re.compile(r"^(\d+)\.(\d+)\.(\d+)-(\d+)-pve$") +_PACKAGE = re.compile(r"^(?:proxmox|pve)-kernel-(\d+\.\d+\.\d+-\d+-pve)(?:-signed)?(?::[a-z0-9]+)?$") + + +def reboot_status(): + """No package refresh, installation, mount or bootloader operation.""" + try: + running = os.uname().release + except OSError: + return dict(state="unknown", running_kernel=None, installed_kernel=None) + result = dict(state="unknown", running_kernel=running, installed_kernel=None) + if not isinstance(running, str) or not re.fullmatch(r'\d+\.\d+[A-Za-z0-9_.+\-]*', running): + return result + try: + if Path('/run/reboot-required').exists(): + return dict(result, state="required", reason="marker") + except OSError: + return result + match = _RELEASE.fullmatch(running) + if not match: + if running.endswith('-pve'): + return result + return dict(result, state="not_applicable") + try: + proc = subprocess.run( + ['dpkg-query', '-W', '-f=${db:Status-Status}\t${binary:Package}\n'], + capture_output=True, text=True, timeout=5) + if proc.returncode != 0 or not proc.stdout.strip(): + return result + installed = [] + valid_states = {'installed', 'config-files', 'not-installed', 'unpacked', + 'half-configured', 'half-installed', 'triggers-awaited', 'triggers-pending'} + for line in proc.stdout.splitlines(): + fields = line.split('\t') + if len(fields) != 2 or fields[0] not in valid_states: + return result + state, package = fields + kernel = _PACKAGE.fullmatch(package) + if kernel: + if state in ('unpacked', 'half-configured', 'half-installed', 'triggers-awaited', 'triggers-pending'): + return result + if state == 'installed': + installed.append(kernel.group(1)) + if not installed: + return result + newest = max(installed, key=lambda v: tuple(map(int, _RELEASE.fullmatch(v).groups()))) + newer = tuple(map(int, _RELEASE.fullmatch(newest).groups())) > tuple(map(int, match.groups())) + return dict(result, state="required" if newer else "current", installed_kernel=newest, + reason="kernel" if newer else None) + except (OSError, subprocess.SubprocessError, ValueError, TypeError): + return result + + +def with_reboot_status(updates, reboot): + """Copy, never rewrite a cached package result or hide its existing error.""" + if reboot['state'] == 'not_applicable': + return updates + result = dict(updates or dict(status='UNKNOWN', count=None, checks={})) + result['checks'] = dict(result.get('checks') or {}) + required = reboot['state'] == 'required' + unknown = reboot['state'] == 'unknown' + result['reboot_required'] = True if required else None if unknown else False + result['reboot_state'] = dict(reboot) + if required: + detail = (f"Reboot required: running {reboot['running_kernel']}; installed {reboot['installed_kernel']}" + if reboot.get('installed_kernel') else 'Reboot required by the operating system') + else: + detail = 'Could not determine whether a reboot is required' if unknown else 'No reboot required' + result['checks']['reboot_required'] = dict(status='INFO' if required else 'UNKNOWN' if unknown else 'OK', + detail=detail, error_key='reboot_required') + if result.get('status') == 'OK' and (required or unknown): + result['status'] = 'INFO' if required else 'UNKNOWN' + return result diff --git a/lang/sk.json b/lang/sk.json index ed0a8955..ef1ea523 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -1,4 +1,6 @@ { + "Host update lock is unavailable.": "Zámok aktualizácie servera nie je dostupný.", + "A host update is already running. Reconnect to it instead of starting another.": "Aktualizácia servera už beží. Otvor jej existujúci priebeh namiesto ďalšieho spustenia.", "# Alternative if you prefer screen": "# Alternatíva, ak preferujete screen", "# Recommended: avoids disconnection during upgrade": "# Odporúčané: zabráni odpojeniu počas aktualizácie", "%s of %s updates failed — see the messages above.": "Zlyhalo %s z %s aktualizácií — pozri hlášky vyššie.", diff --git a/scripts/global/update-pve-safe.sh b/scripts/global/update-pve-safe.sh index 3896c177..e975a1b6 100644 --- a/scripts/global/update-pve-safe.sh +++ b/scripts/global/update-pve-safe.sh @@ -209,6 +209,10 @@ update_pve_safe() { if ! pmx_confirm_update "$(translate "Proxmox Update")" "$menu_text" "$upgradable_list" \ "$(translate "Do you want to proceed with the system update?")"; then msg_info2 "$(translate "Update cancelled by user")" + if [[ "${PROXMENUX_HOST_UPDATE_JOB:-}" == "1" && "${EXECUTION_MODE:-}" == "web" ]]; then + rm -f "$screen_capture" + return 130 + fi apt-get -y autoremove >/dev/null 2>&1 || true apt-get -y autoclean >/dev/null 2>&1 || true rm -f "$screen_capture" diff --git a/scripts/utilities/proxmox_update.sh b/scripts/utilities/proxmox_update.sh index a8784ea5..e5f53d8b 100644 --- a/scripts/utilities/proxmox_update.sh +++ b/scripts/utilities/proxmox_update.sh @@ -45,6 +45,23 @@ export SCRIPT_TITLE="Proxmox system update" NECESSARY_REBOOT=1 +_pmx_acquire_host_update_lock() { + # /run is root-owned; do not follow an unexpected link or truncate a + # pre-existing file. The inherited descriptor also covers Menu/API/CLI. + local path="$1" + if [[ -L "$path" || ( -e "$path" && ! -f "$path" ) ]]; then + msg_error "$(translate 'Host update lock is unavailable.')" + return 75 + fi + exec {PROXMENUX_HOST_UPDATE_LOCK_FD}>> "$path" || return 75 + if ! flock -n "$PROXMENUX_HOST_UPDATE_LOCK_FD"; then + msg_error "$(translate 'A host update is already running. Reconnect to it instead of starting another.')" + return 75 + fi +} + +_pmx_acquire_host_update_lock /run/proxmenux-host-update.lock || exit "$?" + # Suppress the Monitor's `service_fail` notifications while apt is running. # PVE services (pve-cluster, pveproxy, pvedaemon, corosync…) get killed and # restarted as part of the upgrade — those events are expected, not real @@ -122,6 +139,18 @@ check_reboot() { NECESSARY_REBOOT=1 fi + # Detached web jobs finish without waiting for a final keystroke and + # never reboot the host. CLI callers retain their explicit prompt below. + if [[ "${PROXMENUX_HOST_UPDATE_JOB:-}" == "1" && "${EXECUTION_MODE:-}" == "web" ]]; then + if [[ "$NECESSARY_REBOOT" -eq 1 ]]; then + msg_warn "$(translate 'Reboot Required')" + msg_info2 "$(translate 'You can reboot later manually.')" + else + msg_ok "$(translate 'All changes applied. No reboot required.')" + fi + return 0 + fi + if [[ "$NECESSARY_REBOOT" -eq 1 ]]; then if whiptail --title "$(translate "Reboot Required")" \ --yesno "$(translate "Some changes require a reboot to take effect. Do you want to restart now?")" 10 60; then diff --git a/tests/test_host_update_auth.py b/tests/test_host_update_auth.py new file mode 100644 index 00000000..c81297d2 --- /dev/null +++ b/tests/test_host_update_auth.py @@ -0,0 +1,84 @@ +"""Real JWT verification, synthetic keys only; never imports live auth config.""" +import ast +import contextlib +from datetime import datetime, timedelta +import hashlib +import io +from pathlib import Path +import threading +import time +import unittest + +import jwt + +ROOT = Path(__file__).resolve().parents[1] + + +class AuthReasons(unittest.TestCase): + def setUp(self): + source = ast.parse((ROOT / 'AppImage/scripts/auth_manager.py').read_text()) + names = {'verify_token_full', '_log_auth_failure_throttled', 'generate_token'} + nodes = [n for n in source.body if isinstance(n, ast.FunctionDef) and n.name in names] + constants = {n.targets[0].id: ast.literal_eval(n.value) for n in source.body + if isinstance(n, ast.Assign) and isinstance(n.targets[0], ast.Name) + and n.targets[0].id in {'TOKEN_EXPIRATION_HOURS', 'JWT_ALGORITHM', 'JWT_ISSUER', 'JWT_AUDIENCE'}} + self.revoked = set() + self.scope = dict(jwt=jwt, JWT_AVAILABLE=True, hashlib=hashlib, time=time, + _get_jwt_secret=lambda: 'fixture-only-secret-for-offline-auth-tests', + _get_revoked_tokens_cached=lambda: self.revoked, + _AUTH_LOG_LOCK=threading.Lock(), + _AUTH_LOG_RATE=dict(last_ts=0., suppressed=0, last_msg=''), + datetime=datetime, timedelta=timedelta, **constants) + exec(compile(ast.Module(body=nodes, type_ignores=[]), 'actual-auth', 'exec'), self.scope) + + def token(self, **values): + claims = dict(username='fixture-admin', exp=time.time()+120, + iss=self.scope['JWT_ISSUER'], aud=self.scope['JWT_AUDIENCE']) + claims.update(values) + return jwt.encode(claims, self.scope['_get_jwt_secret'](), algorithm='HS256') + + def rejection(self, token, reason): + self.scope['_AUTH_LOG_RATE'].update(last_ts=0., suppressed=0, last_msg='') + out = io.StringIO() + with contextlib.redirect_stdout(out): + result = self.scope['verify_token_full'](token) + self.assertEqual(result, (None, None)) + log = out.getvalue() + self.assertIn(reason, log) + for secret in (self.scope['_get_jwt_secret'](), token or 'not-present', 'fixture-admin'): + self.assertNotIn(secret, log) + + def test_expired_invalid_revoked_and_missing_are_safe_categories(self): + self.rejection(self.token(exp=time.time()-10), 'expired') + self.rejection('fixture.invalid.token', 'invalid') + token = self.token() + self.revoked.add(hashlib.sha256(token.encode()).hexdigest()) + self.rejection(token, 'revoked') + self.rejection(None, 'missing token') + + def test_valid_and_legacy_scopes_keep_existing_contract(self): + verify = self.scope['verify_token_full'] + self.assertEqual(verify(self.token()), ('fixture-admin', 'full_admin')) + self.assertEqual(verify(self.token(scope='read_only')), ('fixture-admin', 'read_only')) + legacy = jwt.encode(dict(username='fixture-admin', exp=time.time()+120), + self.scope['_get_jwt_secret'](), algorithm='HS256') + self.assertEqual(verify(legacy), ('fixture-admin', 'full_admin')) + + def test_normal_session_lifetime_remains_24_hours(self): + self.assertEqual(self.scope['TOKEN_EXPIRATION_HOURS'], 24) + token = self.scope['generate_token']('fixture-admin') + payload = jwt.decode(token, self.scope['_get_jwt_secret'](), algorithms=['HS256'], + audience=self.scope['JWT_AUDIENCE'], issuer=self.scope['JWT_ISSUER']) + self.assertAlmostEqual(payload['exp']-payload['iat'], 86400, delta=1) + + def test_repeated_auth_failures_are_rate_limited_without_secrets(self): + out = io.StringIO() + with contextlib.redirect_stdout(out): + for _ in range(100): + self.scope['verify_token_full']('fixture.invalid.token') + self.assertEqual(out.getvalue().count('[auth]'), 1) + self.assertNotIn('fixture.invalid.token', out.getvalue()) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_host_update_jobs.py b/tests/test_host_update_jobs.py new file mode 100644 index 00000000..c690f215 --- /dev/null +++ b/tests/test_host_update_jobs.py @@ -0,0 +1,349 @@ +"""Real inert host workers plus actual auth/ticket/viewer boundaries. No APT.""" +import ast +import base64 +import importlib.util +import io +import json +import os +from pathlib import Path +import shlex +import sys +import tempfile +import threading +import time +from types import ModuleType, SimpleNamespace +import unittest +from unittest.mock import Mock, patch +from flask import Blueprint, Flask, jsonify, request + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'AppImage/scripts')) +import host_update_jobs as jobs + + +def wait(predicate, timeout=6): + limit = time.monotonic() + timeout + while not predicate(): + if time.monotonic() >= limit: + raise AssertionError('Inert job did not reach the expected boundary') + time.sleep(.01) + + +class Jobs(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.folder = Path(self.tmp.name) + self.script, self.gate, self.events = [self.folder / n for n in ('host.sh', 'finish', 'events')] + self.script.write_text(f'echo started >> {shlex.quote(str(self.events))}\n' + f'while [ ! -e {shlex.quote(str(self.gate))} ]; do sleep .02; done\n' + f'echo final-step >> {shlex.quote(str(self.events))}\necho done\n') + self.fixed = patch.object(jobs, 'SCRIPT', str(self.script)) + self.fixed.start() + self.addCleanup(self.fixed.stop) + jobs._jobs.clear() + self.callback = Mock() + self.addCleanup(self.finish_all) + + def finish_all(self): + self.gate.touch() + for job in list(jobs._jobs.values()): + wait(lambda: job.state == 'complete') + jobs._jobs.clear() + + def start(self, identity='job-1'): + return jobs.start(identity, self.callback) + + def finish(self, job): + self.gate.touch() + wait(lambda: job.state == 'complete') + + def test_real_worker_finishes_post_steps_without_any_viewer(self): + job = self.start() + wait(self.events.exists) + self.assertEqual(self.events.read_text().splitlines(), ['started']) + self.finish(job) + self.assertEqual(self.events.read_text().splitlines(), ['started', 'final-step']) + self.assertEqual(job.exit_code, 0) + self.callback.assert_called_once() + + def test_same_id_is_idempotent_and_other_id_cannot_start(self): + first = self.start() + wait(self.events.exists) + with patch.object(jobs.subprocess, 'Popen') as spawn: + self.assertIs(self.start(), first) + with self.assertRaises(jobs.JobConflict): + self.start('other') + self.assertIs(jobs.get(first.id), first) + spawn.assert_not_called() + self.finish(first) + self.assertIs(self.start(), first) + + def test_concurrent_start_has_one_process(self): + barrier, results = threading.Barrier(6), [] + def run(): + barrier.wait() + results.append(self.start()) + workers = [threading.Thread(target=run) for _ in range(6)] + for worker in workers: + worker.start() + for worker in workers: + worker.join() + self.assertEqual(len({id(job) for job in results}), 1) + self.finish(results[0]) + self.assertEqual(self.events.read_text().splitlines().count('started'), 1) + + def test_read_list_and_missing_resume_have_zero_effects(self): + with patch.object(jobs.subprocess, 'Popen') as spawn, patch.object(jobs.tempfile, 'mkstemp') as files: + self.assertEqual(jobs.list_jobs(), []) + for value in ('missing', '../bad', '', None): + with self.subTest(value=value), self.assertRaises((KeyError, ValueError)): + jobs.get(value) + spawn.assert_not_called() + files.assert_not_called() + + def test_invalid_start_and_missing_script_spawn_nothing(self): + with patch.object(jobs.subprocess, 'Popen') as spawn, patch.object(jobs.tempfile, 'mkstemp') as files: + for value in ('../bad', 'x'*65, None): + with self.assertRaises(ValueError): + self.start(value) + with patch.object(jobs.os.path, 'isfile', return_value=False): + with self.assertRaises(ValueError): + self.start() + spawn.assert_not_called() + files.assert_not_called() + + def test_owner_thread_failure_cannot_spawn_an_unowned_update(self): + with patch.object(jobs.threading.Thread, 'start', side_effect=RuntimeError('thread unavailable')), patch.object(jobs.subprocess, 'Popen') as spawn: + with self.assertRaises(RuntimeError): + self.start() + spawn.assert_not_called() + self.assertEqual(jobs.list_jobs(), []) + + def test_popen_failure_is_recorded_not_success_or_automatic_retry(self): + with patch.object(jobs.subprocess, 'Popen', side_effect=OSError('fixture failure')) as spawn: + job = self.start() + wait(lambda: job.state == 'complete') + self.assertEqual(job.exit_code, 1) + self.assertIs(self.start(), job) + spawn.assert_called_once() + self.assertFalse(self.events.exists()) + + def test_runtime_limit_marks_attention_without_signal_or_second_update(self): + with patch.object(jobs, 'ATTENTION_AFTER', .05), patch.object(jobs.os, 'killpg') as signals: + job = self.start() + wait(lambda: job.state == 'attention') + self.assertIsNone(job.process.poll()) + with self.assertRaises(jobs.JobConflict): + self.start('other') + signals.assert_not_called() + self.finish(job) + self.assertEqual(job.exit_code, 0) + + def test_explicit_viewer_cancel_and_terminal_control_cannot_kill_host(self): + job = self.start() + wait(self.events.exists) + with patch.object(jobs.os, 'killpg') as signals: + for value in ({'type':'cancel'}, '\x03', 'paste\x1a', '\x1c'): + with self.assertRaises(ValueError): + job.input(value) + signals.assert_not_called() + self.finish(job) + + def test_partial_interaction_replays_and_can_only_be_answered_once(self): + job = self.start() + wait(self.events.exists) + identity = 'host-fixture-' + os.urandom(8).hex() + target = Path('/tmp/proxmenux_response_' + identity) + self.addCleanup(lambda: target.unlink(missing_ok=True)) + b64 = lambda s: base64.b64encode(s.encode()).decode() + text = f'WEB_INTERACTION:yesno:{identity}:{b64("Confirm")}:{b64("Question")}\n' + job._interactions(io.StringIO(text[:17])) + self.assertIsNone(job.interaction) + job._interactions(io.StringIO(text[17:])) + self.assertEqual(jobs.get(job.id).read(0)['interaction']['id'], identity) + job.input(dict(type='interaction_response', id=identity, value='yes')) + self.assertEqual(target.read_text(), 'yes') + self.assertEqual(target.stat().st_mode & 0o777, 0o600) + with patch.object(jobs.os, 'open') as write: + with self.assertRaises(ValueError): + job.input(dict(type='interaction_response', id=identity, value='yes')) + write.assert_not_called() + self.finish(job) + + def test_symlink_response_is_not_overwritten(self): + job = self.start() + wait(self.events.exists) + identity = 'host-fixture-' + os.urandom(8).hex() + target = Path('/tmp/proxmenux_response_' + identity) + target.symlink_to(self.events) + self.addCleanup(lambda: target.unlink(missing_ok=True)) + job.interaction = dict(id=identity) + before = self.events.read_bytes() + with self.assertRaises(OSError): + job.input(dict(type='interaction_response', id=identity, value='no')) + self.assertEqual(self.events.read_bytes(), before) + self.finish(job) + + def test_finalizing_reserves_host_and_failure_is_not_hidden(self): + entered, release = threading.Event(), threading.Event() + self.addCleanup(release.set) + def finalize(*_): + entered.set() + release.wait(3) + raise RuntimeError('cache failure') + self.callback.side_effect = finalize + job = self.start() + self.gate.touch() + self.assertTrue(entered.wait(3)) + with self.assertRaises(jobs.JobConflict): + self.start('other') + release.set() + wait(lambda: job.state == 'complete') + self.assertEqual(job.script_exit_code, 0) + self.assertEqual(job.exit_code, 1) + self.assertTrue(job.finalization_error) + self.callback.assert_called_once() + + def test_failed_shell_with_surviving_child_remains_reserved(self): + self.script.write_text( + f'(trap "" HUP; echo child-ready >> {shlex.quote(str(self.events))}; ' + f'while [ ! -e {shlex.quote(str(self.gate))} ]; do sleep .02; done; ' + f'echo child-finished >> {shlex.quote(str(self.events))}) &\n' + f'while [ ! -s {shlex.quote(str(self.events))} ]; do sleep .01; done\nexit 7\n') + job = self.start() + wait(lambda: job.state == 'attention') + with self.assertRaises(jobs.JobConflict): + self.start('other') + self.gate.touch() + wait(lambda: job.state == 'complete') + self.assertEqual(job.exit_code, 7) + self.assertIn('child-finished', self.events.read_text()) + + +class Routes(unittest.TestCase): + finish_all = Jobs.finish_all + finish = Jobs.finish + def setUp(self): + Jobs.setUp(self) + self.app = Flask(__name__) + auth = ModuleType('auth_manager') + auth.load_auth_config = lambda: {'enabled':True} + auth.verify_token = lambda token: 'fixture' if token=='valid' else None + auth.verify_token_full = lambda token: ('fixture','full_admin') if token=='valid' else ('fixture','read_only') if token=='read-only' else (None,None) + modules = patch.dict(sys.modules, {'auth_manager':auth}) + modules.start() + self.addCleanup(modules.stop) + spec = importlib.util.spec_from_file_location('host_jwt_fixture',ROOT/'AppImage/scripts/jwt_middleware.py') + middleware = importlib.util.module_from_spec(spec) + spec.loader.exec_module(middleware) + self.ws_routes = {} + def route(path): + def register(fn): + self.ws_routes[path]=fn + return fn + return register + scope=dict(terminal_bp=Blueprint('host_fixture',__name__),sock=SimpleNamespace(route=route), + request=request,jsonify=jsonify,require_admin_scope=middleware.require_admin_scope, + _TERMINAL_TICKETS={},_TICKETS_LOCK=threading.Lock(),_TICKET_TTL=5, + _TICKET_MAX_INFLIGHT=256,time=time,secrets=__import__('secrets'),json=json, + _host_update_completed=self.callback) + names={'_issue_terminal_ticket','_consume_terminal_ticket','_ws_auth_check', + 'issue_terminal_ticket_route','host_update_jobs_status','host_update_viewer'} + nodes=[n for n in ast.parse((ROOT/'AppImage/scripts/flask_terminal_routes.py').read_text()).body if isinstance(n,ast.FunctionDef) and n.name in names] + self.assertEqual(len(nodes),len(names)) + exec(compile(ast.Module(body=nodes,type_ignores=[]),'actual-host-routes','exec'),scope) + self.app.register_blueprint(scope['terminal_bp']) + self.client=self.app.test_client() + + def ticket(self, token='valid'): + return self.client.post('/api/terminal/ticket',headers={'Authorization':'Bearer '+token}) + + def attach(self, init, ticket=None, identity='job-1'): + frames=iter([json.dumps(init)]) + ws=Mock() + def receive(**_): + try: + return next(frames) + except StopIteration: + raise ConnectionError('viewer signed out') + ws.receive.side_effect=receive + with self.app.test_request_context('/ws/host-update/'+identity+('?ticket='+ticket if ticket else '')): + self.ws_routes['/ws/host-update/'](ws,identity) + return ws + + def test_401_and_read_only_cannot_list_start_or_get_a_ticket(self): + for token,status in [('expired',401),('read-only',403)]: + with patch.object(jobs.subprocess,'Popen') as spawn: + self.assertEqual(self.ticket(token).status_code,status) + self.assertEqual(self.client.get('/api/host-update-jobs',headers={'Authorization':'Bearer '+token}).status_code,status) + self.attach(dict(resume=False)) + spawn.assert_not_called() + self.assertEqual(jobs.list_jobs(),[]) + + def test_real_route_disconnect_401_then_relogin_replays_one_job(self): + self.attach(dict(resume=False),self.ticket().json['ticket']) + job=jobs.get('job-1') + wait(self.events.exists) + self.assertEqual(self.client.get('/api/host-update-jobs',headers={'Authorization':'Bearer expired'}).status_code,401) + self.assertEqual(self.client.get('/api/host-update-jobs',headers={'Authorization':'Bearer valid'}).json['jobs'][0]['id'],job.id) + with patch.object(jobs.subprocess,'Popen') as spawn: + self.attach(dict(resume=True),self.ticket().json['ticket']) + spawn.assert_not_called() + self.finish(job) + ws=self.attach(dict(resume=True),self.ticket().json['ticket']) + messages=[json.loads(call.args[0]) for call in ws.send.call_args_list] + self.assertEqual(messages[-1],dict(type='script_complete',exit_code=0)) + self.assertEqual(self.events.read_text().splitlines(),['started','final-step']) + self.callback.assert_called_once() + + def test_missing_resume_extra_params_and_boolean_cursor_spawn_nothing(self): + with patch.object(jobs.subprocess,'Popen') as spawn, patch.object(jobs.tempfile,'mkstemp') as files: + for init in (dict(resume=True),dict(resume=False,params={'LD_PRELOAD':'bad'}),dict(resume=False,offset=True),dict(resume='false')): + self.attach(init,self.ticket().json['ticket']) + spawn.assert_not_called() + files.assert_not_called() + + def test_legacy_action_api_keeps_fixed_command_and_cancel_cannot_target_host_job(self): + # Execute the actual existing API functions, with only the systemd + # boundary replaced. They must keep their original unit/argv contract. + source=ast.parse((ROOT/'AppImage/scripts/flask_server.py').read_text()) + names={'api_system_pve_update_run','api_system_pve_update_status','api_system_pve_update_cancel'} + nodes=[n for n in source.body if isinstance(n,ast.FunctionDef) and n.name in names] + app=Flask('legacy-host-action-fixture') + run=Mock(return_value=(True,None)) + state=Mock(return_value={'status':'running'}) + cancel=Mock() + scope=dict(app=app,require_admin_scope=lambda fn:fn,require_auth=lambda fn:fn, + os=os,jsonify=jsonify,_PVE_UPDATE_SCRIPT=str(self.script), + _action_run=run,_action_state=state,_action_cancel=cancel) + exec(compile(ast.Module(body=nodes,type_ignores=[]),'actual-legacy-api','exec'),scope) + client=app.test_client() + self.assertEqual(client.post('/api/system/pve-update/run').status_code,202) + run.assert_called_once_with('proxmenux-action-pve-update',['/bin/bash',str(self.script)], + description='ProxMenux action: Proxmox VE update') + self.assertEqual(client.get('/api/system/pve-update/status').status_code,200) + self.assertEqual(client.delete('/api/system/pve-update').status_code,200) + cancel.assert_called_once_with('proxmenux-action-pve-update') + self.assertEqual(jobs.list_jobs(),[]) + + def test_actual_host_finalizer_invalidates_health_without_viewer_or_package_calls(self): + from types import SimpleNamespace + keys=('updates_check','overall_health','_bg_overall','_bg_detailed') + cache=SimpleNamespace(last_check_times=dict.fromkeys((*keys,'unrelated'),123), + cached_results=dict.fromkeys((*keys,'unrelated'),{'status':'old'})) + module=ModuleType('health_monitor') + module.health_monitor=cache + node=next(n for n in ast.parse((ROOT/'AppImage/scripts/flask_terminal_routes.py').read_text()).body + if isinstance(n,ast.FunctionDef) and n.name=='_host_update_completed') + scope={} + exec(compile(ast.Module(body=[node],type_ignores=[]),'actual-finalizer','exec'),scope) + with patch.dict(sys.modules,{'health_monitor':module}),patch.object(jobs.subprocess,'Popen') as spawn: + scope['_host_update_completed'](jobs.SCRIPT,{},0,1) + spawn.assert_not_called() + self.assertEqual(cache.last_check_times,{'unrelated':123}) + self.assertEqual(cache.cached_results,{'unrelated':{'status':'old'}}) + + +if __name__=='__main__': + unittest.main(verbosity=2) diff --git a/tests/test_host_update_shell.py b/tests/test_host_update_shell.py new file mode 100644 index 00000000..9728ff6c --- /dev/null +++ b/tests/test_host_update_shell.py @@ -0,0 +1,204 @@ +"""Actual wrapper + worker, with every machine effect replaced by local fixtures. + +Only fixed filesystem locations are redirected. Package commands and reboot +are inert executables which record calls; no root, network or host update. +""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class HostUpdateShell(unittest.TestCase): + def test_packaging_copies_required_modules_with_no_host_probe_on_import(self): + source = (ROOT / 'AppImage/scripts/build_appimage.sh').read_text().splitlines() + copies = [f'cp "$SCRIPT_DIR/{name}.py" "$APP_DIR/usr/bin/"' + for name in ('host_update_jobs', 'kernel_reboot')] + for line in copies: + self.assertIn(line, source) + app = self.folder / 'AppDir' + (app / 'usr/bin').mkdir(parents=True) + result = subprocess.run(['/bin/bash', '-c', '\n'.join(copies)], + env=dict(os.environ, SCRIPT_DIR=str(ROOT / 'AppImage/scripts'), APP_DIR=str(app)), + capture_output=True, text=True, timeout=3) + self.assertEqual(result.returncode, 0, result.stderr) + code = ('import sys; from unittest.mock import patch; sys.path.insert(0,sys.argv[1]); ' + 'guard=patch("subprocess.Popen",side_effect=AssertionError("forbidden probe")); ' + 'guard.start(); import host_update_jobs,kernel_reboot; ' + 'assert host_update_jobs.list_jobs()==[]') + result = subprocess.run([sys.executable, '-I', '-B', '-c', code, str(app / 'usr/bin')], + capture_output=True, text=True, timeout=3) + self.assertEqual(result.returncode, 0, result.stderr) + + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='host-update-shell-') + self.addCleanup(self.temp.cleanup) + self.folder = Path(self.temp.name) + self.bin = self.folder / 'bin' + self.bin.mkdir() + scripts = self.folder / 'scripts' + (scripts / 'utilities').mkdir(parents=True) + (scripts / 'global').mkdir() + self.calls = self.folder / 'calls' + self.calls.touch() + self.lock = self.folder / 'update.lock' + for name in ('utilities/proxmox_update.sh', 'global/update-pve-safe.sh'): + text = (ROOT / 'scripts' / name).read_text() + text = text.replace('/usr/local/share/proxmenux', str(self.folder)) + text = text.replace('/run/proxmenux-host-update.lock', str(self.lock)) + text = text.replace('/var/run/proxmenux-update-', str(self.folder / 'marker-')) + text = text.replace('/var/run/reboot-required', str(self.folder / 'reboot-required')) + text = text.replace('/var/log/proxmox-update-', str(self.folder / 'update-')) + text = text.replace('/tmp/proxmenux_screen_capture_', str(self.folder / 'capture-')) + (scripts / name).write_text(text) + (self.folder / 'utils.sh').write_text(''' +load_language() { :; } +initialize_cache() { :; } +translate() { printf '%s' "$1"; } +msg_error() { echo "$*"; } +msg_warn() { echo "$*"; } +msg_info() { echo "$*"; } +msg_info2() { echo "$*"; } +msg_ok() { echo "$*"; } +msg_title() { echo "$*"; } +msg_success() { echo "$*"; } +show_proxmenux_logo() { :; } +LOCAL_SCRIPTS="$FIXTURE/scripts" +''') + (scripts / 'global/common-functions.sh').write_text(''' +pmx_format_upgradable() { cat; } +pmx_dpkg_changes_since() { echo 'package changes fixture'; } +lvm_repair_check() { echo lvm-poststep >> "$CALLS"; } +pmx_rebuild_dkms_after_kernel() { echo dkms-poststep >> "$CALLS"; } +''') + (scripts / 'global/utils-install-functions.sh').write_text('ensure_repositories() { return 0; }\n') + commands = { + 'pveversion': "echo 'pve-manager/9.2.24/test (running kernel: 7.0.14-25-pve)'", + 'df': "printf 'Filesystem 1K-blocks Used Available Use%% Mounted\\nfixture 9999999 1 9999999 1%% /\\n'", + 'uname': "echo 7.0.14-25-pve", + 'dpkg-query': "printf 'install ok installed\\tproxmox-kernel-7.0.14-26-pve-signed\\n'", + 'apt-cache': "echo 'Candidate: 9.2.24'", + 'apt-get': 'echo "apt-get $*" >> "$CALLS"; exit 0', + 'apt': '''echo "apt $*" >> "$CALLS" +if [[ "$1" == list ]]; then + printf 'Listing...\\npve-manager/stable 9.2.24 amd64 [upgradable from: 9.2.21]\\n' +else + touch "$FIXTURE/upgrade-started" + while [[ ! -e "$FIXTURE/release" ]]; do sleep .02; done + exit "${UPGRADE_EXIT:-0}" +fi''', + 'dialog': 'echo dialog >> "$CALLS"; exit "${CONFIRM_EXIT:-0}"', + 'whiptail': 'echo "whiptail $*" >> "$CALLS"; exit 1', + 'reboot': 'echo FORBIDDEN-REBOOT >> "$CALLS"; exit 99', + 'clear': ':', + } + for name, body in commands.items(): + path = self.bin / name + path.write_text('#!/bin/bash\n' + body + '\n') + path.chmod(0o700) + self.env = dict(os.environ, PATH=str(self.bin)+':'+os.environ['PATH'], + FIXTURE=str(self.folder), CALLS=str(self.calls), + EXECUTION_MODE='web', PROXMENUX_HOST_UPDATE_JOB='1') + self.wrapper = scripts / 'utilities/proxmox_update.sh' + self.procs = [] + self.addCleanup(self.finish) + + def finish(self): + (self.folder / 'release').touch() + for proc in self.procs: + if proc.poll() is None: + proc.communicate('\n', timeout=8) + + def start(self, **env): + proc = subprocess.Popen(['/bin/bash', str(self.wrapper)], env=dict(self.env, **env), + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True) + self.procs.append(proc) + return proc + + def wait_upgrade(self): + until = time.monotonic() + 5 + while not (self.folder / 'upgrade-started').exists(): + self.assertLess(time.monotonic(), until, 'inert upgrade did not start') + time.sleep(.01) + + def test_web_full_flow_finishes_poststeps_and_footer_without_reboot_or_keypress(self): + proc = self.start() + self.wait_upgrade() + (self.folder / 'release').touch() + output, _ = proc.communicate(timeout=8) + self.assertEqual(proc.returncode, 0, output) + calls = self.calls.read_text() + self.assertIn('lvm-poststep', calls) + self.assertIn('dkms-poststep', calls) + self.assertIn('apt-get -y autoremove', calls) + self.assertIn('apt-get -y autoclean', calls) + self.assertNotIn('FORBIDDEN-REBOOT', calls) + self.assertNotIn('whiptail', calls) + self.assertIn('Reboot Required', output) + self.assertIn('You can reboot later manually.', output) + self.assertFalse((self.folder / 'marker-in-progress').exists()) + self.assertTrue((self.folder / 'marker-just-finished').exists()) + log = next(self.folder.glob('update-*.log')).read_text() + self.assertIn('--- Packages changed (exit 0) ---', log) + self.assertIn('Finished:', log) + + def test_cancel_before_upgrade_has_no_upgrade_cleanup_or_reboot(self): + proc = self.start(CONFIRM_EXIT='1') + output, _ = proc.communicate(timeout=5) + self.assertEqual(proc.returncode, 130, output) + calls = self.calls.read_text() + for forbidden in ('full-upgrade', 'autoremove', 'autoclean', 'REBOOT', 'poststep'): + self.assertNotIn(forbidden, calls) + + def test_failure_keeps_failure_and_never_offers_reboot(self): + (self.folder / 'release').touch() + proc = self.start(UPGRADE_EXIT='1') + output, _ = proc.communicate(timeout=5) + self.assertEqual(proc.returncode, 1, output) + for forbidden in ('autoremove', 'autoclean', 'REBOOT', 'poststep', 'whiptail'): + self.assertNotIn(forbidden, self.calls.read_text()) + + def test_old_menu_cli_contract_retains_explicit_reboot_prompt(self): + (self.folder / 'release').touch() + proc = self.start(PROXMENUX_HOST_UPDATE_JOB='', EXECUTION_MODE='cli') + output, _ = proc.communicate('\n', timeout=5) + self.assertEqual(proc.returncode, 0, output) + self.assertIn('whiptail --title Reboot Required --yesno', self.calls.read_text()) + self.assertNotIn('FORBIDDEN-REBOOT', self.calls.read_text()) + + def test_shared_lock_rejects_second_legacy_or_web_caller_before_any_package_call(self): + first = self.start() + self.wait_upgrade() + before = self.calls.read_text() + for mode in ('cli', 'web'): + second = self.start(EXECUTION_MODE=mode) + output, _ = second.communicate(timeout=3) + self.assertEqual(second.returncode, 75, output) + self.assertEqual(self.calls.read_text(), before) + self.assertIn('already running', output) + self.assertIsNone(first.poll()) + + def test_lock_does_not_follow_symlink_or_truncate_existing_file(self): + target = self.folder / 'protected' + target.write_text('preserve') + self.lock.symlink_to(target) + proc = self.start() + output, _ = proc.communicate(timeout=3) + self.assertEqual(proc.returncode, 75, output) + self.assertEqual(target.read_text(), 'preserve') + self.assertEqual(self.calls.read_text(), '') + self.lock.unlink() + self.lock.write_text('existing metadata') + proc = self.start(CONFIRM_EXIT='1') + proc.communicate(timeout=5) + self.assertEqual(self.lock.read_text(), 'existing metadata') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_host_update_transport.py b/tests/test_host_update_transport.py new file mode 100644 index 00000000..2a218d8c --- /dev/null +++ b/tests/test_host_update_transport.py @@ -0,0 +1,107 @@ +"""Real HTTP + WebSocket + PTY under the same gevent patch as Monitor. + +Authentication boundaries use synthetic identities; full JWT verification is +covered separately. The sole executable is an inert temporary shell script. +""" +from pathlib import Path +import subprocess +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +def wire_fixture(): + import json + import shlex + import tempfile + import time + from types import ModuleType + import requests + from flask import Flask + from gevent.pywsgi import WSGIServer + from simple_websocket import Client + sys.path.insert(0, str(ROOT / 'AppImage/scripts')) + auth = ModuleType('auth_manager') + auth.load_auth_config = lambda: {'enabled': True} + auth.verify_token = lambda token: 'fixture' if token == 'valid' else None + auth.verify_token_full = lambda token: ('fixture', 'full_admin') if token == 'valid' else ('fixture', 'read_only') if token == 'read-only' else (None, None) + sys.modules['auth_manager'] = auth + import flask_terminal_routes as routes + import host_update_jobs as jobs + callbacks = [] + routes._host_update_completed = lambda *args: callbacks.append(args) + app = Flask(__name__) + app.register_blueprint(routes.terminal_bp) + routes.sock.init_app(app) + server = WSGIServer(('127.0.0.1', 0), app, log=None) + server.start() + base = f'http://127.0.0.1:{server.server_port}' + headers = {'Authorization': 'Bearer valid'} + def wait(predicate): + end = time.monotonic() + 5 + while not predicate(): + assert time.monotonic() < end, 'fixture timeout' + time.sleep(.01) + def ticket(): + response = requests.post(base + '/api/terminal/ticket', headers=headers, timeout=3) + assert response.status_code == 200 + return response.json()['ticket'] + def connect(identity, resume): + client = Client(base.replace('http:', 'ws:') + '/ws/host-update/' + identity + '?ticket=' + ticket()) + client.send(json.dumps(dict(resume=resume, offset=0))) + return client + try: + with tempfile.TemporaryDirectory(prefix='host-update-wire-') as folder: + folder = Path(folder) + gate, events, script = [folder / n for n in ('release', 'events', 'update.sh')] + script.write_text(f'echo start >> {shlex.quote(str(events))}\n' + f'while [ ! -f {shlex.quote(str(gate))} ]; do sleep .02; done\n' + f'echo poststep >> {shlex.quote(str(events))}\necho completed\n') + jobs.SCRIPT = str(script) + viewer = connect('one-host-run', False) + wait(events.exists) + if viewer.connected: + viewer.close() + assert requests.get(base + '/api/host-update-jobs', headers={'Authorization': 'Bearer expired'}, timeout=3).status_code == 401 + assert requests.post(base + '/api/terminal/ticket', headers={'Authorization': 'Bearer read-only'}, timeout=3).status_code == 403 + listed = requests.get(base + '/api/host-update-jobs', headers=headers, timeout=3).json()['jobs'] + assert listed[0]['id'] == 'one-host-run' + viewer = connect('one-host-run', True) + gate.touch() + frames = [] + while not frames or frames[-1]['type'] != 'script_complete': + message = viewer.receive(timeout=5) + assert message is not None + frames.append(json.loads(message)) + assert frames[-1]['exit_code'] == 0 + assert events.read_text().splitlines() == ['start', 'poststep'] + assert len(callbacks) == 1 + assert 'completed' in ''.join(frame.get('text', '') for frame in frames) + if viewer.connected: + viewer.close() + viewer = connect('missing-host-run', True) + assert json.loads(viewer.receive(timeout=3))['type'] == 'job_unavailable' + if viewer.connected: + viewer.close() + assert len(jobs.list_jobs()) == 1 + assert len(callbacks) == 1 + finally: + server.stop(timeout=2) + print('PASS real gevent HTTP/WS disconnect, 401, relogin, resume, PTY poststep and missing-ID no restart') + + +class HostWire(unittest.TestCase): + def test_actual_gevent_transport_survives_viewer_loss(self): + command = ('from gevent import monkey; monkey.patch_all(); ' + 'import runpy,sys; runpy.run_path(sys.argv[1],run_name="__wire__")') + result = subprocess.run([sys.executable, '-I', '-B', '-c', command, str(Path(__file__).resolve())], + capture_output=True, text=True, timeout=25) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('PASS real gevent', result.stdout) + + +if __name__ == '__wire__': + wire_fixture() +elif __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_host_update_ui.cjs b/tests/test_host_update_ui.cjs new file mode 100644 index 00000000..0fae438d --- /dev/null +++ b/tests/test_host_update_ui.cjs @@ -0,0 +1,63 @@ +// Execute actual transport initializers, reconnect handlers and routing. +const assert = require('node:assert/strict') +const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm') +const app = path.resolve(__dirname, '../AppImage') +const ts = require(path.join(app, 'node_modules/typescript')) +const text = fs.readFileSync(path.join(app, 'components/script-terminal-modal.tsx'), 'utf8') +const tree = ts.createSourceFile('modal.tsx', text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) +const opens = [], messages = [], closes = [] +let route, cleanup +function walk(n) { + if (ts.isBinaryExpression(n) && n.left.getText(tree) === 'ws.onopen') opens.push(n.right.getText(tree)) + if (ts.isBinaryExpression(n) && n.left.getText(tree) === 'ws.onmessage') messages.push(n.right.getText(tree)) + if (ts.isBinaryExpression(n) && n.left.getText(tree) === 'ws.onclose') closes.push(n.right.getText(tree)) + if (ts.isVariableDeclaration(n) && n.name.getText(tree) === 'getScriptWebSocketUrl') route = n.initializer.getText(tree) + if (ts.isCallExpression(n) && n.expression.getText(tree) === 'useEffect' + && n.arguments[0]?.getText(tree).includes('Transport mode is fixed')) cleanup = n.arguments[0].getText(tree) + ts.forEachChild(n, walk) +} +walk(tree) +const compile = (code, scope) => vm.runInNewContext(ts.transpileModule('('+code+')', { + compilerOptions:{target:ts.ScriptTarget.ES2020,module:ts.ModuleKind.CommonJS}, +}).outputText, scope) +assert.equal(opens.length, 2) +for (const [index, open] of opens.entries()) { + for (const resume of [false, true]) { + const sent = [], scope = {backgroundUpdate:{runId:'same-id',kind:'host',resume}, + scriptPath:'/forbidden-client-path', paramsRef:{current:{FORBIDDEN:'client-env'}}, + outputOffsetRef:{current:42}, setConnectionStatus(){}, reconnectAttemptsRef:{current:0}, + keepAliveIntervalRef:{current:null}, clearInterval(){}, setInterval(){return 1}, setTimeout(){}, + WebSocket:{OPEN:1}, ws:{readyState:1,send:s=>sent.push(JSON.parse(s))}} + compile(open, scope)() + assert.equal(sent.length,1) + assert.deepEqual(sent[0],{resume:index===0 ? true : resume, offset:42}, + 'reconnect must attach; explicit initial start is the sole resume=false message') + } +} +for (const backgroundUpdate of [{runId:'same-id',kind:'host'}, {runId:'lxc-id',vmid:101}, undefined]) { + const actual = compile(route,{backgroundUpdate,API_PORT:'8008'})('legacy-id') + assert.equal(actual,'ws://localhost:8008/ws/'+(backgroundUpdate?.kind==='host'?'host-update/same-id':backgroundUpdate?'lxc-update/lxc-id':'script/legacy-id')) +} +for (const close of closes) { + const sent=[], results=[], scope={backgroundUpdate:{runId:'same-id',kind:'host'}, + ws:{send:s=>sent.push(s)}, isOpenRef:{current:true}, isComplete:false, + completionReceivedRef:{current:false}, keepAliveIntervalRef:{current:null}, + reconnectAttemptsRef:{current:0},reconnectTimeoutRef:{current:null},setTimeout(fn){fn();return 1},clearTimeout(){}, + setConnectionStatus(){}, clearInterval(){}, termRef:{current:{writeln(){}}},term:{writeln(){}}, + attemptReconnect(){results.push('attach')}, onCompleteRef:{current:()=>results.push('complete')},t:s=>s} + compile(close,scope)({}) + assert.deepEqual(results,['attach']) + assert.deepEqual(sent,[]) +} +const sent=[], scope={backgroundUpdate:{runId:'same-id',kind:'host'}, + openGenerationRef:{current:0}, isOpenRef:{current:true}, wsRef:{current:{close(){},send:x=>sent.push(x)}}, + termRef:{current:{dispose(){}}}, clearTimeout(){},clearInterval(){}, + waitingTimeoutRef:{current:null}, reconnectTimeoutRef:{current:null}, keepAliveIntervalRef:{current:null},checkConnectionInterval:{current:null}} +compile(cleanup,scope)()() +assert.deepEqual(sent,[],'unmount cannot send cancellation or restart') +const health=fs.readFileSync(path.join(app,'components/health-status-modal.tsx'),'utf8') +const en=JSON.parse(fs.readFileSync(path.join(app,'messages/en/common.json'),'utf8')) +const sk=JSON.parse(fs.readFileSync(path.join(app,'messages/sk/common.json'),'utf8')) +assert.deepEqual(Object.keys(en.healthStatus.hostUpdate).sort(),Object.keys(sk.healthStatus.hostUpdate).sort()) +assert.ok(health.includes('Number(categoryData?.count ?? 0) > 0'),'reboot-only state has no Update Now action') +console.log('PASS host initial/resume transport, legacy/LXC routes, disconnect/unmount and EN/SK keys') diff --git a/tests/test_kernel_reboot.py b/tests/test_kernel_reboot.py new file mode 100644 index 00000000..5efd7096 --- /dev/null +++ b/tests/test_kernel_reboot.py @@ -0,0 +1,134 @@ +"""Real dpkg contract shape and actual cached HealthMonitor method. No APT.""" +import ast +import copy +from pathlib import Path +import subprocess +import sys +import time +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT/'AppImage/scripts')) +import kernel_reboot as kernel + +def listing(*releases): + return '\n'.join('installed\tproxmox-kernel-'+v+'-signed' for v in releases)+'\n' + +class Reboot(unittest.TestCase): + def setUp(self): + self.uname=patch.object(kernel.os,'uname',return_value=SimpleNamespace(release='7.0.14-25-pve')) + self.uname.start() + self.addCleanup(self.uname.stop) + self.marker=patch.object(Path,'exists',return_value=False) + self.marker.start() + self.addCleanup(self.marker.stop) + + def check(self, output, rc=0): + with patch.object(kernel.subprocess,'run',return_value=subprocess.CompletedProcess([],rc,output,'')) as call: + result=kernel.reboot_status() + if call.called: + self.assertEqual(call.call_args.args[0][0],'dpkg-query') + self.assertNotIn('apt',call.call_args.args[0]) + return result + + def test_installed_26_running_25_requires_reboot_without_marker_or_pending_updates(self): + result=self.check(listing('7.0.14-25-pve','7.0.14-26-pve')) + self.assertEqual(result['state'],'required') + self.assertEqual(result['installed_kernel'],'7.0.14-26-pve') + + def test_old_kernel_meta_and_removed_config_are_not_new_installed_kernel(self): + text=listing('7.0.14-25-pve','6.17.13-21-pve')+'installed\tproxmox-kernel-7.0\nconfig-files\tproxmox-kernel-7.0.14-26-pve-signed\n' + self.assertEqual(self.check(text)['state'],'current') + + def test_reboot_into_new_kernel_clears_the_requirement(self): + with patch.object(kernel.os,'uname',return_value=SimpleNamespace(release='7.0.14-26-pve')): + self.assertEqual(self.check(listing('7.0.14-25-pve','7.0.14-26-pve'))['state'],'current') + + def test_version_comparison_is_numeric(self): + self.assertEqual(self.check(listing('7.0.14-9-pve','7.0.14-100-pve'))['state'],'required') + + def test_legacy_pve_package_and_arch_suffix_are_supported(self): + self.assertEqual(self.check('installed\tpve-kernel-7.0.14-26-pve:amd64\n')['state'],'required') + + def test_missing_empty_malformed_and_partial_are_unknown_not_zero(self): + for text in ('','garbage','installed\tother-package\n','half-configured\tproxmox-kernel-7.0.14-26-pve-signed\n','bad-state\tfoo\n'): + with self.subTest(text=text): + self.assertEqual(self.check(text)['state'],'unknown') + self.assertEqual(self.check(listing('7.0.14-26-pve'),rc=1)['state'],'unknown') + + def test_timeout_and_read_error_are_unknown(self): + for error in (subprocess.TimeoutExpired('dpkg-query',5),OSError('unreadable')): + with patch.object(kernel.subprocess,'run',side_effect=error): + self.assertEqual(kernel.reboot_status()['state'],'unknown') + + def test_unavailable_or_invalid_uname_never_probes_packages(self): + with patch.object(kernel.os,'uname',side_effect=OSError('unknown')),patch.object(kernel.subprocess,'run') as packages: + self.assertEqual(kernel.reboot_status()['state'],'unknown') + packages.assert_not_called() + for release in ('','invalid','7.0.14--pve'): + with patch.object(kernel.os,'uname',return_value=SimpleNamespace(release=release)),patch.object(kernel.subprocess,'run') as packages: + self.assertEqual(kernel.reboot_status()['state'],'unknown') + packages.assert_not_called() + + def test_supported_marker_never_requires_a_package_probe(self): + with patch.object(Path,'exists',return_value=True),patch.object(kernel.subprocess,'run') as packages: + self.assertEqual(kernel.reboot_status()['state'],'required') + packages.assert_not_called() + + def test_non_pve_kernel_is_not_a_failed_pve_read(self): + with patch.object(kernel.os,'uname',return_value=SimpleNamespace(release='6.12.0-amd64')),patch.object(kernel.subprocess,'run') as packages: + self.assertEqual(kernel.reboot_status()['state'],'not_applicable') + packages.assert_not_called() + + def test_overlay_does_not_mutate_cache_or_hide_existing_error(self): + for original in ('OK','UNKNOWN','WARNING','CRITICAL'): + cache=dict(status=original,count=0,checks={'pending_updates':dict(status='OK')}) + before=copy.deepcopy(cache) + result=kernel.with_reboot_status(cache,dict(state='required',running_kernel='old',installed_kernel='new')) + self.assertEqual(cache,before) + self.assertTrue(result['reboot_required']) + self.assertEqual(result['status'],'INFO' if original=='OK' else original) + result=kernel.with_reboot_status(dict(status='OK',checks={}),dict(state='unknown',running_kernel='old',installed_kernel=None)) + self.assertEqual(result['status'],'UNKNOWN') + self.assertIsNone(result['reboot_required']) + + def test_actual_cached_health_path_refreshes_reboot_evidence_without_apt(self): + source=ast.parse((ROOT/'AppImage/scripts/health_monitor.py').read_text()) + node=next(n for n in ast.walk(source) if isinstance(n,ast.FunctionDef) and n.name=='_check_updates') + node.decorator_list=[] + import __future__ + scope=dict(os=kernel.os,time=time,subprocess=kernel.subprocess) + exec(compile(ast.Module(body=[node],type_ignores=[]),'actual-health','exec',flags=__future__.annotations.compiler_flag),scope) + for age in (0,599.999): + cache=dict(status='OK',count=0,checks={}) + owner=SimpleNamespace(last_check_times={'updates_check':time.time()-age},cached_results={'updates_check':cache}) + with patch.object(kernel.os.path,'exists',return_value=True),patch.object(kernel.os.path,'getmtime',return_value=0),patch.object(kernel.subprocess,'run',return_value=subprocess.CompletedProcess([],0,listing('7.0.14-26-pve'),'')) as probe: + result=scope['_check_updates'](owner) + self.assertEqual(result['status'],'INFO') + self.assertTrue(result['reboot_required']) + self.assertEqual(cache,dict(status='OK',count=0,checks={})) + self.assertEqual([call.args[0][0] for call in probe.call_args_list],['dpkg-query']) + + def test_actual_health_failure_paths_keep_independent_reboot_evidence(self): + source=ast.parse((ROOT/'AppImage/scripts/health_monitor.py').read_text()) + node=next(n for n in ast.walk(source) if isinstance(n,ast.FunctionDef) and n.name=='_check_updates') + import __future__ + scope=dict(os=kernel.os,time=time,subprocess=kernel.subprocess) + exec(compile(ast.Module(body=[node],type_ignores=[]),'actual-health','exec',flags=__future__.annotations.compiler_flag),scope) + for age in (600,600.001): + for error in (subprocess.TimeoutExpired('apt-get',30),OSError('fixture-unavailable')): + owner=SimpleNamespace(last_check_times={'updates_check':time.time()-age},cached_results={}) + def command(args, **kwargs): + if args[0]=='dpkg-query': + return subprocess.CompletedProcess(args,0,listing('7.0.14-26-pve'),'') + raise error + with patch.object(kernel.os.path,'exists',return_value=False),patch.object(kernel.subprocess,'run',side_effect=command) as probe: + result=scope['_check_updates'](owner) + self.assertEqual(result['status'],'UNKNOWN') + self.assertTrue(result['reboot_required']) + self.assertEqual([call.args[0][0] for call in probe.call_args_list],['dpkg-query','apt-get']) + +if __name__=='__main__': + unittest.main(verbosity=2) From ac9565fa1903b7112d14798da7e591cf857bdbb6 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:33:34 +0000 Subject: [PATCH 02/13] fix: refresh reboot evidence after external host updates --- .github/scripts/run_offline_qualified.py | 2 + AppImage/components/health-status-modal.tsx | 58 +++++-- AppImage/components/proxmox-dashboard.tsx | 35 +++- AppImage/scripts/flask_health_routes.py | 20 ++- AppImage/scripts/kernel_reboot.py | 37 +++++ tests/test_reboot_health_refresh.cjs | 130 +++++++++++++++ tests/test_reboot_health_refresh.py | 173 ++++++++++++++++++++ 7 files changed, 434 insertions(+), 21 deletions(-) create mode 100644 tests/test_reboot_health_refresh.cjs create mode 100644 tests/test_reboot_health_refresh.py diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index 14f3370a..d5e0d7a1 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -18,6 +18,7 @@ PYTHON_FILES = ( "tests/test_host_update_shell.py", "tests/test_host_update_auth.py", "tests/test_kernel_reboot.py", + "tests/test_reboot_health_refresh.py", "tests/test_lxc_update_jobs.py", "tests/test_post_install_i18n.py", "tests/test_remaining_cli_i18n.py", @@ -40,6 +41,7 @@ PYTHON_FILES = ( ) NODE_FILES = ( "tests/test_host_update_ui.cjs", + "tests/test_reboot_health_refresh.cjs", "tests/test_lxc_update_jobs.cjs", "tests/test_api_import_boundaries.cjs", "tests/test_api_errors.cjs", diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index c6975300..a355b252 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -2,7 +2,7 @@ import type React from "react" -import { useState, useEffect, useCallback } from "react" +import { useState, useEffect, useCallback, useRef } from "react" import { getAuthToken } from "@/lib/api-config" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Badge } from "@/components/ui/badge" @@ -132,9 +132,16 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu const [showUpdateTerminal, setShowUpdateTerminal] = useState(false) const [hostJob, setHostJob] = useState<{ runId: string; resume: boolean; kind: "host" } | null>(null) const [hostJobs, setHostJobs] = useState([]) + const healthRequest = useRef(null) + const previousAutoExpanded = useRef>(new Set()) - const fetchHealthDetails = useCallback(async (force = false) => { - setLoading(true) + const fetchHealthDetails = useCallback(async (force = false, cachedOnly = false) => { + if (cachedOnly && healthRequest.current) return + healthRequest.current?.abort() + const controller = new AbortController() + healthRequest.current = controller + const timeout = cachedOnly ? setTimeout(() => controller.abort(), 10000) : undefined + if (!cachedOnly) setLoading(true) setError(null) try { @@ -147,14 +154,19 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu authHeaders["Authorization"] = `Bearer ${token}` } - const response = await fetch(getApiUrl(force ? "/api/health/full?refresh=1" : "/api/health/full"), { headers: authHeaders }) + const path = cachedOnly ? "/api/health/full?cached=1" : force ? "/api/health/full?refresh=1" : "/api/health/full" + const response = await fetch(getApiUrl(path), { headers: authHeaders, signal: controller.signal }) + if (healthRequest.current !== controller) return let infoCount = 0 if (!response.ok) { + // Polling cannot fall back to a legacy endpoint that runs a full audit. + if (cachedOnly) throw new Error(t("healthStatus.errors.fetchFailed")) // Fallback to legacy endpoint - const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders }) + const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders, signal: controller.signal }) if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed")) const data = await legacyResponse.json() + if (healthRequest.current !== controller) return setHealthData(data) setDismissedItems([]) setCustomSuppressions([]) @@ -171,6 +183,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu } } else { const fullData: FullHealthData = await response.json() + if (healthRequest.current !== controller) return + if (!fullData?.health?.details) throw new Error(t("healthStatus.errors.fetchFailed")) setHealthData(fullData.health) setDismissedItems(fullData.dismissed || []) setCustomSuppressions(fullData.custom_suppressions || []) @@ -210,9 +224,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu }) window.dispatchEvent(event) } catch (err) { - setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown")) + if (healthRequest.current === controller) { + setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown")) + } } finally { - setLoading(false) + if (timeout !== undefined) clearTimeout(timeout) + if (healthRequest.current === controller) { + healthRequest.current = null + setLoading(false) + } } }, [getApiUrl, t]) @@ -228,9 +248,21 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu useEffect(() => { if (open) { fetchHealthDetails() - // Auto-refresh every 5 minutes while modal is open - const refreshInterval = setInterval(() => fetchHealthDetails(), 300000) - return () => clearInterval(refreshInterval) + // Recheck reboot evidence promptly without running APT/full health checks. + const refresh = () => { + if (document.visibilityState === "visible") void fetchHealthDetails(false, true) + } + const refreshInterval = setInterval(refresh, 30000) + window.addEventListener("focus", refresh) + document.addEventListener("visibilitychange", refresh) + return () => { + clearInterval(refreshInterval) + window.removeEventListener("focus", refresh) + document.removeEventListener("visibilitychange", refresh) + const controller = healthRequest.current + healthRequest.current = null + controller?.abort() + } } }, [open, fetchHealthDetails]) @@ -248,7 +280,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu nonOkCategories.add(key) } }) - setExpandedCategories(nonOkCategories) + const newlyDegraded = [...nonOkCategories].filter(key => !previousAutoExpanded.current.has(key)) + previousAutoExpanded.current = nonOkCategories + if (newlyDegraded.length) { + setExpandedCategories(previous => new Set([...previous, ...newlyDegraded])) + } } }, [healthData]) diff --git a/AppImage/components/proxmox-dashboard.tsx b/AppImage/components/proxmox-dashboard.tsx index ef7dca2f..d7ec7547 100644 --- a/AppImage/components/proxmox-dashboard.tsx +++ b/AppImage/components/proxmox-dashboard.tsx @@ -1,6 +1,6 @@ "use client" -import React, { useState, useEffect, useMemo, useCallback } from "react" +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react" import useSWR from "swr" import { Badge } from "./ui/badge" import { Button } from "./ui/button" @@ -185,10 +185,18 @@ function MonitorDashboard({ product }: { product: Product }) { } }, []) + const healthRequest = useRef(null) // Fetch health info count independently (for initial load and refresh) - const fetchHealthInfoCount = useCallback(async () => { + const fetchHealthInfoCount = useCallback(async (cachedOnly = false) => { + if (cachedOnly && healthRequest.current) return + healthRequest.current?.abort() + const controller = new AbortController() + healthRequest.current = controller + const timeout = cachedOnly ? setTimeout(() => controller.abort(), 10000) : undefined try { - const response = await fetchApi("/api/health/full") + const response = await fetchApi(cachedOnly ? "/api/health/full?cached=1" : "/api/health/full", { signal: controller.signal }) + if (healthRequest.current !== controller) return + if (!response?.health?.details) throw new Error("Invalid health snapshot") let calculatedInfoCount = 0 if (response && response.health?.details) { @@ -218,7 +226,10 @@ function MonitorDashboard({ product }: { product: Product }) { setInfoCount(calculatedInfoCount) } catch (error) { - // Silently fail - infoCount will remain at 0 + // Preserve the last known count; a failed poll is not a healthy result. + } finally { + if (timeout !== undefined) clearTimeout(timeout) + if (healthRequest.current === controller) healthRequest.current = null } }, []) @@ -269,20 +280,29 @@ function MonitorDashboard({ product }: { product: Product }) { fetchHealthInfoCount() fetchUpdateStatus() - // Overview polls health every 30 s; the other tabs every 60 s. + // Header reboot information stays current on every tab, without full audits. + const refreshHealth = () => { + if (document.visibilityState === "visible") void fetchHealthInfoCount(true) + } let interval: ReturnType | null = null let healthInterval: ReturnType | null = null if (activeTab === "overview") { interval = setInterval(fetchSystemData, 30000) // 30 segundos - healthInterval = setInterval(fetchHealthInfoCount, 30000) // Also refresh info count } else { interval = setInterval(fetchSystemData, 60000) // 60 segundos - healthInterval = setInterval(fetchHealthInfoCount, 60000) // Also refresh info count } + healthInterval = setInterval(refreshHealth, 30000) + window.addEventListener("focus", refreshHealth) + document.addEventListener("visibilitychange", refreshHealth) return () => { if (interval) clearInterval(interval) if (healthInterval) clearInterval(healthInterval) + window.removeEventListener("focus", refreshHealth) + document.removeEventListener("visibilitychange", refreshHealth) + const controller = healthRequest.current + healthRequest.current = null + controller?.abort() } }, [fetchSystemData, fetchHealthInfoCount, fetchUpdateStatus, activeTab]) @@ -391,6 +411,7 @@ function MonitorDashboard({ product }: { product: Product }) { const refreshData = async () => { setIsRefreshing(true) await fetchSystemData() + await fetchHealthInfoCount() setComponentKey((prev) => prev + 1) await new Promise((resolve) => setTimeout(resolve, 500)) setIsRefreshing(false) diff --git a/AppImage/scripts/flask_health_routes.py b/AppImage/scripts/flask_health_routes.py index 7cad09f7..13f96105 100644 --- a/AppImage/scripts/flask_health_routes.py +++ b/AppImage/scripts/flask_health_routes.py @@ -243,10 +243,14 @@ def get_full_health(): security before returning, so an event that just changed underlying state (Update Now finished, dismiss action) sees the new value immediately instead of waiting for the next polling tick. + ?cached=1 is a strictly read-only poll: no full checks or cache clearing, + even with refresh=1. Unavailable/stale snapshots return 503. Every valid + response refreshes reboot evidence, while keeping the full audit's date. """ import time as _time try: - if request.args.get('refresh') == '1': + cached_only = request.args.get('cached') == '1' + if not cached_only and request.args.get('refresh') == '1': for ck in ('updates_check', 'pve_services', 'security_check', '_bg_detailed', '_bg_overall', 'overall_health'): health_monitor.last_check_times.pop(ck, None) @@ -255,14 +259,24 @@ def get_full_health(): # Try to use the background-cached detailed result for instant response bg_key = '_bg_detailed' bg_last = health_monitor.last_check_times.get(bg_key, 0) - bg_age = _time.time() - bg_last + bg_age = _time.time() - bg_last if type(bg_last) in (int, float) else float('inf') - if bg_age < 360 and bg_key in health_monitor.cached_results: + if 0 <= bg_age < 360 and bg_key in health_monitor.cached_results: # Use cached result (at most ~5 min old) details = health_monitor.cached_results[bg_key] + elif cached_only: + # Automatic polling must never start APT or a full audit, even + # during startup, collector failure, expiry or a conflicting flag. + return jsonify({'error': 'Health snapshot is not available yet'}), 503 else: # No fresh cache, run live (first load or cache expired) details = health_monitor.get_detailed_status() + + # Installed/running kernel state changes outside Monitor jobs too. + # Overlay fresh read-only evidence without refreshing other checks or + # rewriting the background collector's snapshot/timestamp. + from kernel_reboot import refresh_health_reboot + details = refresh_health_reboot(details) active_errors = health_persistence.get_active_errors() dismissed = health_persistence.get_dismissed_errors() diff --git a/AppImage/scripts/kernel_reboot.py b/AppImage/scripts/kernel_reboot.py index 793fb497..2d2de9eb 100644 --- a/AppImage/scripts/kernel_reboot.py +++ b/AppImage/scripts/kernel_reboot.py @@ -1,5 +1,6 @@ """Read-only reboot evidence, independent of APT pending-update caches.""" import os +import copy from pathlib import Path import re import subprocess @@ -63,6 +64,13 @@ def with_reboot_status(updates, reboot): return updates result = dict(updates or dict(status='UNKNOWN', count=None, checks={})) result['checks'] = dict(result.get('checks') or {}) + # Keep the package result separate so a later reboot-only overlay can + # clear its own INFO/UNKNOWN without hiding a package-check failure. + base_status = result.get('package_status', result.get('status', 'UNKNOWN')) + if result.get('all_dismissed'): + base_status = 'OK' + result['package_status'] = base_status + result['status'] = base_status required = reboot['state'] == 'required' unknown = reboot['state'] == 'unknown' result['reboot_required'] = True if required else None if unknown else False @@ -77,3 +85,32 @@ def with_reboot_status(updates, reboot): if result.get('status') == 'OK' and (required or unknown): result['status'] = 'INFO' if required else 'UNKNOWN' return result + + +def refresh_health_reboot(snapshot): + """Refresh only reboot evidence on an immutable, dated health snapshot. + + Never runs the full health check, APT, persistence writes or lifecycle + commands. Other categories and the audit timestamp retain their age. + """ + result = copy.deepcopy(snapshot) + details = result.get('details') + if not isinstance(details, dict) or not isinstance(details.get('updates'), dict): + raise ValueError('Health snapshot has no updates result') + details['updates'] = with_reboot_status(details['updates'], reboot_status()) + if details['updates']['status'] == snapshot['details']['updates']['status']: + return result + issues = {status: [] for status in ('CRITICAL', 'WARNING', 'UNKNOWN', 'INFO')} + for key, block in details.items(): + if not isinstance(block, dict): + continue + status = block.get('status', 'UNKNOWN') + if status in issues: + label = key.replace('_', ' ').capitalize() + reason = block.get('reason') + issues[status].append(f'{label}: {reason}' if reason else label) + result['overall'] = ('CRITICAL' if issues['CRITICAL'] else 'WARNING' + if issues['WARNING'] or issues['UNKNOWN'] else 'OK') + primary = next((values for values in issues.values() if values), []) + result['summary'] = '; '.join(primary) if primary else 'All systems operational' + return result diff --git a/tests/test_reboot_health_refresh.cjs b/tests/test_reboot_health_refresh.cjs new file mode 100644 index 00000000..318ef654 --- /dev/null +++ b/tests/test_reboot_health_refresh.cjs @@ -0,0 +1,130 @@ +// Execute actual TS callbacks/effects with controlled transport and clocks. +const assert = require('node:assert/strict') +const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm') +const app = path.resolve(__dirname, '../AppImage') +const ts = require(path.join(app, 'node_modules/typescript')) +function source(file, name, effectText) { + const tree = ts.createSourceFile(file, fs.readFileSync(path.join(app, 'components', file), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) + let callback, effect + function walk(n) { + if (ts.isVariableDeclaration(n) && n.name.getText(tree) === name) callback = n.initializer.arguments[0].getText(tree) + if (ts.isCallExpression(n) && n.expression.getText(tree) === 'useEffect' && n.arguments[0].getText(tree).includes(effectText)) effect = n.arguments[0].getText(tree) + ts.forEachChild(n, walk) + } + walk(tree) + assert.ok(callback && effect) + return {callback, effect} +} +const modal = source('health-status-modal.tsx', 'fetchHealthDetails', 'Recheck reboot evidence') +const header = source('proxmox-dashboard.tsx', 'fetchHealthInfoCount', 'Header reboot information') +const compile = (code, scope) => vm.runInNewContext(ts.transpileModule('('+code+')', {compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText, scope) +function environment() { + const calls = [], data = [], errors = [], counts = [], events = [], timers = new Map(), listeners = new Map() + let timerId = 0 + const categories = [{key:'updates', category:'updates'}] + const scope = {AbortController, CustomEvent:class {constructor(type, values){this.type=type;this.detail=values.detail}}, + healthRequest:{current:null}, getAuthToken:()=>null, getApiUrl:p=>p, t:s=>s, + CATEGORIES:categories, HEALTH_CATEGORY_KEYS:categories, + setHealthData:v=>data.push(v), setDismissedItems(){}, setCustomSuppressions(){}, + setLoading(){}, setError:v=>errors.push(v), setInfoCount:v=>counts.push(v), + setTimeout(fn, delay){timers.set(++timerId,{fn,delay});return timerId}, clearTimeout:id=>timers.delete(id), + window:{dispatchEvent:e=>events.push(e)}, + fetch:async(p, opts)=>{calls.push({p,opts});return {ok:true,json:async()=>payload('INFO')}}, + fetchApi:async(p, opts)=>{calls.push({p,opts});return payload('INFO')}} + return {scope,calls,data,errors,counts,events,timers,listeners} +} +function payload(status='OK') {return {health:{overall:'OK',timestamp:'original',details:{updates:{status,count:0}}},dismissed:[],custom_suppressions:[]}} +async function callbacks() { + for (const [item, args] of [[modal,[false,true]],[header,[true]]]) { + const env=environment(), run=compile(item.callback,env.scope) + await run(...args) + assert.equal(env.calls[0].p,'/api/health/full?cached=1') + assert.equal(env.scope.healthRequest.current,null) + assert.equal(env.timers.size,0) + assert.deepEqual(item===modal ? env.events[0].detail.infoCount : env.counts[0],1) + } + const env=environment(), run=compile(modal.callback,env.scope) + env.scope.fetch=async(p)=>{env.calls.push({p});return {ok:false}} + await run(false,true) + assert.deepEqual(env.calls.map(x=>x.p),['/api/health/full?cached=1']) + assert.equal(env.data.length,0) + assert.ok(env.errors.includes('healthStatus.errors.fetchFailed')) + const malformed=environment() + malformed.scope.fetch=async()=>({ok:true,json:async()=>({})}) + await compile(modal.callback,malformed.scope)(false,true) + assert.equal(malformed.data.length,0) + assert.ok(malformed.errors.includes('healthStatus.errors.fetchFailed')) + const h=environment() + h.scope.fetchApi=async()=>({}) + await compile(header.callback,h.scope)(true) + assert.equal(h.counts.length,0,'malformed data must not replace the previous count with zero') +} +async function races() { + for (const [item, args, forceArgs] of [[modal,[false,true],[true]],[header,[true],[]]]) { + const e=environment(), pending=[] + if(item===modal) e.scope.fetch=(p,opts)=>{e.calls.push({p,opts});return new Promise(resolve=>pending.push(resolve))} + else e.scope.fetchApi=(p,opts)=>{e.calls.push({p,opts});return new Promise(resolve=>pending.push(resolve))} + const run=compile(item.callback,e.scope), first=run(...args) + await run(...args) + assert.equal(e.calls.length,1,'overlapping polls are coalesced') + const second=run(...forceArgs) + assert.ok(e.calls[0].opts.signal.aborted,'explicit refresh supersedes old poll') + pending[1](item===modal ? {ok:true,json:async()=>payload('INFO')} : payload('INFO')) + await second + pending[0](item===modal ? {ok:true,json:async()=>payload('OK')} : payload('OK')) + await first + assert.equal(item===modal ? e.data.length : e.counts.length,1,'late response cannot overwrite new data') + assert.equal(item===modal ? e.events[0].detail.infoCount : e.counts[0],1) + assert.equal(e.timers.size,0) + } +} +function effects(item, activeTab='overview') { + const e=environment(), intervals=new Map(), listeners=new Map(), polls=[] + let timerId=0 + e.scope.open=true; e.scope.activeTab=activeTab + e.scope.fetchHealthDetails=(...args)=>polls.push(args) + e.scope.fetchHealthInfoCount=(...args)=>polls.push(args) + e.scope.fetchSystemData=()=>{}; e.scope.fetchUpdateStatus=()=>{} + e.scope.setInterval=(fn,delay)=>{intervals.set(++timerId,{fn,delay});return timerId} + e.scope.clearInterval=id=>intervals.delete(id) + const target = name=>({addEventListener:(type,fn)=>listeners.set(name+type,fn),removeEventListener:(type,fn)=>{assert.equal(listeners.get(name+type),fn);listeners.delete(name+type)}}) + Object.assign(e.scope.window,target('window')) + e.scope.document={visibilityState:'visible',...target('document')} + const cleanup=compile(item.effect,e.scope)() + const interval=[...intervals.values()].find(x=>x.delay===30000 && x.fn.toString().includes('fetchHealth')) + assert.ok(interval,'every tab polls health at 30 seconds') + assert.equal(polls.length,1,'initial full request retained') + interval.fn(); listeners.get('windowfocus')(); listeners.get('documentvisibilitychange')() + assert.equal(polls.length,4) + for(const args of polls.slice(1))assert.deepEqual(args,item===modal?[false,true]:[true]) + e.scope.document.visibilityState='hidden' + interval.fn();listeners.get('documentvisibilitychange')() + assert.equal(polls.length,4,'hidden document does not poll') + const controller=new AbortController();e.scope.healthRequest.current=controller + cleanup() + assert.ok(controller.signal.aborted) + assert.equal(e.scope.healthRequest.current,null) + assert.equal(intervals.size,0) + assert.equal(listeners.size,0) +} +function expansion() { + const text=fs.readFileSync(path.join(app,'components/health-status-modal.tsx'),'utf8') + const tree=ts.createSourceFile('modal.tsx',text,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX) + let code + function walk(n) { + if(ts.isCallExpression(n)&&n.expression.getText(tree)==='useEffect'&&n.arguments[0].getText(tree).includes('newlyDegraded'))code=n.arguments[0].getText(tree) + ts.forEachChild(n,walk) + } + walk(tree);assert.ok(code) + let expanded=new Set(['updates']) + const scope={Set,CATEGORIES:[{key:'updates'},{key:'cpu'}],previousAutoExpanded:{current:new Set()}, + healthData:{details:{updates:{status:'INFO'},cpu:{status:'OK'}}},setExpandedCategories:fn=>{expanded=fn(expanded)}} + const effect=compile(code,scope) + effect();assert.ok(expanded.has('updates'),'poll must preserve manually opened updates') + scope.healthData.details.cpu.status='WARNING';effect() + assert.ok(expanded.has('cpu'),'new warnings may expand') + expanded.delete('cpu');effect() + assert.ok(!expanded.has('cpu'),'poll must preserve user collapsing a known warning') +} +async function main() {await callbacks();await races();effects(modal);effects(header);effects(header,'settings');expansion();console.log('PASS actual health callbacks: polling, focus, errors, no fallback, races, expansion and cleanup')} +main().catch(e=>{console.error(e);process.exitCode=1}) diff --git a/tests/test_reboot_health_refresh.py b/tests/test_reboot_health_refresh.py new file mode 100644 index 00000000..4a52392f --- /dev/null +++ b/tests/test_reboot_health_refresh.py @@ -0,0 +1,173 @@ +"""Actual full-health route with inert collaborators; no APT or host actions.""" +import ast +import copy +from pathlib import Path +import subprocess +import sys +import time +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + +from flask import Flask, jsonify, request + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'AppImage/scripts')) +import kernel_reboot as kernel + + +def snapshot(): + return dict(overall='OK', summary='All systems operational', timestamp='original', details={ + 'cpu': dict(status='OK', checks={}), + 'updates': dict(status='OK', count=0, checks={'pending_updates': dict(status='OK')})}) + + +class FullHealth(unittest.TestCase): + def setUp(self): + self.app = Flask(__name__) + self.now = time.time() + self.cached = snapshot() + self.owner = SimpleNamespace(cached_results={'_bg_detailed': self.cached}, + last_check_times={'_bg_detailed': self.now}, + get_detailed_status=Mock(return_value=snapshot())) + persistence = SimpleNamespace(get_active_errors=Mock(return_value=[]), + get_dismissed_errors=Mock(return_value=[]), + get_custom_suppressions=Mock(return_value=[])) + node = next(n for n in ast.parse((ROOT/'AppImage/scripts/flask_health_routes.py').read_text()).body + if isinstance(n, ast.FunctionDef) and n.name == 'get_full_health') + node.decorator_list = [] + self.scope = dict(health_monitor=self.owner, health_persistence=persistence, + request=request, jsonify=jsonify) + exec(compile(ast.Module(body=[node], type_ignores=[]), 'actual-health-route', 'exec'), self.scope) + self.uname = patch.object(kernel.os, 'uname', return_value=SimpleNamespace(release='7.0.14-25-pve')) + self.marker = patch.object(Path, 'exists', return_value=False) + self.uname.start(); self.marker.start() + self.addCleanup(self.uname.stop); self.addCleanup(self.marker.stop) + def command(args, **kwargs): + self.assertEqual(args, ['dpkg-query', '-W', '-f=${db:Status-Status}\t${binary:Package}\n']) + return subprocess.CompletedProcess(args, 0, 'installed\tproxmox-kernel-7.0.14-26-pve-signed\n', '') + self.probe = patch.object(kernel.subprocess, 'run', side_effect=command).start() + self.addCleanup(patch.stopall) + + def call(self, query=''): + with self.app.test_request_context('/api/health/full'+query), patch('time.time', return_value=self.now): + response = self.scope['get_full_health']() + if isinstance(response, tuple): + return response[0].get_json(), response[1] + return response.get_json(), response.status_code + + def test_cached_full_response_detects_external_update_without_full_check(self): + before = copy.deepcopy(self.cached) + result, code = self.call() + self.assertEqual(code, 200) + self.assertTrue(result['health']['details']['updates']['reboot_required']) + self.assertEqual(result['health']['details']['updates']['count'], 0) + self.assertEqual(result['health']['timestamp'], 'original') + self.assertEqual(self.cached, before) + self.owner.get_detailed_status.assert_not_called() + self.assertEqual(self.probe.call_count, 1) + + def test_cached_only_miss_expiry_and_future_never_run_full_check_or_clear_cache(self): + for age in (360, 360.001, -1): + for query in ('?cached=1', '?cached=1&refresh=1'): + with self.subTest(age=age, query=query): + self.owner.last_check_times['_bg_detailed'] = self.now-age + before = copy.deepcopy(self.owner.cached_results) + result, code = self.call(query) + self.assertEqual(code, 503) + self.assertIn('error', result) + self.assertEqual(self.owner.cached_results, before) + self.owner.cached_results.clear() + self.assertEqual(self.call('?cached=1')[1], 503) + self.owner.get_detailed_status.assert_not_called() + self.probe.assert_not_called() + + def test_cached_only_just_before_boundary_is_fresh_reboot_without_full_check(self): + self.owner.last_check_times['_bg_detailed'] = self.now-359.999 + result, code = self.call('?cached=1') + self.assertEqual(code, 200) + self.assertTrue(result['health']['details']['updates']['reboot_required']) + self.owner.get_detailed_status.assert_not_called() + + def test_invalid_timestamp_and_incomplete_snapshot_never_start_full_check(self): + for value in (None, 'bad', float('nan'), float('inf')): + self.owner.last_check_times['_bg_detailed'] = value + self.assertEqual(self.call('?cached=1')[1], 503) + self.owner.last_check_times['_bg_detailed'] = self.now + self.owner.cached_results['_bg_detailed'] = {'details': {}} + self.assertEqual(self.call('?cached=1')[1], 500) + self.owner.get_detailed_status.assert_not_called() + self.probe.assert_not_called() + + def test_probe_failure_is_unknown_not_green_and_retains_other_categories(self): + self.cached['details']['cpu']['status'] = 'CRITICAL' + self.cached['overall'] = 'CRITICAL' + with patch.object(kernel.subprocess, 'run', side_effect=OSError('fixture-read-error')): + result, code = self.call('?cached=1') + self.assertEqual(code, 200) + health = result['health'] + self.assertIsNone(health['details']['updates']['reboot_required']) + self.assertEqual(health['details']['updates']['status'], 'UNKNOWN') + self.assertEqual(health['overall'], 'CRITICAL') + self.assertEqual(health['details']['cpu'], self.cached['details']['cpu']) + + def test_marker_zero_package_probes_and_no_process_or_shell_actions(self): + with patch.object(Path, 'exists', return_value=True), \ + patch.object(kernel.subprocess, 'Popen') as spawn, patch.object(kernel.os, 'system') as shell: + self.assertTrue(self.call('?cached=1')[0]['health']['details']['updates']['reboot_required']) + spawn.assert_not_called() + shell.assert_not_called() + self.probe.assert_not_called() + + def test_explicit_refresh_retains_original_full_check_contract(self): + result, code = self.call('?refresh=1') + self.assertEqual(code, 200) + self.owner.get_detailed_status.assert_called_once() + + +class Overlay(unittest.TestCase): + def test_repeated_required_unknown_current_preserves_package_result_and_snapshot(self): + states = [dict(state=s, running_kernel='old', installed_kernel='new' if s=='required' else None) + for s in ('required', 'unknown', 'current')] + for package in ('OK', 'INFO', 'WARNING', 'CRITICAL', 'UNKNOWN'): + value = snapshot() + value['details']['updates']['status'] = package + before = copy.deepcopy(value) + for state in states: + previous = copy.deepcopy(value) + with patch.object(kernel, 'reboot_status', return_value=state): + refreshed = kernel.refresh_health_reboot(value) + self.assertEqual(value, previous) + value = refreshed + block = value['details']['updates'] + expected = ('INFO' if state['state']=='required' else 'UNKNOWN' if state['state']=='unknown' else 'OK') if package=='OK' else package + self.assertEqual(block['status'], expected) + self.assertEqual(block['package_status'], package) + self.assertEqual(value['timestamp'], 'original') + self.assertEqual(value['details']['cpu'], before['details']['cpu']) + + def test_dismissed_package_warning_stays_dismissed_but_reboot_remains_visible(self): + value = snapshot() + value['details']['updates'].update(status='OK', package_status='WARNING', all_dismissed=True) + state = dict(state='required', running_kernel='old', installed_kernel='new') + with patch.object(kernel, 'reboot_status', return_value=state): + result = kernel.refresh_health_reboot(value) + self.assertEqual(result['details']['updates']['status'], 'INFO') + self.assertTrue(result['details']['updates']['all_dismissed']) + self.assertEqual(value['details']['updates']['status'], 'OK') + + def test_unknown_recovery_recomputes_overall_without_erasing_unrelated_warning(self): + value = snapshot() + with patch.object(kernel, 'reboot_status', return_value=dict(state='unknown', running_kernel='old')): + value = kernel.refresh_health_reboot(value) + self.assertEqual(value['overall'], 'WARNING') + with patch.object(kernel, 'reboot_status', return_value=dict(state='current', running_kernel='new')): + recovered = kernel.refresh_health_reboot(value) + value['details']['cpu']['status'] = 'WARNING' + preserved = kernel.refresh_health_reboot(value) + self.assertEqual(recovered['overall'], 'OK') + self.assertEqual(preserved['overall'], 'WARNING') + + +if __name__ == '__main__': + unittest.main(verbosity=2) From 503a946d85c64a14e8ef2967acaab2a47835a8be Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:36:11 +0000 Subject: [PATCH 03/13] fix: retain translated reboot summary in cached health responses --- AppImage/scripts/kernel_reboot.py | 7 ++++++- tests/test_reboot_health_refresh.py | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/AppImage/scripts/kernel_reboot.py b/AppImage/scripts/kernel_reboot.py index 2d2de9eb..7998ba37 100644 --- a/AppImage/scripts/kernel_reboot.py +++ b/AppImage/scripts/kernel_reboot.py @@ -108,7 +108,12 @@ def refresh_health_reboot(snapshot): if status in issues: label = key.replace('_', ' ').capitalize() reason = block.get('reason') - issues[status].append(f'{label}: {reason}' if reason else label) + if key == 'updates' and not reason: + # Reuse the existing localized reboot detail contract rather + # than introducing an untranslated, context-free "Updates". + issues[status].append(block['checks']['reboot_required']['detail']) + else: + issues[status].append(f'{label}: {reason}' if reason else label) result['overall'] = ('CRITICAL' if issues['CRITICAL'] else 'WARNING' if issues['WARNING'] or issues['UNKNOWN'] else 'OK') primary = next((values for values in issues.values() if values), []) diff --git a/tests/test_reboot_health_refresh.py b/tests/test_reboot_health_refresh.py index 4a52392f..2a046066 100644 --- a/tests/test_reboot_health_refresh.py +++ b/tests/test_reboot_health_refresh.py @@ -63,6 +63,7 @@ class FullHealth(unittest.TestCase): self.assertTrue(result['health']['details']['updates']['reboot_required']) self.assertEqual(result['health']['details']['updates']['count'], 0) self.assertEqual(result['health']['timestamp'], 'original') + self.assertEqual(result['health']['summary'], 'Reboot required: running 7.0.14-25-pve; installed 7.0.14-26-pve') self.assertEqual(self.cached, before) self.owner.get_detailed_status.assert_not_called() self.assertEqual(self.probe.call_count, 1) From 52cd21dcb1b77c15cbaea9bfdf25e3c537696413 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:38:33 +0000 Subject: [PATCH 04/13] feat: add guarded host restart and read-only ProxMenux version status --- .github/scripts/run_offline_qualified.py | 2 + AppImage/components/health-status-modal.tsx | 8 + AppImage/components/host-update-status.tsx | 157 ++++++++++++++ AppImage/messages/en/common.json | 24 +++ AppImage/messages/sk/common.json | 24 +++ AppImage/scripts/flask_health_routes.py | 49 ++++- AppImage/scripts/flask_server.py | 3 + AppImage/scripts/host_update_controls.py | 57 +++++ AppImage/scripts/proxmenux_version_status.py | 56 +++++ tests/test_host_update_controls.cjs | 44 ++++ tests/test_host_update_controls.py | 208 +++++++++++++++++++ 11 files changed, 631 insertions(+), 1 deletion(-) create mode 100644 AppImage/components/host-update-status.tsx create mode 100644 AppImage/scripts/host_update_controls.py create mode 100644 AppImage/scripts/proxmenux_version_status.py create mode 100644 tests/test_host_update_controls.cjs create mode 100644 tests/test_host_update_controls.py diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index d5e0d7a1..48173d59 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -19,6 +19,7 @@ PYTHON_FILES = ( "tests/test_host_update_auth.py", "tests/test_kernel_reboot.py", "tests/test_reboot_health_refresh.py", + "tests/test_host_update_controls.py", "tests/test_lxc_update_jobs.py", "tests/test_post_install_i18n.py", "tests/test_remaining_cli_i18n.py", @@ -42,6 +43,7 @@ PYTHON_FILES = ( NODE_FILES = ( "tests/test_host_update_ui.cjs", "tests/test_reboot_health_refresh.cjs", + "tests/test_host_update_controls.cjs", "tests/test_lxc_update_jobs.cjs", "tests/test_api_import_boundaries.cjs", "tests/test_api_errors.cjs", diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index a355b252..f986e903 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -42,6 +42,7 @@ import { } from "lucide-react" import { ScriptTerminalModal } from "./script-terminal-modal" import { HostUpdateJobs, type HostUpdateJob } from "./host-update-jobs" +import { RestartAfterUpdate, ProxMenuxVersionCard } from "./host-update-status" import { useT } from "@/lib/i18n/provider" import { useIsPbs } from "@/lib/use-platform" @@ -370,6 +371,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu "UP": t("healthStatus.details.up"), "Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"), "No reboot required": t("healthStatus.hostUpdate.noReboot"), + "No pending updates": t("healthStatus.details.noPendingUpdates"), "Reboot required by the operating system": t("healthStatus.hostUpdate.rebootMarker"), "Could not determine whether a reboot is required": t("healthStatus.hostUpdate.rebootUnknown"), "Proxmox VE is up to date": t("healthStatus.details.proxmoxUpToDate"), @@ -689,6 +691,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu {Object.entries(checks) .filter(([, checkData]) => checkData.installed !== false) .map(([checkKey, checkData]) => { + if (categoryKey === "updates" && checkKey === "reboot_required" && checkData.status === "INFO") { + return job.state !== "complete")} label={formatCheckLabel(checkKey)} + detail={translateHealthText(checkData.detail)} icon={getStatusIcon(checkData.status, "sm")} /> + } const isDismissable = checkData.dismissable === true const checkStatus = checkData.status?.toUpperCase() || "OK" @@ -896,6 +903,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu pending prevents the operator from spawning a terminal that would only report "System is already up to date". */} + {key === "updates" && } {key === "updates" && Number(categoryData?.count ?? 0) > 0 && (
+
+ {disabled &&

{t("healthStatus.restart.blocked")}

} + {accepted &&

{t("healthStatus.restart.accepted")}

} + {error && !open &&

{t("healthStatus.restart.unconfirmed")}

} + + + + {t("healthStatus.restart.title", { server: context?.hostname ?? "…" })} + {t("healthStatus.restart.impact")} + {(disabled || context?.reason === "busy") &&

{t("healthStatus.restart.blocked")}

} + {context?.reason === "current" &&

{t("healthStatus.hostUpdate.noReboot")}

} + {(error || context?.reason === "unknown") &&

{t("healthStatus.restart.unconfirmed")}

} +
+ + +
+
+
+ +} + +type Versions = { installed: string | null; available: string | null; state: "available" | "current" | "unknown"; checked_at: string | null; channel: "stable" | "beta" } +export function ProxMenuxVersionCard({ getApiUrl }: { getApiUrl: Url }) { + const t = useT() + const [value, setValue] = useState(null) + useEffect(() => { + let disposed = false, reading = false + let active: AbortController | undefined + const read = async () => { + if (reading || document.visibilityState !== "visible") return + reading = true + const controller = new AbortController(); active = controller + const timeout = setTimeout(() => controller.abort(), 6000) + try { + const response = await fetch(getApiUrl("/api/health/proxmenux-version"), { headers: headers(), signal: controller.signal }) + if (!response.ok) throw Error("unavailable") + const data: Versions = await response.json() + if (!["available", "current", "unknown"].includes(data?.state)) throw Error("invalid") + const validVersion = (version: unknown) => typeof version === "string" && /^\d+(?:\.\d+){1,5}$/.test(version) + if (!["stable", "beta"].includes(data.channel) || + (data.installed !== null && !validVersion(data.installed)) || + (data.available !== null && !validVersion(data.available)) || + (data.state !== "unknown" && (!validVersion(data.installed) || !validVersion(data.available) || !data.checked_at)) || + (data.checked_at !== null && !Number.isFinite(Date.parse(data.checked_at)))) throw Error("invalid") + if (!disposed) setValue(data) + } catch { if (!disposed) setValue(previous => previous ? { ...previous, state: "unknown" } : null) } + finally { clearTimeout(timeout); reading = false } + } + void read() + const interval = setInterval(read, 300000) + window.addEventListener("focus", read); document.addEventListener("visibilitychange", read) + return () => { disposed = true; active?.abort(); clearInterval(interval); window.removeEventListener("focus", read); document.removeEventListener("visibilitychange", read) } + }, [getApiUrl]) + const state = value?.state ?? "unknown" + return
+

{t("healthStatus.versions.title")}

+ {t(`healthStatus.versions.${state}`)}
+
+
{t("healthStatus.versions.installed")}
{value?.installed ?? t("healthStatus.versions.unverified")}
+
{t(state === "unknown" && value?.available ? "healthStatus.versions.lastKnown" : "healthStatus.versions.latest")}
{value?.available ?? t("healthStatus.versions.unverified")}
+
+ {value &&

{t(`healthStatus.versions.${value.channel}`)}

} + {value?.checked_at &&

{t("healthStatus.versions.checked", { time: new Date(value.checked_at).toLocaleString() })}

} +
+} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 06e945c9..d2507a10 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -5309,6 +5309,29 @@ } }, "healthStatus": { + "restart": { + "button": "Restart server", + "title": "Restart server {server}?", + "impact": "Restarting temporarily interrupts the server, virtual machines, containers and their services.", + "cancel": "Cancel", + "confirm": "Confirm restart", + "blocked": "Restart will be available after the host update finishes.", + "accepted": "Restart request accepted. Check that the server returns online.", + "unconfirmed": "The restart request could not be verified. Check the server before trying again." + }, + "versions": { + "title": "ProxMenux update", + "installed": "Installed version", + "latest": "Available version", + "lastKnown": "Last known available version", + "available": "Update available", + "current": "ProxMenux is up to date", + "unknown": "Could not verify the available version", + "unverified": "Unverified", + "stable": "Stable channel", + "beta": "Beta channel", + "checked": "Last successful check: {time}" + }, "hostUpdate": { "running": "The host update continues even if you sign out. Reopen it after signing in.", "finished": "A host update has finished. View its result before starting another.", @@ -5403,6 +5426,7 @@ "runningKernelUpdate": "Kernel update available for running {kernel}", "kernelPveUpdates": "Kernel updates available: {kernelCount}; Proxmox updates available: {pveCount}", "pendingPackageUpdates": "Package updates pending: {count}", + "noPendingUpdates": "No pending updates", "updatesAptError": "Failed to check for updates (apt-get error)", "updatesAptTimeout": "apt-get timed out - repository may be unreachable", "kernelPackagesMore": "{technicalIdentity} (+{count} more)", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 5ad36f6b..3e522ea1 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -5181,6 +5181,29 @@ } }, "healthStatus": { + "restart": { + "button": "Reštartovať server", + "title": "Reštartovať server {server}?", + "impact": "Reštart dočasne preruší dostupnosť servera, virtuálnych strojov, kontajnerov a ich služieb.", + "cancel": "Zrušiť", + "confirm": "Potvrdiť reštart", + "blocked": "Reštart bude dostupný po dokončení aktualizácie servera.", + "accepted": "Požiadavka na reštart bola prijatá. Over, že sa server znova sprístupní.", + "unconfirmed": "Požiadavku na reštart sa nepodarilo overiť. Pred ďalším pokusom skontroluj server." + }, + "versions": { + "title": "Aktualizácia ProxMenuxu", + "installed": "Nainštalovaná verzia", + "latest": "Dostupná verzia", + "lastKnown": "Posledná známa dostupná verzia", + "available": "Dostupná aktualizácia", + "current": "ProxMenux je aktuálny", + "unknown": "Dostupnú verziu sa nepodarilo overiť", + "unverified": "Neoverené", + "stable": "Stabilný kanál", + "beta": "Beta kanál", + "checked": "Posledná úspešná kontrola: {time}" + }, "hostUpdate": { "running": "Aktualizácia servera pokračuje aj po odhlásení. Po prihlásení ju môžeš znova otvoriť.", "finished": "Aktualizácia servera sa skončila. Pred ďalším spustením si pozri jej výsledok.", @@ -5314,6 +5337,7 @@ "runningKernelUpdate": "K dispozícii je aktualizácia jadra pre spustenie {kernel}", "kernelPveUpdates": "Dostupné aktualizácie jadra: {kernelCount}; dostupné aktualizácie Proxmox: {pveCount}", "pendingPackageUpdates": "Čakajúce aktualizácie balíka: {count}", + "noPendingUpdates": "Žiadne čakajúce aktualizácie", "updatesAptError": "Nepodarilo sa skontrolovať aktualizácie (chyba apt-get)", "updatesAptTimeout": "Časový limit apt-get vypršal - úložisko môže byť nedostupné", "kernelPackagesMore": "{technicalIdentity} (+{count} viac)", diff --git a/AppImage/scripts/flask_health_routes.py b/AppImage/scripts/flask_health_routes.py index 13f96105..26f5479a 100644 --- a/AppImage/scripts/flask_health_routes.py +++ b/AppImage/scripts/flask_health_routes.py @@ -2,7 +2,7 @@ Flask routes for health monitoring with persistence support """ -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, request, current_app from health_monitor import health_monitor from health_persistence import health_persistence from jwt_middleware import require_auth, require_admin_scope @@ -17,6 +17,53 @@ except ImportError: health_bp = Blueprint('health', __name__) + +@health_bp.route('/api/health/proxmenux-version', methods=['GET']) +@require_auth +def get_proxmenux_version(): + from proxmenux_version_status import version_status + return jsonify(version_status()) + + +@health_bp.route('/api/health/reboot-context', methods=['GET']) +@require_admin_scope +def get_reboot_context(): + from host_update_controls import reboot_context + return jsonify(reboot_context()) + + +@health_bp.route('/api/health/reboot-after-update', methods=['POST']) +@require_admin_scope +def reboot_after_update(): + import socket + data = request.get_json(silent=True) + if not isinstance(data, dict) or data.get('confirm') is not True or data.get('hostname') != socket.gethostname() or not isinstance(data.get('boot_id'), str) or not data['boot_id']: + return jsonify({'reason': 'confirmation'}), 400 + power = current_app.extensions.get('host_update_power') + if not power: + return jsonify({'reason': 'unavailable'}), 503 + from host_update_controls import reboot_context, COMMAND, UNIT + context = reboot_context() + if context.get('boot_id') != data['boot_id']: + return jsonify({'reason': 'confirmation'}), 409 + if not context['allowed']: + return jsonify({'reason': context['reason']}), 409 + ok, error = power['run'](UNIT, list(COMMAND), description='ProxMenux: reboot after host update', + extra_properties=['RuntimeMaxSec=75']) + if not ok: + return jsonify({'reason': 'unavailable'}), error.get('status', 500) + return jsonify({'accepted': True}), 202 + + +@health_bp.route('/api/health/reboot-after-update/status', methods=['GET']) +@require_admin_scope +def reboot_after_update_status(): + power = current_app.extensions.get('host_update_power') + if not power: + return jsonify({'state': 'unknown'}), 503 + from host_update_controls import UNIT + return jsonify(power['state'](UNIT)) + @health_bp.route('/api/health/status', methods=['GET']) @require_auth def get_health_status(): diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 1f8182cb..0db3c3f6 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -2748,6 +2748,9 @@ def _action_state(unit_name): return result_dict +app.extensions['host_update_power'] = {'run': _action_run, 'state': _action_state} + + def _action_cancel(unit_name): """Stop a running transient action unit. Idempotent — succeeds on units that are not running (or don't exist). diff --git a/AppImage/scripts/host_update_controls.py b/AppImage/scripts/host_update_controls.py new file mode 100644 index 00000000..1c4b7520 --- /dev/null +++ b/AppImage/scripts/host_update_controls.py @@ -0,0 +1,57 @@ +"""Guarded reboot-after-update context; existing power API is unchanged.""" +import fcntl +import os +from pathlib import Path +import socket +import stat + +from kernel_reboot import reboot_status +from host_update_jobs import list_jobs + +LOCK = '/run/proxmenux-host-update.lock' +BOOT_ID = Path('/proc/sys/kernel/random/boot_id') +UNIT = 'proxmenux-action-reboot-after-update' +# The unit obtains the SAME lock as CLI/legacy/web updates. Holding it for +# the shutdown transition closes the precheck-to-launch race. If an updater +# wins that race, flock exits75 and systemctl reboot is never invoked. +COMMAND = ['/usr/bin/flock', '-n', '-E', '75', LOCK, '/bin/sh', '-c', + '/bin/systemctl reboot && /bin/sleep 60'] + + +def reboot_context(): + result = dict(hostname=socket.gethostname(), allowed=False, reason='unknown') + try: + boot_id = BOOT_ID.read_text().strip() + if not boot_id: + return result + result['boot_id'] = boot_id + except OSError: + return result + try: + jobs = list_jobs() + if not isinstance(jobs, list) or any(not isinstance(job, dict) for job in jobs): + return result + except Exception: + return result + if any(job.get('state') != 'complete' for job in jobs): + return dict(result, reason='busy') + try: + fd = os.open(LOCK, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except FileNotFoundError: + fd = None + except OSError: + return result + if fd is not None: + try: + if not stat.S_ISREG(os.fstat(fd).st_mode): + return result + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return dict(result, reason='busy') + except OSError: + return result + finally: + os.close(fd) + reboot = reboot_status() + return dict(result, allowed=reboot['state']=='required', + reason='required' if reboot['state']=='required' else 'current' if reboot['state']=='current' else 'unknown') diff --git a/AppImage/scripts/proxmenux_version_status.py b/AppImage/scripts/proxmenux_version_status.py new file mode 100644 index 00000000..9b5aa093 --- /dev/null +++ b/AppImage/scripts/proxmenux_version_status.py @@ -0,0 +1,56 @@ +"""Read-only version information. No installer or configuration writes.""" +from datetime import datetime, timezone +from pathlib import Path +import re +import threading +import time +import urllib.request + +ROOT = Path('/usr/local/share/proxmenux') +URLS = { + 'stable': 'https://raw.githubusercontent.com/MacRimi/ProxMenux/main/version.txt', + 'beta': 'https://raw.githubusercontent.com/MacRimi/ProxMenux/develop/beta_version.txt', +} +TTL = 1800 +RETRY = 60 +_lock = threading.Lock() +_cache = {} + + +def _version(text): + if not isinstance(text, str) or not re.fullmatch(r'\d{1,8}(?:\.\d{1,8}){1,5}', text): + raise ValueError('Invalid version') + parts = tuple(map(int, text.split('.'))) + return parts + (0,) * (6-len(parts)) + + +def version_status(): + result = dict(state='unknown', installed=None, available=None, checked_at=None, channel='stable') + try: + beta = ROOT/'beta_version.txt' + channel = 'beta' if beta.exists() else 'stable' + result['channel'] = channel + installed = (beta if channel == 'beta' else ROOT/'version.txt').read_text().strip() + _version(installed) + result.update(installed=installed, channel=channel) + except (OSError, ValueError): + return result # No remote probe without a usable installed version. + with _lock: + now = time.monotonic() + cache = _cache.get(channel, {}) + age = now-cache.get('attempt', -float('inf')) + if not 0 <= age < (RETRY if cache.get('failed') else TTL): + try: + req = urllib.request.Request(URLS[channel], headers={'User-Agent': 'ProxMenux-Monitor'}) + with urllib.request.urlopen(req, timeout=3) as response: + latest = response.read(129).decode('utf-8').strip() + _version(latest) + cache = dict(attempt=time.monotonic(), version=latest, failed=False, + checked_at=datetime.now(timezone.utc).isoformat()) + except (OSError, ValueError, UnicodeError): + cache = dict(cache, attempt=time.monotonic(), failed=True) + _cache[channel] = cache + result.update(available=cache.get('version'), checked_at=cache.get('checked_at')) + if cache.get('version') and not cache.get('failed'): + result['state'] = 'available' if _version(cache['version']) > _version(installed) else 'current' + return result diff --git a/tests/test_host_update_controls.cjs b/tests/test_host_update_controls.cjs new file mode 100644 index 00000000..665d377b --- /dev/null +++ b/tests/test_host_update_controls.cjs @@ -0,0 +1,44 @@ +// Execute the actual destructive-action callback using an inert transport. +const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm') +const app=path.resolve(__dirname,'../AppImage'),ts=require(path.join(app,'node_modules/typescript')) +const tree=ts.createSourceFile('controls.tsx',fs.readFileSync(path.join(app,'components/host-update-status.tsx'),'utf8'),ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX) +let callback +function walk(n){if(ts.isVariableDeclaration(n)&&n.name.getText(tree)==='confirm')callback=n.initializer.getText(tree);ts.forEachChild(n,walk)} +walk(tree);assert.ok(callback) +function fixture(){ + const calls=[],events=[] + const scope={AbortController,JSON,Error,disabled:false,context:{allowed:true,hostname:'fixture-host',boot_id:'fixture-boot'},submitted:false, + sending:{current:false},alive:{current:true},postRequest:{current:null},getApiUrl:p=>p,headers:()=>({}), + setSubmitted:v=>events.push(['submitted',v]),setPending:v=>events.push(['pending',v]),setError:v=>events.push(['error',v]), + setAccepted:v=>events.push(['accepted',v]),setOpen:v=>events.push(['open',v]), + setTimeout:()=>1,clearTimeout(){},fetch:async(p,options)=>{calls.push({p,options});return {status:202,json:async()=>({accepted:true})}}} + const run=vm.runInNewContext(ts.transpileModule('('+callback+')',{compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText,scope) + return {scope,calls,events,run} +} +async function main(){ + for(const block of ['disabled','submitted','sending','missing-context','not-allowed']){ + const x=fixture() + if(block==='sending')x.scope.sending.current=true + else if(block==='missing-context')x.scope.context=null + else if(block==='not-allowed')x.scope.context.allowed=false + else x.scope[block]=true + await x.run();assert.equal(x.calls.length,0);assert.equal(x.events.length,0) + } + const x=fixture();await x.run();await x.run() + assert.equal(x.calls.length,1,'duplicate confirm never sends a second request') + assert.equal(x.calls[0].p,'/api/health/reboot-after-update') + assert.equal(x.calls[0].options.method,'POST') + assert.deepEqual(JSON.parse(x.calls[0].options.body),{confirm:true,hostname:'fixture-host',boot_id:'fixture-boot'}) + assert.ok(x.events.some(e=>e[0]==='accepted'&&e[1]===true)) + assert.equal(x.scope.postRequest.current,null) + for(const response of [{status:409},{status:202,json:async()=>({accepted:false})}]){ + const e=fixture();e.scope.fetch=async()=>response;await e.run() + assert.ok(e.events.some(v=>v[0]==='error'&&v[1]===true)) + assert.ok(!e.events.some(v=>v[0]==='accepted')) + } + const e=fixture();e.scope.fetch=async()=>{throw Error('unknown outcome')};await e.run() + assert.ok(!e.events.some(v=>v[0]==='accepted')) + assert.ok(e.scope.sending.current,'unknown outcome is not automatically retried') + console.log('PASS actual confirmation guards, exact payload, duplicate suppression and unknown outcomes') +} +main().catch(e=>{console.error(e);process.exitCode=1}) diff --git a/tests/test_host_update_controls.py b/tests/test_host_update_controls.py new file mode 100644 index 00000000..31ec8f03 --- /dev/null +++ b/tests/test_host_update_controls.py @@ -0,0 +1,208 @@ +"""Actual version/guard helpers and authenticated Flask routes; no real reboot.""" +import fcntl +import importlib.util +import io +import json +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile +from types import ModuleType, SimpleNamespace +import unittest +from unittest.mock import Mock, patch + +from flask import Flask + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT/'AppImage/scripts')) +import host_update_controls as controls +import proxmenux_version_status as versions + + +class VersionStatus(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(); self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.root_patch = patch.object(versions, 'ROOT', self.root); self.root_patch.start(); self.addCleanup(self.root_patch.stop) + versions._cache.clear() + (self.root/'version.txt').write_text('1.2.7\n') + self.now = 1000. + self.clock = patch.object(versions.time, 'monotonic', side_effect=lambda:self.now); self.clock.start(); self.addCleanup(self.clock.stop) + self.remote = Mock(return_value=io.BytesIO(b'1.2.8\n')) + self.network = patch.object(versions.urllib.request, 'urlopen', self.remote); self.network.start(); self.addCleanup(self.network.stop) + + def test_real_contract_stable_and_beta_use_only_fixed_urls(self): + result = versions.version_status() + self.assertEqual((result['state'], result['installed'], result['available']), ('available','1.2.7','1.2.8')) + self.assertEqual(self.remote.call_args.args[0].full_url, versions.URLS['stable']) + (self.root/'beta_version.txt').write_text('1.2.9') + self.remote.return_value = io.BytesIO(b'1.2.10') + result = versions.version_status() + self.assertEqual((result['channel'], result['state'], result['available']), ('beta','available','1.2.10')) + self.assertEqual(self.remote.call_args.args[0].full_url, versions.URLS['beta']) + + def test_fresh_cache_then_ttl_boundary_refetch(self): + versions.version_status() + for age in (0,1799.999): + self.now = 1000+age + self.assertEqual(versions.version_status()['state'],'available') + self.assertEqual(self.remote.call_count,1) + self.now=2800 + self.remote.return_value = io.BytesIO(b'1.2.7') + self.assertEqual(versions.version_status()['state'],'current') + self.assertEqual(self.remote.call_count,2) + self.now=4600.001;self.remote.return_value=io.BytesIO(b'1.2.9') + self.assertEqual(versions.version_status()['state'],'available') + + def test_failed_refresh_keeps_last_known_and_retries_after_sixty_seconds(self): + first=versions.version_status() + self.now=2800;self.remote.side_effect=OSError('fixture timeout') + failed=versions.version_status() + self.assertEqual(failed['state'],'unknown') + self.assertEqual(failed['available'],first['available']) + self.assertEqual(failed['checked_at'],first['checked_at']) + self.now=2859.999;versions.version_status();self.assertEqual(self.remote.call_count,2) + self.now=2860;versions.version_status();self.assertEqual(self.remote.call_count,3) + + def test_missing_or_invalid_installed_never_probes_network(self): + for text in ('', 'junk', '1.2.7\npartial', '1.2.7-rc1'): + (self.root/'version.txt').write_text(text) + self.assertEqual(versions.version_status()['state'],'unknown') + (self.root/'version.txt').unlink() + self.assertEqual(versions.version_status()['state'],'unknown') + self.remote.assert_not_called() + + def test_bad_remote_and_http_failure_never_mean_current_or_write_local_files(self): + before={p.name:p.read_bytes() for p in self.root.iterdir()} + for content in (b'',b'error',b'1.2.8\npartial',b'1.'+b'9'*128,b'\xff'): + versions._cache.clear(); self.remote.return_value=io.BytesIO(content) + self.assertEqual(versions.version_status()['state'],'unknown') + versions._cache.clear(); self.remote.side_effect=TimeoutError('fixture') + self.assertEqual(versions.version_status()['state'],'unknown') + self.assertEqual({p.name:p.read_bytes() for p in self.root.iterdir()},before) + + def test_installed_version_change_and_numeric_comparison(self): + versions.version_status() + (self.root/'version.txt').write_text('1.2.10') + self.assertEqual(versions.version_status()['state'],'current') + self.assertEqual(self.remote.call_count,1) + self.assertEqual(versions._version('1.2'),versions._version('1.2.0')) + + +class RebootGuards(unittest.TestCase): + def setUp(self): + temporary=tempfile.TemporaryDirectory();self.addCleanup(temporary.cleanup) + self.root=Path(temporary.name); self.lock=self.root/'update.lock' + boot=self.root/'boot_id';boot.write_text('fixture-boot') + self.boot=patch.object(controls,'BOOT_ID',boot);self.boot.start();self.addCleanup(self.boot.stop) + self.a=patch.object(controls,'LOCK',str(self.lock)); self.a.start(); self.addCleanup(self.a.stop) + self.jobs=patch.object(controls,'list_jobs',return_value=[]).start() + self.probe=patch.object(controls,'reboot_status',return_value={'state':'required'}).start() + self.addCleanup(patch.stopall) + + def test_active_attention_starting_and_unreadable_jobs_block_before_kernel_probe(self): + for state in ('starting','running','attention','finalizing',None): + self.jobs.return_value=[{'state':state}] + self.assertFalse(controls.reboot_context()['allowed']) + self.jobs.side_effect=OSError('fixture missing registry') + self.assertFalse(controls.reboot_context()['allowed']) + self.probe.assert_not_called() + + def test_lock_probe_does_not_create_missing_file_and_symlink_is_refused(self): + self.assertTrue(controls.reboot_context()['allowed']) + self.assertFalse(self.lock.exists()) + self.lock.symlink_to(self.root/'missing') + self.probe.reset_mock() + self.assertFalse(controls.reboot_context()['allowed']) + self.probe.assert_not_called() + + def test_real_shared_flock_stops_probe_and_final_command_before_any_effect(self): + self.lock.touch() + marker=self.root/'inert-action' + command=list(controls.COMMAND) + self.assertEqual(command[-1],'/bin/systemctl reboot && /bin/sleep 60') + command[4]=str(self.lock);command[-1]='printf reached > '+shlex.quote(str(marker)) + with self.lock.open('r') as owner: + fcntl.flock(owner,fcntl.LOCK_EX|fcntl.LOCK_NB) + self.assertFalse(controls.reboot_context()['allowed']) + self.probe.assert_not_called() + self.assertEqual(subprocess.run(command,check=False).returncode,75) + self.assertFalse(marker.exists()) + self.assertEqual(subprocess.run(command,check=False).returncode,0) + self.assertEqual(marker.read_text(),'reached') + + def test_current_unknown_and_read_failures_never_allow_restart(self): + for state in ('current','unknown','not_applicable'): + self.probe.return_value={'state':state} + self.assertFalse(controls.reboot_context()['allowed']) + + +class Routes(unittest.TestCase): + def setUp(self): + auth=ModuleType('auth_manager') + auth.load_auth_config=lambda:{'enabled':True} + auth.verify_token=lambda token:'fixture' if token=='admin' else None + auth.verify_token_full=lambda token:('fixture','full_admin') if token=='admin' else ('fixture','read_only') if token=='reader' else (None,None) + patches={'auth_manager':auth,'health_monitor':SimpleNamespace(health_monitor=object()), + 'health_persistence':SimpleNamespace(health_persistence=object()),'mount_monitor':ModuleType('mount_monitor')} + self.modules=patch.dict(sys.modules,patches);self.modules.start();self.addCleanup(self.modules.stop) + def load(name,filename): + spec=importlib.util.spec_from_file_location(name,ROOT/'AppImage/scripts'/filename) + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module + middleware=load('controls_middleware','jwt_middleware.py') + self.mw=patch.dict(sys.modules,{'jwt_middleware':middleware});self.mw.start();self.addCleanup(self.mw.stop) + routes=load('controls_routes','flask_health_routes.py') + self.app=Flask(__name__);self.app.register_blueprint(routes.health_bp) + self.run=Mock(return_value=(True,{}));self.state=Mock(return_value={'state':'running'}) + self.app.extensions['host_update_power']={'run':self.run,'state':self.state} + self.context=patch.object(controls,'reboot_context',return_value={'allowed':True,'reason':'required','boot_id':'fixture-boot'}).start() + self.version=patch.object(versions,'version_status',return_value={'state':'unknown'}).start() + self.addCleanup(patch.stopall) + self.client=self.app.test_client() + import socket + self.body={'confirm':True,'hostname':socket.gethostname(),'boot_id':'fixture-boot'} + + def test_denied_auth_and_methods_do_not_probe_or_launch(self): + for token in (None,'bad','reader'): + headers={'Authorization':'Bearer '+token} if token else {} + response=self.client.post('/api/health/reboot-after-update',json=self.body,headers=headers) + self.assertEqual(response.status_code,403 if token=='reader' else 401) + self.assertEqual(self.client.get('/api/health/reboot-after-update').status_code,405) + self.context.assert_not_called();self.run.assert_not_called() + self.assertEqual(self.client.get('/api/health/proxmenux-version').status_code,401) + self.version.assert_not_called() + + def test_invalid_or_missing_confirmation_and_wrong_host_have_zero_probes(self): + for body in ({},{'confirm':'true'},dict(self.body,confirm=False),dict(self.body,hostname='another-host')): + self.assertEqual(self.client.post('/api/health/reboot-after-update',json=body,headers={'Authorization':'Bearer admin'}).status_code,400) + self.context.assert_not_called();self.run.assert_not_called() + + def test_busy_current_unknown_and_unconfigured_runner_never_launch(self): + for reason in ('busy','current','unknown'): + self.context.return_value={'allowed':False,'reason':reason,'boot_id':'fixture-boot'} + self.assertEqual(self.client.post('/api/health/reboot-after-update',json=self.body,headers={'Authorization':'Bearer admin'}).status_code,409) + self.app.extensions.clear();self.context.reset_mock() + self.assertEqual(self.client.post('/api/health/reboot-after-update',json=self.body,headers={'Authorization':'Bearer admin'}).status_code,503) + self.context.assert_not_called();self.run.assert_not_called() + + def test_stale_or_other_server_boot_identity_never_launches(self): + self.assertEqual(self.client.post('/api/health/reboot-after-update',json=dict(self.body,boot_id='another-boot'),headers={'Authorization':'Bearer admin'}).status_code,409) + self.run.assert_not_called() + + def test_allowed_confirmation_queues_only_fixed_guarded_action_and_preserves_existing_api(self): + response=self.client.post('/api/health/reboot-after-update',json=self.body,headers={'Authorization':'Bearer admin'}) + self.assertEqual(response.status_code,202);self.assertTrue(response.json['accepted']) + self.run.assert_called_once_with(controls.UNIT,list(controls.COMMAND),description='ProxMenux: reboot after host update',extra_properties=['RuntimeMaxSec=75']) + self.run.return_value=(False,{'status':409}) + self.assertEqual(self.client.post('/api/health/reboot-after-update',json=self.body,headers={'Authorization':'Bearer admin'}).status_code,409) + + def test_read_only_version_is_allowed_but_context_and_action_status_are_admin_only(self): + self.assertEqual(self.client.get('/api/health/proxmenux-version',headers={'Authorization':'Bearer reader'}).status_code,200) + for path in ('reboot-context','reboot-after-update/status'): + self.assertEqual(self.client.get('/api/health/'+path,headers={'Authorization':'Bearer reader'}).status_code,403) + self.context.assert_not_called();self.state.assert_not_called();self.run.assert_not_called() + + +if __name__=='__main__': + unittest.main(verbosity=2) From 707a45e639f79c3207e05e5495c584416db43d60 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:46:32 +0000 Subject: [PATCH 05/13] fix: guard host reboot with native package locks --- AppImage/scripts/host_update_controls.py | 56 +++++++++++++++++++++++- tests/test_host_update_controls.py | 39 ++++++++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/AppImage/scripts/host_update_controls.py b/AppImage/scripts/host_update_controls.py index 1c4b7520..c2d78179 100644 --- a/AppImage/scripts/host_update_controls.py +++ b/AppImage/scripts/host_update_controls.py @@ -4,18 +4,64 @@ import os from pathlib import Path import socket import stat +import subprocess +import sys +import time from kernel_reboot import reboot_status from host_update_jobs import list_jobs LOCK = '/run/proxmenux-host-update.lock' BOOT_ID = Path('/proc/sys/kernel/random/boot_id') +PROC = Path('/proc') +PACKAGE_LOCKS = ('/var/lib/dpkg/lock-frontend', '/var/lib/dpkg/lock') UNIT = 'proxmenux-action-reboot-after-update' # The unit obtains the SAME lock as CLI/legacy/web updates. Holding it for # the shutdown transition closes the precheck-to-launch race. If an updater # wins that race, flock exits75 and systemctl reboot is never invoked. -COMMAND = ['/usr/bin/flock', '-n', '-E', '75', LOCK, '/bin/sh', '-c', - '/bin/systemctl reboot && /bin/sleep 60'] +COMMAND = ['/usr/bin/flock', '-n', '-E', '75', LOCK, '/usr/bin/python3', + '/usr/local/share/proxmenux/monitor-app/usr/bin/host_update_controls.py', '--execute-reboot'] + + +def package_process_active(): + try: + for path in PROC.iterdir(): + if not path.name.isdigit(): + continue + try: + if (path/'comm').read_text().strip() in ('apt', 'apt-get', 'dpkg', 'unattended-upgr'): + return True + except (FileNotFoundError, ProcessLookupError): + continue + except OSError: + return True + return False + + +def execute_reboot(): + """Unit-only final guard: POSIX dpkg locks also protect direct APT callers. + + Do not use flock for these files: APT/dpkg use POSIX record locks. The + parent retains both locks while systemctl runs and during shutdown. + """ + handles = [] + try: + for path in PACKAGE_LOCKS: + fd = os.open(path, os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK) + handles.append(fd) + if not stat.S_ISREG(os.fstat(fd).st_mode): + return 76 + fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + if reboot_status()['state'] != 'required': + return 77 + subprocess.run(['/bin/systemctl', 'reboot'], check=True, timeout=10) + time.sleep(60) + return 0 + except (OSError, subprocess.SubprocessError): + return 75 + finally: + for fd in handles: + os.close(fd) def reboot_context(): @@ -35,6 +81,8 @@ def reboot_context(): return result if any(job.get('state') != 'complete' for job in jobs): return dict(result, reason='busy') + if package_process_active(): + return dict(result, reason='busy') try: fd = os.open(LOCK, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) except FileNotFoundError: @@ -55,3 +103,7 @@ def reboot_context(): reboot = reboot_status() return dict(result, allowed=reboot['state']=='required', reason='required' if reboot['state']=='required' else 'current' if reboot['state']=='current' else 'unknown') + + +if __name__ == '__main__': + sys.exit(execute_reboot() if sys.argv[1:] == ['--execute-reboot'] else 2) diff --git a/tests/test_host_update_controls.py b/tests/test_host_update_controls.py index 31ec8f03..7a7a1ed2 100644 --- a/tests/test_host_update_controls.py +++ b/tests/test_host_update_controls.py @@ -94,6 +94,8 @@ class RebootGuards(unittest.TestCase): def setUp(self): temporary=tempfile.TemporaryDirectory();self.addCleanup(temporary.cleanup) self.root=Path(temporary.name); self.lock=self.root/'update.lock' + (self.root/'proc').mkdir() + self.proc=patch.object(controls,'PROC',self.root/'proc');self.proc.start();self.addCleanup(self.proc.stop) boot=self.root/'boot_id';boot.write_text('fixture-boot') self.boot=patch.object(controls,'BOOT_ID',boot);self.boot.start();self.addCleanup(self.boot.stop) self.a=patch.object(controls,'LOCK',str(self.lock)); self.a.start(); self.addCleanup(self.a.stop) @@ -121,8 +123,8 @@ class RebootGuards(unittest.TestCase): self.lock.touch() marker=self.root/'inert-action' command=list(controls.COMMAND) - self.assertEqual(command[-1],'/bin/systemctl reboot && /bin/sleep 60') - command[4]=str(self.lock);command[-1]='printf reached > '+shlex.quote(str(marker)) + self.assertEqual(command[5:],['/usr/bin/python3','/usr/local/share/proxmenux/monitor-app/usr/bin/host_update_controls.py','--execute-reboot']) + command[4]=str(self.lock);command[5:]=['/bin/sh','-c','printf reached > '+shlex.quote(str(marker))] with self.lock.open('r') as owner: fcntl.flock(owner,fcntl.LOCK_EX|fcntl.LOCK_NB) self.assertFalse(controls.reboot_context()['allowed']) @@ -137,6 +139,39 @@ class RebootGuards(unittest.TestCase): self.probe.return_value={'state':state} self.assertFalse(controls.reboot_context()['allowed']) + def test_direct_package_process_blocks_before_kernel_probe(self): + path=self.root/'proc/123';path.mkdir();(path/'comm').write_text('apt-get\n') + self.assertFalse(controls.reboot_context()['allowed']) + self.probe.assert_not_called() + + def test_worker_package_locks_missing_or_unknown_kernel_never_reboot(self): + paths=[str(self.root/name) for name in ('frontend','backend')] + with patch.object(controls,'PACKAGE_LOCKS',paths),patch.object(controls.subprocess,'run') as reboot,patch.object(controls.time,'sleep') as delay: + self.assertEqual(controls.execute_reboot(),75) + reboot.assert_not_called();delay.assert_not_called() + for path in paths:Path(path).touch() + self.probe.return_value={'state':'unknown'} + self.assertEqual(controls.execute_reboot(),77) + reboot.assert_not_called() + self.probe.return_value={'state':'required'} + self.assertEqual(controls.execute_reboot(),0) + reboot.assert_called_once_with(['/bin/systemctl','reboot'],check=True,timeout=10) + delay.assert_called_once_with(60) + + def test_actual_posix_package_lock_blocks_worker_before_reboot(self): + paths=[str(self.root/name) for name in ('frontend','backend')] + for path in paths:Path(path).touch() + # A separate process holds the same POSIX lock, just as APT does. + code='import fcntl,sys,time; f=open(sys.argv[1],"r+"); fcntl.lockf(f,fcntl.LOCK_EX); print("locked",flush=True); time.sleep(5)' + child=subprocess.Popen([sys.executable,'-c',code,paths[0]],stdout=subprocess.PIPE,text=True) + try: + self.assertEqual(child.stdout.readline().strip(),'locked') + with patch.object(controls,'PACKAGE_LOCKS',paths),patch.object(controls.subprocess,'run') as reboot: + self.assertEqual(controls.execute_reboot(),75) + reboot.assert_not_called() + finally: + child.terminate();child.wait(timeout=2);child.stdout.close() + class Routes(unittest.TestCase): def setUp(self): From 79cdf814ed34bb908688de9bbd2b847fbb4e0e9f Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:59:14 +0000 Subject: [PATCH 06/13] fix: keep restart controls disabled while request is pending --- AppImage/components/host-update-status.tsx | 4 ++-- tests/test_host_update_controls.cjs | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/AppImage/components/host-update-status.tsx b/AppImage/components/host-update-status.tsx index f2a30ff1..20693da3 100644 --- a/AppImage/components/host-update-status.tsx +++ b/AppImage/components/host-update-status.tsx @@ -88,7 +88,7 @@ export function RestartAfterUpdate({ getApiUrl, disabled, label, detail, icon }:
{icon}
{label}{detail}
+ disabled={disabled || pending || accepted} onClick={() => setOpen(true)}>{t("healthStatus.restart.button")} {disabled &&

{t("healthStatus.restart.blocked")}

} {accepted &&

{t("healthStatus.restart.accepted")}

} @@ -103,7 +103,7 @@ export function RestartAfterUpdate({ getApiUrl, disabled, label, detail, icon }: {(error || context?.reason === "unknown") &&

{t("healthStatus.restart.unconfirmed")}

}
-
diff --git a/tests/test_host_update_controls.cjs b/tests/test_host_update_controls.cjs index 665d377b..460c5816 100644 --- a/tests/test_host_update_controls.cjs +++ b/tests/test_host_update_controls.cjs @@ -2,8 +2,14 @@ const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm') const app=path.resolve(__dirname,'../AppImage'),ts=require(path.join(app,'node_modules/typescript')) const tree=ts.createSourceFile('controls.tsx',fs.readFileSync(path.join(app,'components/host-update-status.tsx'),'utf8'),ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX) -let callback -function walk(n){if(ts.isVariableDeclaration(n)&&n.name.getText(tree)==='confirm')callback=n.initializer.getText(tree);ts.forEachChild(n,walk)} +let callback,triggerDisabled +function walk(n){ + if(ts.isVariableDeclaration(n)&&n.name.getText(tree)==='confirm')callback=n.initializer.getText(tree) + if(ts.isJsxElement(n)&&n.openingElement.tagName.getText(tree)==='Button'&&n.children.some(c=>c.getText(tree).includes('healthStatus.restart.button'))){ + triggerDisabled=n.openingElement.attributes.properties.find(p=>p.name?.getText(tree)==='disabled').initializer.expression.getText(tree) + } + ts.forEachChild(n,walk) +} walk(tree);assert.ok(callback) function fixture(){ const calls=[],events=[] @@ -16,6 +22,8 @@ function fixture(){ return {scope,calls,events,run} } async function main(){ + assert.ok(triggerDisabled) + assert.equal(vm.runInNewContext(triggerDisabled,{disabled:false,accepted:false,pending:true}),true,'closing confirmation while its request is pending must not enable another request') for(const block of ['disabled','submitted','sending','missing-context','not-allowed']){ const x=fixture() if(block==='sending')x.scope.sending.current=true From e1be5c22c876c33bcf54cae64648b9ad02ec0606 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:09:46 +0000 Subject: [PATCH 07/13] fix: include host update controls in AppImage assembly --- .github/scripts/run_offline_qualified.py | 1 + AppImage/scripts/build_appimage.sh | 2 ++ tests/test_host_update_packaging.py | 27 ++++++++++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 tests/test_host_update_packaging.py diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index 48173d59..4d4e45b9 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -20,6 +20,7 @@ PYTHON_FILES = ( "tests/test_kernel_reboot.py", "tests/test_reboot_health_refresh.py", "tests/test_host_update_controls.py", + "tests/test_host_update_packaging.py", "tests/test_lxc_update_jobs.py", "tests/test_post_install_i18n.py", "tests/test_remaining_cli_i18n.py", diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index c39cd14d..3ccf3d66 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -151,6 +151,8 @@ cp "$SCRIPT_DIR/lxc_update_finalization.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/lxc_update_jobs.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/host_update_jobs.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/kernel_reboot.py" "$APP_DIR/usr/bin/" +cp "$SCRIPT_DIR/host_update_controls.py" "$APP_DIR/usr/bin/" +cp "$SCRIPT_DIR/proxmenux_version_status.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/lxc_check_job.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/native_app_discovery.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/local_app_icons.py" "$APP_DIR/usr/bin/" diff --git a/tests/test_host_update_packaging.py b/tests/test_host_update_packaging.py new file mode 100644 index 00000000..dc32a671 --- /dev/null +++ b/tests/test_host_update_packaging.py @@ -0,0 +1,27 @@ +"""Execute the actual backend-copy recipe, then import the packaged helpers.""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +ROOT=Path(__file__).resolve().parents[1] + +class Packaging(unittest.TestCase): + def test_actual_appimage_copy_recipe_contains_importable_host_controls(self): + script=(ROOT/'AppImage/scripts/build_appimage.sh').read_text() + recipe='\n'.join(line for line in script.splitlines() if line.startswith('cp "$SCRIPT_DIR/') and '.py"' in line) + self.assertTrue(recipe) + with tempfile.TemporaryDirectory() as temporary: + folder=Path(temporary);(folder/'usr/bin').mkdir(parents=True) + result=subprocess.run(['bash','-eu','-c',recipe],env=dict(os.environ,SCRIPT_DIR=str(ROOT/'AppImage/scripts'),APP_DIR=str(folder)),capture_output=True,text=True,timeout=10) + self.assertEqual(result.returncode,0,result.stderr) + for name in ('host_update_controls.py','proxmenux_version_status.py','kernel_reboot.py','host_update_jobs.py'): + self.assertTrue((folder/'usr/bin'/name).is_file(),name+' omitted from AppImage') + self.assertEqual((folder/'usr/bin'/name).read_bytes(),(ROOT/'AppImage/scripts'/name).read_bytes()) + code='import sys;sys.path.insert(0,sys.argv[1]);import host_update_controls,proxmenux_version_status' + result=subprocess.run([sys.executable,'-I','-B','-c',code,str(folder/'usr/bin')],capture_output=True,text=True,timeout=5) + self.assertEqual(result.returncode,0,result.stderr) + +if __name__=='__main__':unittest.main(verbosity=2) From 43973e1d0688c12a37ca4a9cc7d0bfbc2a89af00 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:40:56 +0000 Subject: [PATCH 08/13] fix: clarify system update action and package plan --- AppImage/components/health-status-modal.tsx | 17 +++++++++++++---- AppImage/messages/en/common.json | 4 +++- AppImage/messages/sk/common.json | 6 ++++-- tests/test_health_text.cjs | 11 +++++++++++ 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index f986e903..3b27172c 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -340,6 +340,10 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu const translateHealthText = (value?: string): string => { if (!value) return "" + // Health summaries include the category before the same detail text. + if (value.startsWith("Updates: ")) { + return `${t("healthStatus.categories.updates")}: ${translateHealthText(value.slice(9))}` + } const exact: Record = { "No temperature sensor detected - install lm-sensors if hardware supports it": t("healthStatus.details.noTemperatureSensor"), "Sustained high CPU usage": t("healthStatus.details.cpuHighSustained"), @@ -903,9 +907,13 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu pending prevents the operator from spawning a terminal that would only report "System is already up to date". */} - {key === "updates" && } {key === "updates" && Number(categoryData?.count ?? 0) > 0 && ( -
+

+ {t("healthStatus.packagePlanNote")} +

+ )} + {key === "updates" && Number(categoryData?.count ?? 0) > 0 && ( +
)} + {key === "updates" && }
)} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index d2507a10..f35857dc 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -5470,7 +5470,7 @@ "failedLogins": "{count} failed login attempts in 24h", "fail2banBannedIps": "Fail2Ban is currently blocking {count} IP addresses (jails: {jails})", "uptimeDays": "Uptime: {count} days", - "pendingPackages": "{count} packages pending", + "pendingPackages": "Planned packages: {count} (including new packages)", "updatedDaysAgo": "Last updated {count} days ago", "startupStoragesChecking": "Checking Proxmox storage after startup: {storages}", "startupStorageChecking": "Checking Proxmox storage after startup: {storage}", @@ -5498,6 +5498,8 @@ }, "noIssues": "No issues detected", "updateNow": "Update now", + "updateSystem": "Update system", + "packagePlanNote": "The plan also includes new packages, such as a new kernel. The overview counts updates to existing packages only.", "dismissedItems": "Dismissed items ({count})", "permanent": "Permanent", "dismissed": "Dismissed", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 3e522ea1..84bfb9bc 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -5303,7 +5303,7 @@ "failedLogins": "Neúspešné prihlásenia za 24 h: {count}", "fail2banBannedIps": "Fail2Ban momentálne blokuje IP adresy: {count} (pravidlá: {jails})", "uptimeDays": "Beží {count} dní", - "pendingPackages": "Čakajúce balíky: {count}", + "pendingPackages": "Plánované balíky: {count} (vrátane nových)", "updatedDaysAgo": "Naposledy aktualizované pred {count} dňami", "startupStoragesChecking": "Po štarte sa ešte overuje stav Proxmox úložísk: {storages}", "startupStorageChecking": "Po štarte sa ešte overuje stav Proxmox úložiska: {storage}", @@ -5334,7 +5334,7 @@ "pendingSecurityUpdatesDays": "Čakajúce aktualizácie zabezpečenia: {count} (dni čakania: {days})", "systemNotUpdatedMonths": "Systém nebol aktualizovaný za {days} dní (>18 mesiacov)", "systemNotUpdatedYear": "Systém nebol aktualizovaný za {days} dní (>1 rok)", - "runningKernelUpdate": "K dispozícii je aktualizácia jadra pre spustenie {kernel}", + "runningKernelUpdate": "K dispozícii je aktualizácia pre bežiace jadro {kernel}", "kernelPveUpdates": "Dostupné aktualizácie jadra: {kernelCount}; dostupné aktualizácie Proxmox: {pveCount}", "pendingPackageUpdates": "Čakajúce aktualizácie balíka: {count}", "noPendingUpdates": "Žiadne čakajúce aktualizácie", @@ -5370,6 +5370,8 @@ }, "noIssues": "Neboli zistené žiadne problémy", "updateNow": "Aktualizovať teraz", + "updateSystem": "Aktualizovať systém", + "packagePlanNote": "Plán zahŕňa aj nové balíky, napríklad nové jadro. Prehľad počíta iba aktualizácie existujúcich balíkov.", "dismissedItems": "Skryté upozornenia ({count})", "permanent": "Natrvalo", "dismissed": "Skryté", diff --git a/tests/test_health_text.cjs b/tests/test_health_text.cjs index 2ffee6ff..34bf9d81 100644 --- a/tests/test_health_text.cjs +++ b/tests/test_health_text.cjs @@ -261,5 +261,16 @@ for (const input of passthrough) { check(`passthrough ${JSON.stringify(input)}`, () => assert.equal(select(input), input)); for (const locale of Object.keys(catalogs)) check(`passthrough ${locale} ${JSON.stringify(input)}`, () => assert.equal(render(input, locale), input)); } +// Real summary contract from health_monitor: category prefix + reason. +for (const [locale, expectedText] of [ + ['sk', 'Aktualizácie systému: K dispozícii je aktualizácia pre bežiace jadro 7.0.14-25-pve'], + ['en', 'System updates: Kernel update available for running 7.0.14-25-pve'], +]) { + check(`prefixed update summary ${locale}`, () => assert.equal( + render('Updates: Kernel update available for running 7.0.14-25-pve', locale), expectedText)); + const opaque = 'Repository unavailable; diagnostic: /srv/path {count}\nline 2'; + check(`prefixed diagnostic preserved ${locale}`, () => assert.equal( + render('Updates: ' + opaque, locale), translator(locale)('healthStatus.categories.updates') + ': ' + opaque)); +} if (failures) throw new Error(`${failures} health-text assertions failed`); console.log(`PASS: actual health mapper/provider; ${new Set(cases.map(row => row[1])).size} new keys, ${cases.length} message variants, ${Object.keys(catalogs).length} shipped locales and unconditional missing-key fallback`); From 889409de8dc6eacc40211a15774bf962d25ac4a9 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:06:38 +0000 Subject: [PATCH 09/13] fix: preserve dated health results while update cache refreshes --- AppImage/components/health-status-modal.tsx | 30 ++++++++++++++++++++- AppImage/messages/en/common.json | 2 ++ AppImage/messages/sk/common.json | 2 ++ tests/test_health_text.cjs | 15 +++++++++++ tests/test_reboot_health_refresh.cjs | 24 +++++++++++++++-- tests/test_reboot_health_refresh.py | 18 +++++++++++++ 6 files changed, 88 insertions(+), 3 deletions(-) diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index 3b27172c..41fa911c 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -128,6 +128,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu const [dismissedItems, setDismissedItems] = useState([]) const [customSuppressions, setCustomSuppressions] = useState([]) const [error, setError] = useState(null) + const [snapshotUnavailable, setSnapshotUnavailable] = useState(false) const [dismissingKey, setDismissingKey] = useState(null) const [expandedCategories, setExpandedCategories] = useState>(new Set()) const [showUpdateTerminal, setShowUpdateTerminal] = useState(false) @@ -161,6 +162,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu let infoCount = 0 if (!response.ok) { + if (cachedOnly && response.status === 503) { + // Completion invalidates the snapshot before the collector refills it. + // Keep the dated result, explicitly unavailable; never start an audit. + setSnapshotUnavailable(true) + return + } // Polling cannot fall back to a legacy endpoint that runs a full audit. if (cachedOnly) throw new Error(t("healthStatus.errors.fetchFailed")) // Fallback to legacy endpoint @@ -168,6 +175,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed")) const data = await legacyResponse.json() if (healthRequest.current !== controller) return + setSnapshotUnavailable(false) setHealthData(data) setDismissedItems([]) setCustomSuppressions([]) @@ -186,6 +194,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu const fullData: FullHealthData = await response.json() if (healthRequest.current !== controller) return if (!fullData?.health?.details) throw new Error(t("healthStatus.errors.fetchFailed")) + setSnapshotUnavailable(false) setHealthData(fullData.health) setDismissedItems(fullData.dismissed || []) setCustomSuppressions(fullData.custom_suppressions || []) @@ -226,6 +235,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu window.dispatchEvent(event) } catch (err) { if (healthRequest.current === controller) { + setSnapshotUnavailable(false) setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown")) } } finally { @@ -684,6 +694,17 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu .replace(/\b\w/g, (c) => c.toUpperCase()) } + const translateHealthSummary = (snapshot: HealthDetails): string => { + if (snapshot.summary === "Updates") { + const reboot = snapshot.details?.updates?.checks?.reboot_required + if (reboot?.status === "INFO" && typeof reboot.detail === "string" && reboot.detail) { + return translateHealthText(reboot.detail) + } + return t("healthStatus.categories.updates") + } + return translateHealthText(snapshot.summary) + } + const renderChecks = ( checks: Record, categoryKey: string @@ -786,6 +807,13 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu )} + {snapshotUnavailable && ( +
+

{t("healthStatus.snapshotUnavailable")}

+ {healthData &&

{t("healthStatus.lastKnownResults")}

} +
+ )} + {healthData && !loading && (
{/* Overall Stats Summary */} @@ -822,7 +850,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu {healthData.summary && healthData.summary !== "All systems operational" && (
-

{translateHealthText(healthData.summary)}

+

{translateHealthSummary(healthData)}

)} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index f35857dc..90ae8de3 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -5500,6 +5500,8 @@ "updateNow": "Update now", "updateSystem": "Update system", "packagePlanNote": "The plan also includes new packages, such as a new kernel. The overview counts updates to existing packages only.", + "snapshotUnavailable": "New health results are not available yet.", + "lastKnownResults": "Showing the last available results with their original check time.", "dismissedItems": "Dismissed items ({count})", "permanent": "Permanent", "dismissed": "Dismissed", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 84bfb9bc..93cf9bc7 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -5372,6 +5372,8 @@ "updateNow": "Aktualizovať teraz", "updateSystem": "Aktualizovať systém", "packagePlanNote": "Plán zahŕňa aj nové balíky, napríklad nové jadro. Prehľad počíta iba aktualizácie existujúcich balíkov.", + "snapshotUnavailable": "Nový výsledok kontroly zatiaľ nie je k dispozícii.", + "lastKnownResults": "Zobrazené sú posledné dostupné údaje s pôvodným časom kontroly.", "dismissedItems": "Skryté upozornenia ({count})", "permanent": "Natrvalo", "dismissed": "Skryté", diff --git a/tests/test_health_text.cjs b/tests/test_health_text.cjs index 34bf9d81..c5156143 100644 --- a/tests/test_health_text.cjs +++ b/tests/test_health_text.cjs @@ -272,5 +272,20 @@ for (const [locale, expectedText] of [ check(`prefixed diagnostic preserved ${locale}`, () => assert.equal( render('Updates: ' + opaque, locale), translator(locale)('healthStatus.categories.updates') + ': ' + opaque)); } +let summaryMapper; +walk(source,n=>{if(ts.isVariableDeclaration(n)&&n.name.getText(source)==='translateHealthSummary')summaryMapper=n.initializer;}); +assert.ok(summaryMapper,'actual summary mapper handles the real bare Updates result'); +const summaryFactory=new Function('t',compile('const translateHealthText='+emit(mapper,source)+';const translateHealthSummary='+emit(summaryMapper,source))+';return translateHealthSummary;'); +for(const locale of ['sk','en']){ + const t=translator(locale), summary=summaryFactory(t); + const detail='Reboot required: running 7.0.14-25-pve; installed 7.0.14-26-pve'; + const value={summary:'Updates',details:{updates:{checks:{reboot_required:{status:'INFO',detail}}}}}; + const before=JSON.stringify(value); + assert.equal(summary(value),t('healthStatus.hostUpdate.rebootKernel',{running:'7.0.14-25-pve',installed:'7.0.14-26-pve'})); + assert.equal(JSON.stringify(value),before,'rendering never mutates the snapshot'); + assert.equal(summary({summary:'Updates',details:{}}),t('healthStatus.categories.updates')); + assert.equal(summary({summary:'Updates',details:{updates:{checks:{reboot_required:{status:'UNKNOWN',detail}}}}}),t('healthStatus.categories.updates'),'unknown evidence cannot create a reboot-required summary'); + assert.equal(summary({summary:'opaque diagnostic',details:{}}),'opaque diagnostic'); +} if (failures) throw new Error(`${failures} health-text assertions failed`); console.log(`PASS: actual health mapper/provider; ${new Set(cases.map(row => row[1])).size} new keys, ${cases.length} message variants, ${Object.keys(catalogs).length} shipped locales and unconditional missing-key fallback`); diff --git a/tests/test_reboot_health_refresh.cjs b/tests/test_reboot_health_refresh.cjs index 318ef654..52f5b901 100644 --- a/tests/test_reboot_health_refresh.cjs +++ b/tests/test_reboot_health_refresh.cjs @@ -19,7 +19,7 @@ const modal = source('health-status-modal.tsx', 'fetchHealthDetails', 'Recheck r const header = source('proxmox-dashboard.tsx', 'fetchHealthInfoCount', 'Header reboot information') const compile = (code, scope) => vm.runInNewContext(ts.transpileModule('('+code+')', {compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText, scope) function environment() { - const calls = [], data = [], errors = [], counts = [], events = [], timers = new Map(), listeners = new Map() + const calls = [], data = [], errors = [], counts = [], events = [], availability = [], timers = new Map(), listeners = new Map() let timerId = 0 const categories = [{key:'updates', category:'updates'}] const scope = {AbortController, CustomEvent:class {constructor(type, values){this.type=type;this.detail=values.detail}}, @@ -27,11 +27,12 @@ function environment() { CATEGORIES:categories, HEALTH_CATEGORY_KEYS:categories, setHealthData:v=>data.push(v), setDismissedItems(){}, setCustomSuppressions(){}, setLoading(){}, setError:v=>errors.push(v), setInfoCount:v=>counts.push(v), + setSnapshotUnavailable:v=>availability.push(v), setTimeout(fn, delay){timers.set(++timerId,{fn,delay});return timerId}, clearTimeout:id=>timers.delete(id), window:{dispatchEvent:e=>events.push(e)}, fetch:async(p, opts)=>{calls.push({p,opts});return {ok:true,json:async()=>payload('INFO')}}, fetchApi:async(p, opts)=>{calls.push({p,opts});return payload('INFO')}} - return {scope,calls,data,errors,counts,events,timers,listeners} + return {scope,calls,data,errors,counts,events,availability,timers,listeners} } function payload(status='OK') {return {health:{overall:'OK',timestamp:'original',details:{updates:{status,count:0}}},dismissed:[],custom_suppressions:[]}} async function callbacks() { @@ -44,6 +45,25 @@ async function callbacks() { assert.deepEqual(item===modal ? env.events[0].detail.infoCount : env.counts[0],1) } const env=environment(), run=compile(modal.callback,env.scope) + const pending=environment(), poll=compile(modal.callback,pending.scope) + pending.scope.fetch=async(p)=>{pending.calls.push({p});return {ok:false,status:503}} + await poll(false,true) + assert.deepEqual(pending.availability,[true],'expected cache gap is explicitly unavailable, not a red failure or healthy result') + assert.equal(pending.errors.filter(Boolean).length,0) + assert.equal(pending.data.length,0,'cache gap cannot erase or replace the last known data') + assert.deepEqual(pending.calls.map(x=>x.p),['/api/health/full?cached=1'],'no full/legacy fallback on cache miss') + pending.scope.fetch=async()=>({ok:true,json:async()=>payload('INFO')}) + await poll(false,true) + assert.deepEqual(pending.availability,[true,false],'a real successful response clears the unavailable notice') + assert.equal(pending.data.length,1) + for(const status of [401,500]){ + const failed=environment() + failed.scope.fetch=async()=>({ok:false,status}) + await compile(modal.callback,failed.scope)(false,true) + assert.ok(failed.errors.filter(Boolean).length,'auth and server failures remain errors') + assert.equal(failed.data.length,0) + assert.ok(!failed.availability.includes(true),'real failures are not relabelled as a cache gap') + } env.scope.fetch=async(p)=>{env.calls.push({p});return {ok:false}} await run(false,true) assert.deepEqual(env.calls.map(x=>x.p),['/api/health/full?cached=1']) diff --git a/tests/test_reboot_health_refresh.py b/tests/test_reboot_health_refresh.py index 2a046066..84d24b34 100644 --- a/tests/test_reboot_health_refresh.py +++ b/tests/test_reboot_health_refresh.py @@ -83,6 +83,24 @@ class FullHealth(unittest.TestCase): self.owner.get_detailed_status.assert_not_called() self.probe.assert_not_called() + def test_real_completion_finalizer_then_cached_poll_has_no_new_audit(self): + import sys + from types import ModuleType + node = next(n for n in ast.parse((ROOT/'AppImage/scripts/flask_terminal_routes.py').read_text()).body + if isinstance(n, ast.FunctionDef) and n.name == '_host_update_completed') + scope = {} + exec(compile(ast.Module(body=[node], type_ignores=[]), 'actual-finalizer', 'exec'), scope) + module = ModuleType('health_monitor') + module.health_monitor = self.owner + with patch.dict(sys.modules, {'health_monitor': module}): + scope['_host_update_completed']('/fixed-script', {}, 0, 164) + self.assertNotIn('_bg_detailed', self.owner.cached_results) + result, code = self.call('?cached=1') + self.assertEqual(code, 503) + self.assertIn('error', result) + self.owner.get_detailed_status.assert_not_called() + self.probe.assert_not_called() + def test_cached_only_just_before_boundary_is_fresh_reboot_without_full_check(self): self.owner.last_check_times['_bg_detailed'] = self.now-359.999 result, code = self.call('?cached=1') From ba3fb1197fd66501bcf68313d232bea73e0f2ad1 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:41:00 +0000 Subject: [PATCH 10/13] fix: read completed update results without a live terminal --- .github/scripts/run_offline_qualified.py | 1 + AppImage/components/health-status-modal.tsx | 4 ++ AppImage/components/host-update-jobs.tsx | 14 ++-- AppImage/components/host-update-result.tsx | 77 +++++++++++++++++++++ AppImage/messages/en/common.json | 10 ++- AppImage/messages/sk/common.json | 10 ++- AppImage/scripts/flask_health_routes.py | 18 +++++ AppImage/scripts/flask_server.py | 12 +++- AppImage/scripts/flask_terminal_routes.py | 16 +++++ tests/test_host_update_jobs.py | 25 ++++++- tests/test_host_update_transport.py | 4 ++ tests/test_host_update_ui.cjs | 34 ++++++++- tests/test_host_update_version.py | 52 ++++++++++++++ tests/test_reboot_health_refresh.py | 23 ++++++ 14 files changed, 289 insertions(+), 11 deletions(-) create mode 100644 AppImage/components/host-update-result.tsx create mode 100644 tests/test_host_update_version.py diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index 4d4e45b9..2af5a61b 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -21,6 +21,7 @@ PYTHON_FILES = ( "tests/test_reboot_health_refresh.py", "tests/test_host_update_controls.py", "tests/test_host_update_packaging.py", + "tests/test_host_update_version.py", "tests/test_lxc_update_jobs.py", "tests/test_post_install_i18n.py", "tests/test_remaining_cli_i18n.py", diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index 41fa911c..b554e18e 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -42,6 +42,7 @@ import { } from "lucide-react" import { ScriptTerminalModal } from "./script-terminal-modal" import { HostUpdateJobs, type HostUpdateJob } from "./host-update-jobs" +import { HostUpdateResult } from "./host-update-result" import { RestartAfterUpdate, ProxMenuxVersionCard } from "./host-update-status" import { useT } from "@/lib/i18n/provider" import { useIsPbs } from "@/lib/use-platform" @@ -134,6 +135,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu const [showUpdateTerminal, setShowUpdateTerminal] = useState(false) const [hostJob, setHostJob] = useState<{ runId: string; resume: boolean; kind: "host" } | null>(null) const [hostJobs, setHostJobs] = useState([]) + const [resultJobId, setResultJobId] = useState(null) const healthRequest = useRef(null) const previousAutoExpanded = useRef>(new Set()) @@ -790,6 +792,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu { void fetchHealthDetails(true) }} onResume={job => { + if (job.state === "complete") { setResultJobId(job.id); return } setHostJob({ runId: job.id, resume: true, kind: "host" }) setShowUpdateTerminal(true) }} /> @@ -1069,6 +1072,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
)} + setResultJobId(null)} /> ([]) + const [hidden, setHidden] = useState>(new Set()) const callbacks = useRef({ onJobs, onSettled }) callbacks.current = { onJobs, onSettled } const observed = useRef(new Set()) @@ -47,15 +48,20 @@ export function HostUpdateJobs({ open, activeId, onResume, onJobs, onSettled }: const timer = setInterval(() => { void read() }, 5000) return () => { closed = true; clearInterval(timer) } }, [open]) - const visible = jobs.filter(job => job.id !== activeId) + const visible = jobs.filter(job => job.id !== activeId && !(job.state === "complete" && hidden.has(job.id))) if (!visible.length) return null return
{visible.map(job =>

{t(`healthStatus.hostUpdate.${job.state === "complete" ? "finished" : job.state === "attention" ? "attention" : "running"}`)}

- +
+ + {job.state === "complete" && } +
)}
} diff --git a/AppImage/components/host-update-result.tsx b/AppImage/components/host-update-result.tsx new file mode 100644 index 00000000..52e8a6a7 --- /dev/null +++ b/AppImage/components/host-update-result.tsx @@ -0,0 +1,77 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog" +import { Button } from "./ui/button" +import { getAuthToken } from "../lib/api-config" +import { scriptCompletion } from "../lib/script-completion" +import { useT } from "../lib/i18n/provider" +import "xterm/css/xterm.css" + +export function HostUpdateResult({ jobId, getApiUrl, onClose }: { + jobId: string | null + getApiUrl: (path: string) => string + onClose: () => void +}) { + const t = useT() + const container = useRef(null) + const [revision, setRevision] = useState(0) + const [exitCode, setExitCode] = useState(null) + const [failed, setFailed] = useState(false) + useEffect(() => { + if (!jobId) return + const controller = new AbortController() + let terminal: import("xterm").Terminal | undefined + let observer: ResizeObserver | undefined + let disposed = false + setExitCode(null); setFailed(false) + const timeout = setTimeout(() => controller.abort(), 10000) + void (async () => { + try { + const token = getAuthToken() + const response = await fetch(getApiUrl(`/api/host-update-jobs/${encodeURIComponent(jobId)}/result`), { + headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: controller.signal, + }) + if (!response.ok) throw Error("unavailable") + const value = await response.json() + if (value.state !== "complete" || !Number.isSafeInteger(value.exit_code) + || typeof value.text !== "string" || value.text.length > 256 * 1024 + || typeof value.truncated !== "boolean") throw Error("invalid result") + const [{ Terminal }, { FitAddon }] = await Promise.all([import("xterm"), import("xterm-addon-fit")]) + if (controller.signal.aborted || !container.current) return + terminal = new Terminal({ disableStdin: true, convertEol: true, fontSize: 12, + screenReaderMode: true, theme: { background: "#000000", foreground: "#dddddd" } }) + const fit = new FitAddon() + terminal.loadAddon(fit); terminal.open(container.current); fit.fit() + if (value.truncated) terminal.writeln(t("vmLxc.updates.backgroundOutputTrimmed")) + terminal.write(value.text) + observer = new ResizeObserver(() => { if (!controller.signal.aborted) fit.fit() }) + observer.observe(container.current) + setExitCode(value.exit_code) + } catch { + if (!disposed) setFailed(true) + } finally { clearTimeout(timeout) } + })() + return () => { disposed = true; controller.abort(); clearTimeout(timeout); observer?.disconnect(); terminal?.dispose() } + }, [jobId, getApiUrl, revision, t]) + const kind = exitCode === null ? null : scriptCompletion(exitCode, 130).kind + return { if (!open) onClose() }}> + + {t("healthStatus.hostUpdate.resultTitle")} + {t("healthStatus.hostUpdate.resultReadOnly")} + {failed &&
+

{t("healthStatus.hostUpdate.resultUnavailable")}

+ +
} +
+
+

+ {kind === null ? t(failed ? "healthStatus.hostUpdate.resultUnavailable" : "healthStatus.hostUpdate.loadingResult") + : kind === "cancelled" ? t("scriptTerminal.cancelled") : exitCode === 0 + ? t("scriptTerminal.completedSuccessfully") : t("scriptTerminal.completedWithError", { code: exitCode })} +

+ +
+ +
+} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 90ae8de3..d61972ef 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -5334,9 +5334,17 @@ }, "hostUpdate": { "running": "The host update continues even if you sign out. Reopen it after signing in.", - "finished": "A host update has finished. View its result before starting another.", + "finished": "The last host update has finished. Its result is available.", "attention": "The host update needs attention. It has not been stopped; reconnect to inspect it.", "view": "View update", + "viewResult": "View result", + "hideResult": "Hide result", + "resultTitle": "Host update result", + "resultReadOnly": "Saved output of a completed update. Opening it does not run another update.", + "resultUnavailable": "The saved result could not be loaded. No update was started.", + "retryResult": "Reload result", + "loadingResult": "Loading saved result…", + "closeResult": "Close result", "noReboot": "No reboot required", "rebootMarker": "The operating system requires a reboot.", "rebootUnknown": "Could not determine whether a reboot is required.", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 93cf9bc7..290fdbd5 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -5206,9 +5206,17 @@ }, "hostUpdate": { "running": "Aktualizácia servera pokračuje aj po odhlásení. Po prihlásení ju môžeš znova otvoriť.", - "finished": "Aktualizácia servera sa skončila. Pred ďalším spustením si pozri jej výsledok.", + "finished": "Posledná aktualizácia servera sa skončila. Jej výsledok je k dispozícii.", "attention": "Aktualizácia servera potrebuje kontrolu. Nebola zastavená; otvor ju a skontroluj priebeh.", "view": "Zobraziť aktualizáciu", + "viewResult": "Zobraziť výsledok", + "hideResult": "Skryť výsledok", + "resultTitle": "Výsledok aktualizácie servera", + "resultReadOnly": "Uložený výpis dokončenej aktualizácie. Jeho otvorenie nespustí ďalšiu aktualizáciu.", + "resultUnavailable": "Uložený výsledok sa nepodarilo načítať. Žiadna aktualizácia sa nespustila.", + "retryResult": "Znova načítať výsledok", + "loadingResult": "Načítava sa uložený výsledok…", + "closeResult": "Zavrieť výsledok", "noReboot": "Reštart nie je potrebný", "rebootMarker": "Operačný systém vyžaduje reštart.", "rebootUnknown": "Nepodarilo sa zistiť, či je potrebný reštart.", diff --git a/AppImage/scripts/flask_health_routes.py b/AppImage/scripts/flask_health_routes.py index 26f5479a..a5287b69 100644 --- a/AppImage/scripts/flask_health_routes.py +++ b/AppImage/scripts/flask_health_routes.py @@ -307,6 +307,7 @@ def get_full_health(): bg_key = '_bg_detailed' bg_last = health_monitor.last_check_times.get(bg_key, 0) bg_age = _time.time() - bg_last if type(bg_last) in (int, float) else float('inf') + publish_live = False if 0 <= bg_age < 360 and bg_key in health_monitor.cached_results: # Use cached result (at most ~5 min old) @@ -318,12 +319,29 @@ def get_full_health(): else: # No fresh cache, run live (first load or cache expired) details = health_monitor.get_detailed_status() + if (not isinstance(details, dict) or not details.get('timestamp') + or details.get('overall') not in ('OK', 'INFO', 'WARNING', 'CRITICAL', 'UNKNOWN') + or not isinstance(details.get('summary'), str) + or not isinstance(details.get('details'), dict) + or not isinstance(details['details'].get('updates'), dict)): + raise ValueError('Health snapshot is incomplete') + # Publish an explicit check too: its caller may have invalidated + # the background cache after an update. A following read-only poll + # must see this result instead of waiting for another collector. + publish_live = True # Installed/running kernel state changes outside Monitor jobs too. # Overlay fresh read-only evidence without refreshing other checks or # rewriting the background collector's snapshot/timestamp. from kernel_reboot import refresh_health_reboot details = refresh_health_reboot(details) + if publish_live: + checked_at = _time.time() + health_monitor.cached_results[bg_key] = details + health_monitor.cached_results['_bg_overall'] = { + 'status': details['overall'], 'summary': details['summary']} + health_monitor.last_check_times[bg_key] = checked_at + health_monitor.last_check_times['_bg_overall'] = checked_at active_errors = health_persistence.get_active_errors() dismissed = health_persistence.get_dismissed_errors() diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 0db3c3f6..c4cbd287 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -1752,6 +1752,7 @@ def get_uptime(): _system_info_cache = { 'proxmox_version': None, 'proxmox_version_time': 0, + 'proxmox_version_stamp': 0.0, 'available_updates': 0, 'available_updates_time': 0, 'available_updates_stamp': 0.0, @@ -3507,14 +3508,16 @@ def get_coral_info(): def get_proxmox_version(): - """Get Proxmox version if available. Cached for 6 hours.""" + """Cache the installed PVE version until package state changes.""" global _system_info_cache if is_pbs_host(): return get_platform().get('version') now = time.time() - if _system_info_cache['proxmox_version'] is not None and \ - now - _system_info_cache['proxmox_version_time'] < _SYSTEM_INFO_CACHE_TTL: + stamp = _apt_state_stamp() + age = now - _system_info_cache['proxmox_version_time'] + if _system_info_cache['proxmox_version'] is not None and (0 <= age < _AVAILABLE_UPDATES_MIN_INTERVAL or + (stamp == _system_info_cache.get('proxmox_version_stamp') and 0 <= age < _SYSTEM_INFO_CACHE_TTL)): return _system_info_cache['proxmox_version'] proxmox_version = None @@ -3530,8 +3533,11 @@ def get_proxmox_version(): except Exception as e: pass + if proxmox_version is None: + return None # Keep last-known cache intact, but do not present it as fresh. _system_info_cache['proxmox_version'] = proxmox_version _system_info_cache['proxmox_version_time'] = now + _system_info_cache['proxmox_version_stamp'] = stamp return proxmox_version def _apt_state_stamp(): diff --git a/AppImage/scripts/flask_terminal_routes.py b/AppImage/scripts/flask_terminal_routes.py index f38eeb53..062c6b97 100644 --- a/AppImage/scripts/flask_terminal_routes.py +++ b/AppImage/scripts/flask_terminal_routes.py @@ -215,6 +215,22 @@ def host_update_jobs_status(): return jsonify({'jobs': list_jobs()}) +@terminal_bp.route('/api/host-update-jobs//result', methods=['GET']) +@require_admin_scope +def host_update_result(run_id): + """Read a completed owner's retained output; never create or resume a job.""" + from host_update_jobs import get + try: + view = get(run_id).read(0) + except (ValueError, KeyError): + return jsonify({'error': 'Host update result is unavailable'}), 404 + if view['state'] != 'complete' or type(view['exit_code']) is not int: + return jsonify({'error': 'Host update has not completed'}), 409 + response = jsonify({key: view[key] for key in ('state', 'exit_code', 'text', 'truncated')}) + response.headers['Cache-Control'] = 'no-store' + return response + + @sock.route('/ws/host-update/') def host_update_viewer(ws, run_id): """New fixed-script contract; existing script/LXC routes stay intact.""" diff --git a/tests/test_host_update_jobs.py b/tests/test_host_update_jobs.py index c690f215..4b4b7e90 100644 --- a/tests/test_host_update_jobs.py +++ b/tests/test_host_update_jobs.py @@ -249,7 +249,7 @@ class Routes(unittest.TestCase): _TICKET_MAX_INFLIGHT=256,time=time,secrets=__import__('secrets'),json=json, _host_update_completed=self.callback) names={'_issue_terminal_ticket','_consume_terminal_ticket','_ws_auth_check', - 'issue_terminal_ticket_route','host_update_jobs_status','host_update_viewer'} + 'issue_terminal_ticket_route','host_update_jobs_status','host_update_result','host_update_viewer'} nodes=[n for n in ast.parse((ROOT/'AppImage/scripts/flask_terminal_routes.py').read_text()).body if isinstance(n,ast.FunctionDef) and n.name in names] self.assertEqual(len(nodes),len(names)) exec(compile(ast.Module(body=nodes,type_ignores=[]),'actual-host-routes','exec'),scope) @@ -277,10 +277,33 @@ class Routes(unittest.TestCase): with patch.object(jobs.subprocess,'Popen') as spawn: self.assertEqual(self.ticket(token).status_code,status) self.assertEqual(self.client.get('/api/host-update-jobs',headers={'Authorization':'Bearer '+token}).status_code,status) + self.assertEqual(self.client.get('/api/host-update-jobs/fixture/result',headers={'Authorization':'Bearer '+token}).status_code,status) self.attach(dict(resume=False)) spawn.assert_not_called() self.assertEqual(jobs.list_jobs(),[]) + def test_finished_result_is_read_only_and_bounded_by_owner_output(self): + view=dict(state='complete',exit_code=0,text='inert saved output',truncated=False) + with patch.object(jobs,'get',return_value=SimpleNamespace(read=Mock(return_value=view))) as get,patch.object(jobs,'start') as start,patch.object(jobs.subprocess,'Popen') as spawn: + response=self.client.get('/api/host-update-jobs/fixture/result',headers={'Authorization':'Bearer valid'}) + self.assertEqual(response.status_code,200) + self.assertEqual(response.json,view) + self.assertEqual(response.headers['Cache-Control'],'no-store') + get.assert_called_once_with('fixture');start.assert_not_called();spawn.assert_not_called() + + def test_result_auth_missing_invalid_running_never_starts_job(self): + with patch.object(jobs,'get') as get,patch.object(jobs,'start') as start,patch.object(jobs.subprocess,'Popen') as spawn: + self.assertEqual(self.client.get('/api/host-update-jobs/fixture/result').status_code,401) + get.assert_not_called() + get.side_effect=KeyError('missing') + self.assertEqual(self.client.get('/api/host-update-jobs/missing/result',headers={'Authorization':'Bearer valid'}).status_code,404) + get.side_effect=ValueError('invalid') + self.assertEqual(self.client.get('/api/host-update-jobs/invalid/result',headers={'Authorization':'Bearer valid'}).status_code,404) + get.side_effect=None + for state in ('running','attention','finalizing'): + get.return_value=SimpleNamespace(read=Mock(return_value=dict(state=state,exit_code=None))) + self.assertEqual(self.client.get('/api/host-update-jobs/fixture/result',headers={'Authorization':'Bearer valid'}).status_code,409) + start.assert_not_called();spawn.assert_not_called() def test_real_route_disconnect_401_then_relogin_replays_one_job(self): self.attach(dict(resume=False),self.ticket().json['ticket']) job=jobs.get('job-1') diff --git a/tests/test_host_update_transport.py b/tests/test_host_update_transport.py index 2a218d8c..07c7c4c0 100644 --- a/tests/test_host_update_transport.py +++ b/tests/test_host_update_transport.py @@ -78,6 +78,10 @@ def wire_fixture(): assert events.read_text().splitlines() == ['start', 'poststep'] assert len(callbacks) == 1 assert 'completed' in ''.join(frame.get('text', '') for frame in frames) + saved=requests.get(base+'/api/host-update-jobs/one-host-run/result',headers=headers,timeout=3) + assert saved.status_code==200 and saved.headers['Cache-Control']=='no-store' + assert saved.json()['exit_code']==0 and 'completed' in saved.json()['text'] + assert events.read_text().splitlines()==['start','poststep'] and len(callbacks)==1 if viewer.connected: viewer.close() viewer = connect('missing-host-run', True) diff --git a/tests/test_host_update_ui.cjs b/tests/test_host_update_ui.cjs index 0fae438d..8760504c 100644 --- a/tests/test_host_update_ui.cjs +++ b/tests/test_host_update_ui.cjs @@ -60,4 +60,36 @@ const en=JSON.parse(fs.readFileSync(path.join(app,'messages/en/common.json'),'ut const sk=JSON.parse(fs.readFileSync(path.join(app,'messages/sk/common.json'),'utf8')) assert.deepEqual(Object.keys(en.healthStatus.hostUpdate).sort(),Object.keys(sk.healthStatus.hostUpdate).sort()) assert.ok(health.includes('Number(categoryData?.count ?? 0) > 0'),'reboot-only state has no Update Now action') -console.log('PASS host initial/resume transport, legacy/LXC routes, disconnect/unmount and EN/SK keys') +const savedTree=ts.createSourceFile('result.tsx',fs.readFileSync(path.join(app,'components/host-update-result.tsx'),'utf8'),ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX) +let readResult +function resultWalk(n){ + if(ts.isArrowFunction(n)&&n.modifiers?.some(m=>m.kind===ts.SyntaxKind.AsyncKeyword)&&n.getText(savedTree).includes('value.state'))readResult=n.getText(savedTree) + ts.forEachChild(n,resultWalk) +} +resultWalk(savedTree);assert.ok(readResult) +async function results(){ + const response={state:'complete',exit_code:0,text:'inert saved result',truncated:false} + for(const code of [0,1,130,-15]){ + const calls=[],writes=[],codes=[],errors=[] + const scope={getAuthToken:()=>null,getApiUrl:p=>p,jobId:'saved-id',controller:new AbortController(),container:{current:{}},disposed:false, + fetch:async(p,options)=>{calls.push({p,options});return {ok:true,json:async()=>({...response,exit_code:code})}}, + require:n=>n==='xterm'?{Terminal:class{loadAddon(){}open(){}write(v){writes.push(v)}writeln(v){writes.push(v)}}}:{FitAddon:class{fit(){}}}, + ResizeObserver:class{observe(){}},setExitCode:v=>codes.push(v),setFailed:v=>errors.push(v),clearTimeout(){},timeout:1,t:s=>s} + await compile(readResult,scope)() + assert.deepEqual(codes,[code]);assert.deepEqual(errors,[]);assert.deepEqual(writes,['inert saved result']) + assert.equal(calls.length,1);assert.equal(calls[0].p,'/api/host-update-jobs/saved-id/result');assert.ok(!calls[0].options.method||calls[0].options.method==='GET') + for(const value of [null,{...response,state:'running'},{...response,exit_code:false},{...response,text:null},{...response,truncated:null}]){ + codes.length=errors.length=writes.length=0 + scope.fetch=async()=>({ok:true,json:async()=>value});await compile(readResult,scope)() + assert.deepEqual(codes,[]);assert.deepEqual(writes,[]);assert.deepEqual(errors,[true]) + } + codes.length=errors.length=writes.length=0 + scope.fetch=async()=>({ok:false});await compile(readResult,scope)() + assert.deepEqual(errors,[true]);assert.deepEqual(codes,[]);assert.deepEqual(writes,[]) + codes.length=errors.length=writes.length=0;scope.disposed=true;scope.controller.abort() + scope.fetch=async()=>({ok:true,json:async()=>response});await compile(readResult,scope)() + assert.deepEqual(codes,[]);assert.deepEqual(errors,[]);assert.deepEqual(writes,[],'closed result cannot receive a late response') + } + console.log('PASS host/legacy/LXC transports and actual readonly result GET: success/error/cancel/invalid/late-response; zero WS/start callbacks') +} +results().catch(error=>{console.error(error);process.exitCode=1}) diff --git a/tests/test_host_update_version.py b/tests/test_host_update_version.py new file mode 100644 index 00000000..b84cd31c --- /dev/null +++ b/tests/test_host_update_version.py @@ -0,0 +1,52 @@ +"""Actual installed-version function; package changes invalidate its old display.""" +import ast +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest.mock import Mock + +ROOT = Path(__file__).resolve().parents[1] + + +class VersionCache(unittest.TestCase): + def setUp(self): + source = ast.parse((ROOT/'AppImage/scripts/flask_server.py').read_text()) + node = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == 'get_proxmox_version') + self.cache = dict(proxmox_version='9.2.21', proxmox_version_time=100, proxmox_version_stamp=1) + self.command = Mock(return_value=SimpleNamespace(returncode=0, stdout='pve-manager/9.2.24/hash\n')) + self.stamp = Mock(return_value=1) + self.scope = dict(is_pbs_host=Mock(return_value=False), get_platform=Mock(return_value={'version':'4.1.0'}), + _system_info_cache=self.cache, _SYSTEM_INFO_CACHE_TTL=21600, + _AVAILABLE_UPDATES_MIN_INTERVAL=30, _apt_state_stamp=self.stamp, + time=SimpleNamespace(time=Mock(return_value=200)), subprocess=SimpleNamespace(run=self.command)) + exec(compile(ast.Module(body=[node], type_ignores=[]), 'actual-installed-version', 'exec'), self.scope) + + def test_unchanged_package_state_has_zero_commands(self): + self.assertEqual(self.scope['get_proxmox_version'](), '9.2.21') + self.command.assert_not_called() + + def test_package_change_at_floor_refreshes_version_without_apt(self): + self.stamp.return_value=2 + self.scope['time'].time.return_value=129.999 + self.assertEqual(self.scope['get_proxmox_version'](), '9.2.21') + self.command.assert_not_called() + self.scope['time'].time.return_value=130 + self.assertEqual(self.scope['get_proxmox_version'](), '9.2.24') + self.command.assert_called_once_with(['pveversion'],capture_output=True,text=True,timeout=5) + self.assertEqual(self.cache['proxmox_version_stamp'],2) + + def test_failed_read_returns_unknown_but_keeps_last_known_cache(self): + self.stamp.return_value=2 + before=dict(self.cache) + self.command.side_effect=OSError('inert unavailable command') + self.assertIsNone(self.scope['get_proxmox_version']()) + self.assertEqual(self.cache,before) + + def test_pbs_guard_precedes_all_pve_package_probes(self): + self.scope['is_pbs_host'].return_value=True + self.assertEqual(self.scope['get_proxmox_version'](),'4.1.0') + self.stamp.assert_not_called();self.command.assert_not_called() + + +if __name__=='__main__': + unittest.main(verbosity=2) diff --git a/tests/test_reboot_health_refresh.py b/tests/test_reboot_health_refresh.py index 84d24b34..56bbd097 100644 --- a/tests/test_reboot_health_refresh.py +++ b/tests/test_reboot_health_refresh.py @@ -143,6 +143,29 @@ class FullHealth(unittest.TestCase): self.assertEqual(code, 200) self.owner.get_detailed_status.assert_called_once() + def test_explicit_refresh_publishes_then_poll_never_rechecks(self): + result, code = self.call('?refresh=1') + self.assertEqual(code, 200) + self.assertIn('_bg_detailed', self.owner.cached_results) + self.assertEqual(self.owner.cached_results['_bg_overall']['status'],result['health']['overall']) + self.assertEqual(self.owner.last_check_times['_bg_overall'],self.owner.last_check_times['_bg_detailed']) + before = copy.deepcopy(self.owner.cached_results) + result, code = self.call('?cached=1') + self.assertEqual(code, 200) + self.assertEqual(result['health']['timestamp'], 'original') + self.assertEqual(self.owner.cached_results, before) + self.owner.get_detailed_status.assert_called_once() + + def test_invalid_live_result_never_publishes_or_becomes_fresh(self): + for result in (None, {}, {'details': {}}, {'details': {'updates': {'status': 'OK'}}}): + with self.subTest(result=result): + self.owner.cached_results.clear() + self.owner.last_check_times.clear() + self.owner.get_detailed_status.return_value = result + self.assertEqual(self.call()[1], 500) + self.assertNotIn('_bg_detailed', self.owner.cached_results) + self.assertNotIn('_bg_detailed', self.owner.last_check_times) + class Overlay(unittest.TestCase): def test_repeated_required_unknown_current_preserves_package_result_and_snapshot(self): From 9147ec2f52426cd83dec520b226295623bc9bd18 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:08:45 +0000 Subject: [PATCH 11/13] fix: keep reboot actions blocked during health recovery --- .github/scripts/run_offline_qualified.py | 1 + AppImage/components/health-status-modal.tsx | 7 +-- AppImage/components/host-update-status.tsx | 15 +++--- tests/test_host_update_controls.cjs | 6 +-- tests/test_reboot_recovery.cjs | 51 +++++++++++++++++++++ 5 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 tests/test_reboot_recovery.cjs diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index 2af5a61b..580fb866 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -46,6 +46,7 @@ NODE_FILES = ( "tests/test_host_update_ui.cjs", "tests/test_reboot_health_refresh.cjs", "tests/test_host_update_controls.cjs", + "tests/test_reboot_recovery.cjs", "tests/test_lxc_update_jobs.cjs", "tests/test_api_import_boundaries.cjs", "tests/test_api_errors.cjs", diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index b554e18e..f4f90bc3 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -720,6 +720,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu .map(([checkKey, checkData]) => { if (categoryKey === "updates" && checkKey === "reboot_required" && checkData.status === "INFO") { return job.state !== "complete")} label={formatCheckLabel(checkKey)} detail={translateHealthText(checkData.detail)} icon={getStatusIcon(checkData.status, "sm")} /> } @@ -817,8 +818,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu )} - {healthData && !loading && ( -
+ {healthData && ( +
{/* Overall Stats Summary */}
0 ? "grid-cols-5" : "grid-cols-4"}`}>
@@ -1069,7 +1070,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu {t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })}
)} -
+
)} setResultJobId(null)} /> diff --git a/AppImage/components/host-update-status.tsx b/AppImage/components/host-update-status.tsx index 20693da3..6b92db4a 100644 --- a/AppImage/components/host-update-status.tsx +++ b/AppImage/components/host-update-status.tsx @@ -14,8 +14,8 @@ type Url = (path: string) => string type Context = { hostname: string; boot_id: string; allowed: boolean; reason: string } const compact = "h-10 sm:h-[34px] px-3 text-xs" -export function RestartAfterUpdate({ getApiUrl, disabled, label, detail, icon }: { - getApiUrl: Url; disabled: boolean; label: string; detail: string; icon: ReactNode +export function RestartAfterUpdate({ getApiUrl, disabled, unavailable = false, label, detail, icon }: { + getApiUrl: Url; disabled: boolean; unavailable?: boolean; label: string; detail: string; icon: ReactNode }) { const t = useT() const [open, setOpen] = useState(false) @@ -29,7 +29,6 @@ export function RestartAfterUpdate({ getApiUrl, disabled, label, detail, icon }: const postRequest = useRef(null) useEffect(() => { alive.current = true; return () => { alive.current = false; postRequest.current?.abort() } }, []) useEffect(() => { - if (!open) return let disposed = false, reading = false let active: AbortController | undefined setContext(null); setSubmitted(false); setError(false); sending.current = false @@ -48,7 +47,9 @@ export function RestartAfterUpdate({ getApiUrl, disabled, label, detail, icon }: finally { clearTimeout(timeout); reading = false } } void read() - const interval = setInterval(read, 2000) + // A retained health snapshot must never grant permission after reconnect + // or remount. This cheap read does not run APT or a full health check. + const interval = setInterval(read, open ? 2000 : 30000) return () => { disposed = true; clearInterval(interval); active?.abort() } }, [open, getApiUrl]) useEffect(() => { @@ -70,7 +71,7 @@ export function RestartAfterUpdate({ getApiUrl, disabled, label, detail, icon }: return () => { disposed = true; controller.abort(); clearInterval(interval); clearTimeout(timeout) } }, [accepted, getApiUrl]) const confirm = async () => { - if (disabled || !context?.allowed || sending.current || submitted) return + if (disabled || unavailable || !context?.allowed || sending.current || submitted) return sending.current = true; setSubmitted(true); setPending(true); setError(false) const controller = new AbortController(); postRequest.current = controller const timeout = setTimeout(() => controller.abort(), 15000) @@ -88,7 +89,7 @@ export function RestartAfterUpdate({ getApiUrl, disabled, label, detail, icon }:
{icon}
{label}{detail}
+ disabled={disabled || unavailable || !context?.allowed || pending || accepted} onClick={() => setOpen(true)}>{t("healthStatus.restart.button")}
{disabled &&

{t("healthStatus.restart.blocked")}

} {accepted &&

{t("healthStatus.restart.accepted")}

} @@ -103,7 +104,7 @@ export function RestartAfterUpdate({ getApiUrl, disabled, label, detail, icon }: {(error || context?.reason === "unknown") &&

{t("healthStatus.restart.unconfirmed")}

}
-
diff --git a/tests/test_host_update_controls.cjs b/tests/test_host_update_controls.cjs index 460c5816..b877ed21 100644 --- a/tests/test_host_update_controls.cjs +++ b/tests/test_host_update_controls.cjs @@ -13,7 +13,7 @@ function walk(n){ walk(tree);assert.ok(callback) function fixture(){ const calls=[],events=[] - const scope={AbortController,JSON,Error,disabled:false,context:{allowed:true,hostname:'fixture-host',boot_id:'fixture-boot'},submitted:false, + const scope={AbortController,JSON,Error,disabled:false,unavailable:false,context:{allowed:true,hostname:'fixture-host',boot_id:'fixture-boot'},submitted:false, sending:{current:false},alive:{current:true},postRequest:{current:null},getApiUrl:p=>p,headers:()=>({}), setSubmitted:v=>events.push(['submitted',v]),setPending:v=>events.push(['pending',v]),setError:v=>events.push(['error',v]), setAccepted:v=>events.push(['accepted',v]),setOpen:v=>events.push(['open',v]), @@ -23,8 +23,8 @@ function fixture(){ } async function main(){ assert.ok(triggerDisabled) - assert.equal(vm.runInNewContext(triggerDisabled,{disabled:false,accepted:false,pending:true}),true,'closing confirmation while its request is pending must not enable another request') - for(const block of ['disabled','submitted','sending','missing-context','not-allowed']){ + assert.equal(vm.runInNewContext(triggerDisabled,{disabled:false,unavailable:false,context:{allowed:true},accepted:false,pending:true}),true,'closing confirmation while its request is pending must not enable another request') + for(const block of ['disabled','unavailable','submitted','sending','missing-context','not-allowed']){ const x=fixture() if(block==='sending')x.scope.sending.current=true else if(block==='missing-context')x.scope.context=null diff --git a/tests/test_reboot_recovery.cjs b/tests/test_reboot_recovery.cjs new file mode 100644 index 00000000..2fe00d7f --- /dev/null +++ b/tests/test_reboot_recovery.cjs @@ -0,0 +1,51 @@ +// The retained health snapshot is informational, never permission to reboot. +const assert = require('node:assert/strict') +const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm') +const app = path.resolve(__dirname, '../AppImage') +const ts = require(path.join(app, 'node_modules/typescript')) +const source = fs.readFileSync(path.join(app, 'components/host-update-status.tsx'), 'utf8') +const tree = ts.createSourceFile('restart.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) +let trigger, contextEffect +function walk(n) { + if (ts.isJsxElement(n) && n.openingElement.tagName.getText(tree) === 'Button' && n.children.some(c => c.getText(tree).includes('healthStatus.restart.button'))) { + trigger = n.openingElement.attributes.properties.find(p => p.name?.getText(tree) === 'disabled').initializer.expression.getText(tree) + } + if (ts.isCallExpression(n) && n.expression.getText(tree) === 'useEffect' && n.arguments[0].getText(tree).includes('/api/health/reboot-context')) contextEffect = n.arguments[0].getText(tree) + ts.forEachChild(n, walk) +} +walk(tree) +const evaluate = (code, scope) => vm.runInNewContext(ts.transpileModule('(' + code + ')', {compilerOptions: {target: ts.ScriptTarget.ES2020}}).outputText, scope) +function enabled(overrides = {}) { + return !evaluate(trigger, {disabled: false, unavailable: false, context: {allowed: true}, pending: false, accepted: false, ...overrides}) +} +async function main() { + assert.equal(enabled({context: null}), false, 'remount after loading/reboot cannot enable a retained button before a fresh context') + for (const reason of ['current', 'busy', 'unknown']) assert.equal(enabled({context: {allowed: false, reason}}), false) + for (const key of ['disabled', 'unavailable', 'pending', 'accepted']) assert.equal(enabled({[key]: true}), false, key) + assert.equal(enabled(), true, 'fresh required context permits an explicit confirmation') + const calls = [], contexts = [], intervals = new Map(), timers = new Map() + let seq = 0 + const scope = {open: false, getApiUrl: p => p, headers: () => ({}), AbortController, + setContext: x => contexts.push(x), setSubmitted() {}, setError() {}, sending: {current: false}, + setTimeout: f => {timers.set(++seq, f); return seq}, clearTimeout: id => timers.delete(id), + setInterval: (f, delay) => {intervals.set(++seq, {f, delay}); return seq}, clearInterval: id => intervals.delete(id), + fetch: async (p, opts) => {calls.push({p, opts}); return {ok: true, json: async () => ({hostname: 'fixture', boot_id: 'new-boot', allowed: false, reason: 'current'})}}} + const cleanup = evaluate(contextEffect, scope)() + await new Promise(setImmediate) + assert.equal(calls.length, 1, 'read the real reboot context even while confirmation is closed') + assert.equal(calls[0].p, '/api/health/reboot-context') + assert.ok(!calls[0].opts.method || calls[0].opts.method === 'GET') + assert.equal(contexts.at(-1).allowed, false) + assert.equal([...intervals.values()][0].delay, 30000, 'closed confirmation uses a bounded cheap probe') + scope.fetch = async () => {throw Error('offline')} + await [...intervals.values()][0].f() + assert.equal(contexts.at(-1), null, 'connection loss revokes old permission') + cleanup() + assert.equal(intervals.size, 0); assert.equal(timers.size, 0) + const modal = fs.readFileSync(path.join(app, 'components/health-status-modal.tsx'), 'utf8') + assert.ok(modal.includes('unavailable={loading || snapshotUnavailable || !!error}'), 'stale/error/refresh snapshots must disable the action') + assert.ok(!modal.includes('{healthData && !loading && ('), 'refresh must not unmount and forget an accepted reboot') + assert.ok(modal.includes('
{console.error(e); process.exitCode = 1}) From 65b10cea0931431fa5c3e700283847cca8c0efae Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:28:05 +0000 Subject: [PATCH 12/13] ui: compact the current ProxMenux version status --- .github/scripts/run_offline_qualified.py | 1 + AppImage/components/host-update-status.tsx | 27 ++++++++++++-- AppImage/messages/en/common.json | 1 + AppImage/messages/sk/common.json | 1 + tests/test_proxmenux_version_row.cjs | 43 ++++++++++++++++++++++ 5 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 tests/test_proxmenux_version_row.cjs diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index 580fb866..2fb22bfa 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -47,6 +47,7 @@ NODE_FILES = ( "tests/test_reboot_health_refresh.cjs", "tests/test_host_update_controls.cjs", "tests/test_reboot_recovery.cjs", + "tests/test_proxmenux_version_row.cjs", "tests/test_lxc_update_jobs.cjs", "tests/test_api_import_boundaries.cjs", "tests/test_api_errors.cjs", diff --git a/AppImage/components/host-update-status.tsx b/AppImage/components/host-update-status.tsx index 6b92db4a..d74f6748 100644 --- a/AppImage/components/host-update-status.tsx +++ b/AppImage/components/host-update-status.tsx @@ -5,6 +5,7 @@ import * as Confirm from "@radix-ui/react-alert-dialog" import { Button } from "./ui/button" import { getAuthToken } from "@/lib/api-config" import { useT } from "@/lib/i18n/provider" +import { CheckCircle2, AlertTriangle, Info, ChevronRight } from "lucide-react" function headers() { const token = getAuthToken() @@ -145,14 +146,32 @@ export function ProxMenuxVersionCard({ getApiUrl }: { getApiUrl: Url }) { return () => { disposed = true; active?.abort(); clearInterval(interval); window.removeEventListener("focus", read); document.removeEventListener("visibilitychange", read) } }, [getApiUrl]) const state = value?.state ?? "unknown" - return
-

{t("healthStatus.versions.title")}

- {t(`healthStatus.versions.${state}`)}
-
+ const versions =
{t("healthStatus.versions.installed")}
{value?.installed ?? t("healthStatus.versions.unverified")}
{t(state === "unknown" && value?.available ? "healthStatus.versions.lastKnown" : "healthStatus.versions.latest")}
{value?.available ?? t("healthStatus.versions.unverified")}
+ const metadata = <> {value &&

{t(`healthStatus.versions.${value.channel}`)}

} {value?.checked_at &&

{t("healthStatus.versions.checked", { time: new Date(value.checked_at).toLocaleString() })}

} + + if (state !== "available") return
+ + {state === "current" ? +
+ {state === "unknown" && versions} + {metadata} +
+
+ return
+

{t("healthStatus.versions.title")}

+
+ {versions} + {metadata}
} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index d61972ef..ac9d283b 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -5326,6 +5326,7 @@ "lastKnown": "Last known available version", "available": "Update available", "current": "ProxMenux is up to date", + "currentVersion": "Version {version} is up to date", "unknown": "Could not verify the available version", "unverified": "Unverified", "stable": "Stable channel", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 290fdbd5..2d82db87 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -5198,6 +5198,7 @@ "lastKnown": "Posledná známa dostupná verzia", "available": "Dostupná aktualizácia", "current": "ProxMenux je aktuálny", + "currentVersion": "Verzia {version} je aktuálna", "unknown": "Dostupnú verziu sa nepodarilo overiť", "unverified": "Neoverené", "stable": "Stabilný kanál", diff --git a/tests/test_proxmenux_version_row.cjs b/tests/test_proxmenux_version_row.cjs new file mode 100644 index 00000000..dcedb123 --- /dev/null +++ b/tests/test_proxmenux_version_row.cjs @@ -0,0 +1,43 @@ +// Render the shipped component with an inert snapshot; no network or power. +const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm') +const app=path.resolve(__dirname,'../AppImage'),ts=require(path.join(app,'node_modules/typescript')) +const react=require(path.join(app,'node_modules/react')) +const {renderToStaticMarkup}=require(path.join(app,'node_modules/react-dom/server')) +const code=ts.transpileModule(fs.readFileSync(path.join(app,'components/host-update-status.tsx'),'utf8'),{ + compilerOptions:{target:ts.ScriptTarget.ES2020,module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX,esModuleInterop:true}}).outputText +for(const locale of ['en','sk']){ + const catalog=JSON.parse(fs.readFileSync(path.join(app,'messages',locale,'common.json'),'utf8')) + const t=(key,params={})=>{const text=key.split('.').reduce((x,k)=>x?.[k],catalog);assert.equal(typeof text,'string',key);return text.replace(/\{(\w+)\}/g,(_,k)=>params[k]??'{'+k+'}')} + const versions=catalog.healthStatus.versions + let value, effects=0 + const exports={} + vm.runInNewContext(code,{exports,require:name=>{ + if(name==='react')return {...react,useState:()=>[value,()=>{}],useEffect:()=>{},useRef:()=>({current:null})} + if(name==='react/jsx-runtime')return require(path.join(app,'node_modules/react/jsx-runtime')) + if(name==='@radix-ui/react-alert-dialog'||name==='./ui/button')return {} + if(name==='@/lib/i18n/provider')return {useT:()=>t} + if(name==='@/lib/api-config')return {getAuthToken:()=>null} + if(name==='lucide-react')return require(path.join(app,'node_modules/lucide-react')) + throw Error('Unexpected boundary '+name) + },fetch:()=>{effects++;throw Error('Forbidden request')},console}) + const snapshot={installed:'1.2.7',available:'1.2.7',state:'current',channel:'stable',checked_at:'2026-10-28T08:00:00Z'} + const render=data=>{value=data;return renderToStaticMarkup(react.createElement(exports.ProxMenuxVersionCard,{getApiUrl:p=>p}))} + const current=render(snapshot) + assert.ok(current.includes(''),'up-to-date version has no expanded update card') + assert.ok(current.includes('1.2.7')&¤t.includes('text-green-500')) + assert.ok(current.includes(versions.stable)&¤t.includes(versions.checked.split('{')[0])) + const available=render({...snapshot,state:'available',available:'1.2.8'}) + assert.ok(available.includes(' Date: Wed, 28 Oct 2026 16:54:36 +0000 Subject: [PATCH 13/13] fix: distinguish the waiting unattended upgrades shutdown helper --- AppImage/scripts/host_update_controls.py | 16 ++- tests/test_host_update_controls.py | 122 +++++++++++++++++++++-- tests/test_host_update_shell.py | 18 +++- 3 files changed, 142 insertions(+), 14 deletions(-) diff --git a/AppImage/scripts/host_update_controls.py b/AppImage/scripts/host_update_controls.py index c2d78179..5c45a529 100644 --- a/AppImage/scripts/host_update_controls.py +++ b/AppImage/scripts/host_update_controls.py @@ -29,10 +29,22 @@ def package_process_active(): if not path.name.isdigit(): continue try: - if (path/'comm').read_text().strip() in ('apt', 'apt-get', 'dpkg', 'unattended-upgr'): + name = (path/'comm').read_text().strip() + if name == 'unattended-upgr': + # Linux comm truncates both the updater and its persistent + # shutdown helper to 15 bytes. Exempt only the exact idle + # service command (also with its system Python shebang). + argv = (path/'cmdline').read_bytes().split(b'\0') + helper = b'/usr/share/unattended-upgrades/unattended-upgrade-shutdown' + if argv in ([helper, b'--wait-for-signal', b''], + [b'/usr/bin/python3', helper, b'--wait-for-signal', b'']): + continue + return True + if name in ('apt', 'apt-get', 'dpkg'): return True except (FileNotFoundError, ProcessLookupError): - continue + if path.exists(): + return True except OSError: return True return False diff --git a/tests/test_host_update_controls.py b/tests/test_host_update_controls.py index 7a7a1ed2..81b29151 100644 --- a/tests/test_host_update_controls.py +++ b/tests/test_host_update_controls.py @@ -10,6 +10,7 @@ import sys import tempfile from types import ModuleType, SimpleNamespace import unittest +from contextlib import contextmanager from unittest.mock import Mock, patch from flask import Flask @@ -19,6 +20,10 @@ sys.path.insert(0, str(ROOT/'AppImage/scripts')) import host_update_controls as controls import proxmenux_version_status as versions +SHUTDOWN_HELPER = b'/usr/share/unattended-upgrades/unattended-upgrade-shutdown' +WAITING_HELPER = b'/usr/bin/python3\0' + SHUTDOWN_HELPER + b'\0--wait-for-signal\0' +REAL_UPDATER = b'/usr/bin/python3\0/usr/bin/unattended-upgrade\0' + class VersionStatus(unittest.TestCase): def setUp(self): @@ -103,6 +108,68 @@ class RebootGuards(unittest.TestCase): self.probe=patch.object(controls,'reboot_status',return_value={'state':'required'}).start() self.addCleanup(patch.stopall) + def package(self, command=WAITING_HELPER, name='unattended-upgr', pid='123'): + path=self.root/'proc'/pid;path.mkdir(exist_ok=True) + (path/'comm').write_text(name+'\n') + (path/'cmdline').write_bytes(command) + return path + + def test_waiting_shutdown_helper_does_not_block_required_reboot_or_run_commands(self): + # Debian's service ExecStart, including the /usr/bin/python3 shebang. + for command in (WAITING_HELPER, SHUTDOWN_HELPER+b'\0--wait-for-signal\0'): + with self.subTest(command=command),patch.object(controls.subprocess,'run') as run: + self.package(command) + self.assertEqual(controls.reboot_context()['reason'],'required') + run.assert_not_called() + + def test_actual_package_tools_and_unattended_updater_still_block_before_probe(self): + for name in ('apt','apt-get','dpkg','unattended-upgr'): + with self.subTest(name=name): + self.package(REAL_UPDATER,name) + self.assertEqual(controls.reboot_context()['reason'],'busy') + self.probe.assert_not_called() + + def test_waiting_helper_never_hides_another_active_updater(self): + self.package() + self.package(REAL_UPDATER,pid='124') + self.assertEqual(controls.reboot_context()['reason'],'busy') + self.probe.assert_not_called() + + def test_helper_without_wait_flag_and_lookalike_arguments_stay_busy(self): + commands=(SHUTDOWN_HELPER+b'\0', + b'/usr/bin/unattended-upgrade\0--wait-for-signal\0', + b'/usr/bin/unattended-upgrade\0'+SHUTDOWN_HELPER+b'\0--wait-for-signal\0', + b'/bin/sh\0-c\0'+SHUTDOWN_HELPER+b' --wait-for-signal\0', + b'/usr/bin/python3\0-c\0'+SHUTDOWN_HELPER+b'\0--wait-for-signal\0', + WAITING_HELPER+b'--unexpected\0', + b'/tmp/unattended-upgrade-shutdown\0--wait-for-signal\0') + for command in commands: + with self.subTest(command=command): + self.package(command) + self.assertEqual(controls.reboot_context()['reason'],'busy') + self.probe.assert_not_called() + + def test_empty_partial_malformed_and_missing_live_cmdline_stay_busy(self): + for command in (b'',b'\0',WAITING_HELPER[:-1],WAITING_HELPER+b'\0',b'\xff\0'): + with self.subTest(command=command): + self.package(command) + self.assertEqual(controls.reboot_context()['reason'],'busy') + (self.root/'proc/123/cmdline').unlink() + self.assertEqual(controls.reboot_context()['reason'],'busy') + self.probe.assert_not_called() + + def test_unreadable_cmdline_blocks_but_process_that_exits_can_be_skipped(self): + process=self.package() + with patch.object(Path,'read_bytes',side_effect=PermissionError('fixture denied')): + self.assertEqual(controls.reboot_context()['reason'],'busy') + self.probe.assert_not_called() + def exited(): + for path in process.iterdir():path.unlink() + process.rmdir() + raise FileNotFoundError('fixture exited after comm read') + with patch.object(Path,'read_bytes',side_effect=exited): + self.assertTrue(controls.reboot_context()['allowed']) + def test_active_attention_starting_and_unreadable_jobs_block_before_kernel_probe(self): for state in ('starting','running','attention','finalizing',None): self.jobs.return_value=[{'state':state}] @@ -163,14 +230,17 @@ class RebootGuards(unittest.TestCase): for path in paths:Path(path).touch() # A separate process holds the same POSIX lock, just as APT does. code='import fcntl,sys,time; f=open(sys.argv[1],"r+"); fcntl.lockf(f,fcntl.LOCK_EX); print("locked",flush=True); time.sleep(5)' - child=subprocess.Popen([sys.executable,'-c',code,paths[0]],stdout=subprocess.PIPE,text=True) - try: - self.assertEqual(child.stdout.readline().strip(),'locked') - with patch.object(controls,'PACKAGE_LOCKS',paths),patch.object(controls.subprocess,'run') as reboot: - self.assertEqual(controls.execute_reboot(),75) - reboot.assert_not_called() - finally: - child.terminate();child.wait(timeout=2);child.stdout.close() + for held in paths: + child=subprocess.Popen([sys.executable,'-c',code,held],stdout=subprocess.PIPE,text=True) + try: + self.assertEqual(child.stdout.readline().strip(),'locked') + with patch.object(controls,'PACKAGE_LOCKS',paths),patch.object(controls.subprocess,'run') as reboot: + self.assertEqual(controls.execute_reboot(),75) + reboot.assert_not_called() + self.probe.assert_not_called() + self.assertEqual([Path(path).read_bytes() for path in paths],[b'',b'']) + finally: + child.terminate();child.wait(timeout=2);child.stdout.close() class Routes(unittest.TestCase): @@ -191,6 +261,7 @@ class Routes(unittest.TestCase): self.app=Flask(__name__);self.app.register_blueprint(routes.health_bp) self.run=Mock(return_value=(True,{}));self.state=Mock(return_value={'state':'running'}) self.app.extensions['host_update_power']={'run':self.run,'state':self.state} + self.real_context=controls.reboot_context self.context=patch.object(controls,'reboot_context',return_value={'allowed':True,'reason':'required','boot_id':'fixture-boot'}).start() self.version=patch.object(versions,'version_status',return_value={'state':'unknown'}).start() self.addCleanup(patch.stopall) @@ -198,6 +269,41 @@ class Routes(unittest.TestCase): import socket self.body={'confirm':True,'hostname':socket.gethostname(),'boot_id':'fixture-boot'} + @contextmanager + def actual_package_context(self,command): + with tempfile.TemporaryDirectory() as folder: + root=Path(folder);proc=root/'proc';(proc/'123').mkdir(parents=True) + (proc/'123/comm').write_text('unattended-upgr\n') + (proc/'123/cmdline').write_bytes(command) + boot=root/'boot_id';boot.write_text('fixture-boot') + with patch.object(controls,'PROC',proc),patch.object(controls,'BOOT_ID',boot),\ + patch.object(controls,'LOCK',str(root/'missing-lock')),\ + patch.object(controls,'list_jobs',return_value=[]),\ + patch.object(controls,'reboot_status',return_value={'state':'required'}) as probe,\ + patch.object(controls,'reboot_context',wraps=self.real_context): + yield probe + + def test_actual_context_routes_allow_waiting_helper_and_only_queue_guarded_action(self): + with self.actual_package_context(WAITING_HELPER): + response=self.client.get('/api/health/reboot-context',headers={'Authorization':'Bearer admin'}) + self.assertEqual(response.status_code,200) + self.assertTrue(response.json['allowed']) + self.run.assert_not_called() + response=self.client.post('/api/health/reboot-after-update',json=self.body,headers={'Authorization':'Bearer admin'}) + self.assertEqual(response.status_code,202) + self.run.assert_called_once_with(controls.UNIT,list(controls.COMMAND),description='ProxMenux: reboot after host update',extra_properties=['RuntimeMaxSec=75']) + + def test_actual_context_routes_deny_active_or_uncertain_updater_without_launch(self): + for command in (REAL_UPDATER,b'',WAITING_HELPER[:-1]): + with self.subTest(command=command),self.actual_package_context(command) as probe: + response=self.client.get('/api/health/reboot-context',headers={'Authorization':'Bearer admin'}) + self.assertEqual(response.status_code,200) + self.assertEqual(response.json['reason'],'busy') + response=self.client.post('/api/health/reboot-after-update',json=self.body,headers={'Authorization':'Bearer admin'}) + self.assertEqual(response.status_code,409) + self.assertEqual(response.json['reason'],'busy') + probe.assert_not_called();self.run.assert_not_called() + def test_denied_auth_and_methods_do_not_probe_or_launch(self): for token in (None,'bad','reader'): headers={'Authorization':'Bearer '+token} if token else {} diff --git a/tests/test_host_update_shell.py b/tests/test_host_update_shell.py index 9728ff6c..699dc58a 100644 --- a/tests/test_host_update_shell.py +++ b/tests/test_host_update_shell.py @@ -5,6 +5,7 @@ are inert executables which record calls; no root, network or host update. """ import os from pathlib import Path +import re import subprocess import sys import tempfile @@ -70,12 +71,18 @@ msg_success() { echo "$*"; } show_proxmenux_logo() { :; } LOCAL_SCRIPTS="$FIXTURE/scripts" ''') + # Keep the real confirmation contract added upstream; stub only the + # package/host effects, not the updater's shared decision helper. + confirmation = re.search(r'^pmx_confirm_update\(\) \{\n.*?^\}\n', + (ROOT / 'scripts/global/common-functions.sh').read_text(), + re.MULTILINE | re.DOTALL) + self.assertIsNotNone(confirmation) (scripts / 'global/common-functions.sh').write_text(''' pmx_format_upgradable() { cat; } pmx_dpkg_changes_since() { echo 'package changes fixture'; } lvm_repair_check() { echo lvm-poststep >> "$CALLS"; } pmx_rebuild_dkms_after_kernel() { echo dkms-poststep >> "$CALLS"; } -''') +''' + confirmation.group(0)) (scripts / 'global/utils-install-functions.sh').write_text('ensure_repositories() { return 0; }\n') commands = { 'pveversion': "echo 'pve-manager/9.2.24/test (running kernel: 7.0.14-25-pve)'", @@ -93,7 +100,9 @@ else exit "${UPGRADE_EXIT:-0}" fi''', 'dialog': 'echo dialog >> "$CALLS"; exit "${CONFIRM_EXIT:-0}"', - 'whiptail': 'echo "whiptail $*" >> "$CALLS"; exit 1', + 'whiptail': '''echo "whiptail $*" >> "$CALLS" +if [[ "$1" == --title && "$2" == "Proxmox Update" ]]; then exit "${CONFIRM_EXIT:-0}"; fi +exit 1''', 'reboot': 'echo FORBIDDEN-REBOOT >> "$CALLS"; exit 99', 'clear': ':', } @@ -139,7 +148,8 @@ fi''', self.assertIn('apt-get -y autoremove', calls) self.assertIn('apt-get -y autoclean', calls) self.assertNotIn('FORBIDDEN-REBOOT', calls) - self.assertNotIn('whiptail', calls) + self.assertIn('whiptail --title Proxmox Update --yesno', calls) + self.assertNotIn('whiptail --title Reboot Required', calls) self.assertIn('Reboot Required', output) self.assertIn('You can reboot later manually.', output) self.assertFalse((self.folder / 'marker-in-progress').exists()) @@ -161,7 +171,7 @@ fi''', proc = self.start(UPGRADE_EXIT='1') output, _ = proc.communicate(timeout=5) self.assertEqual(proc.returncode, 1, output) - for forbidden in ('autoremove', 'autoclean', 'REBOOT', 'poststep', 'whiptail'): + for forbidden in ('autoremove', 'autoclean', 'REBOOT', 'poststep', 'whiptail --title Reboot Required'): self.assertNotIn(forbidden, self.calls.read_text()) def test_old_menu_cli_contract_retains_explicit_reboot_prompt(self):