Files
ProxMenux/AppImage/scripts/kernel_reboot.py
T

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