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] 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):