feat: add guarded host restart and read-only ProxMenux version status

This commit is contained in:
VAIO73
2026-10-28 17:30:44 +00:00
parent 503a946d85
commit 52cd21dcb1
11 changed files with 631 additions and 1 deletions
+48 -1
View File
@@ -2,7 +2,7 @@
Flask routes for health monitoring with persistence support
"""
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify, request, current_app
from health_monitor import health_monitor
from health_persistence import health_persistence
from jwt_middleware import require_auth, require_admin_scope
@@ -17,6 +17,53 @@ except ImportError:
health_bp = Blueprint('health', __name__)
@health_bp.route('/api/health/proxmenux-version', methods=['GET'])
@require_auth
def get_proxmenux_version():
from proxmenux_version_status import version_status
return jsonify(version_status())
@health_bp.route('/api/health/reboot-context', methods=['GET'])
@require_admin_scope
def get_reboot_context():
from host_update_controls import reboot_context
return jsonify(reboot_context())
@health_bp.route('/api/health/reboot-after-update', methods=['POST'])
@require_admin_scope
def reboot_after_update():
import socket
data = request.get_json(silent=True)
if not isinstance(data, dict) or data.get('confirm') is not True or data.get('hostname') != socket.gethostname() or not isinstance(data.get('boot_id'), str) or not data['boot_id']:
return jsonify({'reason': 'confirmation'}), 400
power = current_app.extensions.get('host_update_power')
if not power:
return jsonify({'reason': 'unavailable'}), 503
from host_update_controls import reboot_context, COMMAND, UNIT
context = reboot_context()
if context.get('boot_id') != data['boot_id']:
return jsonify({'reason': 'confirmation'}), 409
if not context['allowed']:
return jsonify({'reason': context['reason']}), 409
ok, error = power['run'](UNIT, list(COMMAND), description='ProxMenux: reboot after host update',
extra_properties=['RuntimeMaxSec=75'])
if not ok:
return jsonify({'reason': 'unavailable'}), error.get('status', 500)
return jsonify({'accepted': True}), 202
@health_bp.route('/api/health/reboot-after-update/status', methods=['GET'])
@require_admin_scope
def reboot_after_update_status():
power = current_app.extensions.get('host_update_power')
if not power:
return jsonify({'state': 'unknown'}), 503
from host_update_controls import UNIT
return jsonify(power['state'](UNIT))
@health_bp.route('/api/health/status', methods=['GET'])
@require_auth
def get_health_status():
+3
View File
@@ -2748,6 +2748,9 @@ def _action_state(unit_name):
return result_dict
app.extensions['host_update_power'] = {'run': _action_run, 'state': _action_state}
def _action_cancel(unit_name):
"""Stop a running transient action unit. Idempotent — succeeds on
units that are not running (or don't exist).
+57
View File
@@ -0,0 +1,57 @@
"""Guarded reboot-after-update context; existing power API is unchanged."""
import fcntl
import os
from pathlib import Path
import socket
import stat
from kernel_reboot import reboot_status
from host_update_jobs import list_jobs
LOCK = '/run/proxmenux-host-update.lock'
BOOT_ID = Path('/proc/sys/kernel/random/boot_id')
UNIT = 'proxmenux-action-reboot-after-update'
# The unit obtains the SAME lock as CLI/legacy/web updates. Holding it for
# the shutdown transition closes the precheck-to-launch race. If an updater
# wins that race, flock exits75 and systemctl reboot is never invoked.
COMMAND = ['/usr/bin/flock', '-n', '-E', '75', LOCK, '/bin/sh', '-c',
'/bin/systemctl reboot && /bin/sleep 60']
def reboot_context():
result = dict(hostname=socket.gethostname(), allowed=False, reason='unknown')
try:
boot_id = BOOT_ID.read_text().strip()
if not boot_id:
return result
result['boot_id'] = boot_id
except OSError:
return result
try:
jobs = list_jobs()
if not isinstance(jobs, list) or any(not isinstance(job, dict) for job in jobs):
return result
except Exception:
return result
if any(job.get('state') != 'complete' for job in jobs):
return dict(result, reason='busy')
try:
fd = os.open(LOCK, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except FileNotFoundError:
fd = None
except OSError:
return result
if fd is not None:
try:
if not stat.S_ISREG(os.fstat(fd).st_mode):
return result
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return dict(result, reason='busy')
except OSError:
return result
finally:
os.close(fd)
reboot = reboot_status()
return dict(result, allowed=reboot['state']=='required',
reason='required' if reboot['state']=='required' else 'current' if reboot['state']=='current' else 'unknown')
@@ -0,0 +1,56 @@
"""Read-only version information. No installer or configuration writes."""
from datetime import datetime, timezone
from pathlib import Path
import re
import threading
import time
import urllib.request
ROOT = Path('/usr/local/share/proxmenux')
URLS = {
'stable': 'https://raw.githubusercontent.com/MacRimi/ProxMenux/main/version.txt',
'beta': 'https://raw.githubusercontent.com/MacRimi/ProxMenux/develop/beta_version.txt',
}
TTL = 1800
RETRY = 60
_lock = threading.Lock()
_cache = {}
def _version(text):
if not isinstance(text, str) or not re.fullmatch(r'\d{1,8}(?:\.\d{1,8}){1,5}', text):
raise ValueError('Invalid version')
parts = tuple(map(int, text.split('.')))
return parts + (0,) * (6-len(parts))
def version_status():
result = dict(state='unknown', installed=None, available=None, checked_at=None, channel='stable')
try:
beta = ROOT/'beta_version.txt'
channel = 'beta' if beta.exists() else 'stable'
result['channel'] = channel
installed = (beta if channel == 'beta' else ROOT/'version.txt').read_text().strip()
_version(installed)
result.update(installed=installed, channel=channel)
except (OSError, ValueError):
return result # No remote probe without a usable installed version.
with _lock:
now = time.monotonic()
cache = _cache.get(channel, {})
age = now-cache.get('attempt', -float('inf'))
if not 0 <= age < (RETRY if cache.get('failed') else TTL):
try:
req = urllib.request.Request(URLS[channel], headers={'User-Agent': 'ProxMenux-Monitor'})
with urllib.request.urlopen(req, timeout=3) as response:
latest = response.read(129).decode('utf-8').strip()
_version(latest)
cache = dict(attempt=time.monotonic(), version=latest, failed=False,
checked_at=datetime.now(timezone.utc).isoformat())
except (OSError, ValueError, UnicodeError):
cache = dict(cache, attempt=time.monotonic(), failed=True)
_cache[channel] = cache
result.update(available=cache.get('version'), checked_at=cache.get('checked_at'))
if cache.get('version') and not cache.get('failed'):
result['state'] = 'available' if _version(cache['version']) > _version(installed) else 'current'
return result