Files
Juan DenisandClaude Fable 5 160bf6dab7 feat(panel): one-click self-update from the About tab
The About tab could only detect a new release and link to it; applying the
update meant SSH and scripts/update.sh. Wire that script to the UI:

- panel_update_service launches update.sh via systemd-run in its own
  transient unit — a plain subprocess child would be killed when the
  updater restarts the panel's own systemd unit. Status is stateless
  (transient-unit state + the updater's own update-*.log) so it survives
  the mid-update backend restart.
- Capability gates keep the button honest: root systemd installs only.
  Docker installs get the docker compose pull instructions; dev servers
  and hosts without systemd-run get a reason instead of a button.
- GET/POST /api/v1/system/update (admin_required, confirm-gated,
  audited). API surface doc regenerated.
- About tab gains Update Now with inline confirm, live log tail while
  the update runs, a restarting phase that tolerates the panel being
  briefly unreachable, and success/rollback/timeout outcomes. New
  aboutTab locale keys in en + es.

The heavy lifting (blue/green switch, migrations, health check,
automatic rollback) stays in update.sh, which is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 08:09:52 -04:00

186 lines
6.8 KiB
Python

"""Panel self-update — run scripts/update.sh from the UI.
The hard parts of updating (blue/green slot switch, DB migration ordering,
post-switch health check, automatic rollback) already live in
``scripts/update.sh``. This service deliberately adds none of that; it only
answers "can this install self-update?", launches the script, and reports
progress from the script's own log.
The one non-obvious mechanic is *how* the script is launched. The updater
restarts the ``serverkit`` systemd unit — the very unit this process runs in.
A plain ``subprocess.Popen`` child lives in that unit's cgroup, so
``systemctl stop serverkit`` would kill the update mid-flight, exactly at the
point of no return. ``systemd-run`` starts the script in its own transient
unit (its own cgroup), so it survives the panel restart it performs.
Status is stateless on purpose: an update restarts this process, so anything
kept in memory would be lost at the most interesting moment. "Running" is the
transient unit's state; progress is the tail of the ``update-*.log`` file the
script already writes. Both survive the restart and are readable by the new
process.
"""
import glob
import os
import re
import shutil
from app.exceptions import ConflictError, ValidationError
from app.utils.system import run_checked
from app.utils.version import get_install_dir, get_panel_version
# Fixed transient-unit name: makes "is an update already running" a single
# systemctl query and guarantees two concurrent starts collide loudly inside
# systemd instead of racing each other through the updater.
UPDATE_UNIT = 'serverkit-panel-update'
# Must match LOG_DIR in scripts/update.sh — that is what writes update-*.log.
LOG_DIR = '/var/log/serverkit'
# Outcome markers printed by scripts/update.sh (see its summary and rollback()).
_SUCCESS_MARKER = 'Update complete'
_ROLLBACK_MARKER = 'Rolled back to'
_ANSI_RE = re.compile(r'\x1b\[[0-9;]*[A-Za-z]')
# Split out so tests can fake a platform without touching the real ``os``
# module (a leaked os.name patch takes the whole pytest run down on Windows).
def _is_windows():
return os.name == 'nt'
def _in_docker():
return os.path.exists('/.dockerenv')
def _is_root():
return hasattr(os, 'geteuid') and os.geteuid() == 0
def get_capability():
"""Whether — and how — this install can self-update.
``mode`` is 'systemd' (one-click works), 'docker' (the panel container
cannot replace itself; the operator pulls a new image on the host), or
'unsupported' (dev servers, missing tooling), with ``reason`` saying why.
"""
if _is_windows():
return {'supported': False, 'mode': 'unsupported',
'reason': 'Self-update requires a Linux install.'}
if _in_docker():
return {'supported': False, 'mode': 'docker',
'reason': 'The panel runs in a container and cannot replace '
'its own image. Update on the host with: '
'docker compose pull && docker compose up -d'}
# Prod installs run the panel as root (templates/serverkit-backend.service.in);
# anything else is a dev server that must not get a working update button.
if not _is_root():
return {'supported': False, 'mode': 'unsupported',
'reason': 'The panel is not running as root — this looks like '
'a development server.'}
script = os.path.join(get_install_dir(), 'scripts', 'update.sh')
if not os.path.exists(script):
return {'supported': False, 'mode': 'unsupported',
'reason': f'Updater script not found at {script}.'}
for tool in ('systemd-run', 'systemctl'):
if not shutil.which(tool):
return {'supported': False, 'mode': 'unsupported',
'reason': f'{tool} not available — self-update needs a '
'systemd host.'}
return {'supported': True, 'mode': 'systemd', 'reason': None,
'script': script}
def is_running():
"""True while the transient update unit is executing."""
result = run_checked(['systemctl', 'is-active', UPDATE_UNIT], timeout=10)
# is-active exits non-zero for every state but 'active'; the output is the
# state either way. 'activating' covers Type=oneshot-style startup.
return result['output'].strip() in ('active', 'activating')
def latest_log(max_bytes=8192):
"""Newest update-*.log the script wrote, with an ANSI-stripped tail.
Returns None when no update has ever logged. ``outcome`` is a best-effort
hint parsed from the tail — 'success', 'rolled_back', or None while the
log has neither marker yet.
"""
try:
logs = glob.glob(os.path.join(LOG_DIR, 'update-*.log'))
if not logs:
return None
path = max(logs, key=os.path.getmtime)
size = os.path.getsize(path)
with open(path, 'rb') as fh:
if size > max_bytes:
fh.seek(size - max_bytes)
tail = fh.read().decode('utf-8', errors='replace')
except OSError:
return None
tail = _ANSI_RE.sub('', tail)
outcome = None
if _SUCCESS_MARKER in tail:
outcome = 'success'
elif _ROLLBACK_MARKER in tail:
outcome = 'rolled_back'
return {
'path': path,
'mtime': os.path.getmtime(path),
'tail': tail,
'outcome': outcome,
}
def get_status():
"""One stateless snapshot the UI can poll before, during and after."""
capability = get_capability()
status = {
'capability': capability,
'running': False,
'unit': UPDATE_UNIT,
'version': get_panel_version(),
'log': None,
}
if capability['supported']:
status['running'] = is_running()
status['log'] = latest_log()
return status
def start_update():
"""Launch scripts/update.sh in its own transient systemd unit.
Raises ValidationError when this install cannot self-update and
ConflictError when an update is already in flight.
"""
capability = get_capability()
if not capability['supported']:
raise ValidationError(capability['reason'])
if is_running():
raise ConflictError('An update is already running.')
# A previous failed run can leave the fixed-name unit in 'failed' and make
# systemd-run refuse the name. Best-effort clear; harmless when clean.
run_checked(['systemctl', 'reset-failed', UPDATE_UNIT], timeout=10)
result = run_checked([
'systemd-run',
'--unit', UPDATE_UNIT,
# Unload the unit when it finishes even on failure — outcome lives in
# the update-*.log, not in lingering unit state.
'--collect',
'/bin/bash', capability['script'],
], timeout=30)
if not result['success']:
raise ValidationError(
f"Failed to launch the updater: {result['error'] or result['stderr']}")
return {'started': True, 'unit': UPDATE_UNIT}