mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 18:32:13 +00:00
122 lines
5.8 KiB
Python
122 lines
5.8 KiB
Python
"""Read-only reboot evidence, independent of APT pending-update caches."""
|
|
import os
|
|
import copy
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
|
|
_RELEASE = re.compile(r"^(\d+)\.(\d+)\.(\d+)-(\d+)-pve$")
|
|
_PACKAGE = re.compile(r"^(?:proxmox|pve)-kernel-(\d+\.\d+\.\d+-\d+-pve)(?:-signed)?(?::[a-z0-9]+)?$")
|
|
|
|
|
|
def reboot_status():
|
|
"""No package refresh, installation, mount or bootloader operation."""
|
|
try:
|
|
running = os.uname().release
|
|
except OSError:
|
|
return dict(state="unknown", running_kernel=None, installed_kernel=None)
|
|
result = dict(state="unknown", running_kernel=running, installed_kernel=None)
|
|
if not isinstance(running, str) or not re.fullmatch(r'\d+\.\d+[A-Za-z0-9_.+\-]*', running):
|
|
return result
|
|
try:
|
|
if Path('/run/reboot-required').exists():
|
|
return dict(result, state="required", reason="marker")
|
|
except OSError:
|
|
return result
|
|
match = _RELEASE.fullmatch(running)
|
|
if not match:
|
|
if running.endswith('-pve'):
|
|
return result
|
|
return dict(result, state="not_applicable")
|
|
try:
|
|
proc = subprocess.run(
|
|
['dpkg-query', '-W', '-f=${db:Status-Status}\t${binary:Package}\n'],
|
|
capture_output=True, text=True, timeout=5)
|
|
if proc.returncode != 0 or not proc.stdout.strip():
|
|
return result
|
|
installed = []
|
|
valid_states = {'installed', 'config-files', 'not-installed', 'unpacked',
|
|
'half-configured', 'half-installed', 'triggers-awaited', 'triggers-pending'}
|
|
for line in proc.stdout.splitlines():
|
|
fields = line.split('\t')
|
|
if len(fields) != 2 or fields[0] not in valid_states:
|
|
return result
|
|
state, package = fields
|
|
kernel = _PACKAGE.fullmatch(package)
|
|
if kernel:
|
|
if state in ('unpacked', 'half-configured', 'half-installed', 'triggers-awaited', 'triggers-pending'):
|
|
return result
|
|
if state == 'installed':
|
|
installed.append(kernel.group(1))
|
|
if not installed:
|
|
return result
|
|
newest = max(installed, key=lambda v: tuple(map(int, _RELEASE.fullmatch(v).groups())))
|
|
newer = tuple(map(int, _RELEASE.fullmatch(newest).groups())) > tuple(map(int, match.groups()))
|
|
return dict(result, state="required" if newer else "current", installed_kernel=newest,
|
|
reason="kernel" if newer else None)
|
|
except (OSError, subprocess.SubprocessError, ValueError, TypeError):
|
|
return result
|
|
|
|
|
|
def with_reboot_status(updates, reboot):
|
|
"""Copy, never rewrite a cached package result or hide its existing error."""
|
|
if reboot['state'] == 'not_applicable':
|
|
return updates
|
|
result = dict(updates or dict(status='UNKNOWN', count=None, checks={}))
|
|
result['checks'] = dict(result.get('checks') or {})
|
|
# 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
|
|
result['reboot_state'] = dict(reboot)
|
|
if required:
|
|
detail = (f"Reboot required: running {reboot['running_kernel']}; installed {reboot['installed_kernel']}"
|
|
if reboot.get('installed_kernel') else 'Reboot required by the operating system')
|
|
else:
|
|
detail = 'Could not determine whether a reboot is required' if unknown else 'No reboot required'
|
|
result['checks']['reboot_required'] = dict(status='INFO' if required else 'UNKNOWN' if unknown else 'OK',
|
|
detail=detail, error_key='reboot_required')
|
|
if result.get('status') == 'OK' and (required or unknown):
|
|
result['status'] = 'INFO' if required else 'UNKNOWN'
|
|
return result
|
|
|
|
|
|
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')
|
|
if key == 'updates' and not reason:
|
|
# Reuse the existing localized reboot detail contract rather
|
|
# than introducing an untranslated, context-free "Updates".
|
|
issues[status].append(block['checks']['reboot_required']['detail'])
|
|
else:
|
|
issues[status].append(f'{label}: {reason}' if reason else label)
|
|
result['overall'] = ('CRITICAL' if issues['CRITICAL'] else 'WARNING'
|
|
if issues['WARNING'] or issues['UNKNOWN'] else 'OK')
|
|
primary = next((values for values in issues.values() if values), [])
|
|
result['summary'] = '; '.join(primary) if primary else 'All systems operational'
|
|
return result
|