fix: refresh reboot evidence after external host updates

This commit is contained in:
VAIO73
2026-10-28 17:30:44 +00:00
parent f40e0a53e1
commit ac9565fa19
7 changed files with 434 additions and 21 deletions
+2
View File
@@ -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",
+47 -11
View File
@@ -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<HostUpdateJob[]>([])
const healthRequest = useRef<AbortController | null>(null)
const previousAutoExpanded = useRef<Set<string>>(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])
+28 -7
View File
@@ -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<AbortController | null>(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<typeof setInterval> | null = null
let healthInterval: ReturnType<typeof setInterval> | 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)
+17 -3
View File
@@ -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()
+37
View File
@@ -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
+130
View File
@@ -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})
+173
View File
@@ -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)