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)