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)