mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 02:42:08 +00:00
feat: add guarded host restart and read-only ProxMenux version status
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user