Files
ServerKit/backend/tests/test_panel_update.py
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

217 lines
8.7 KiB
Python

"""One-click panel self-update — capability gates, launch contract, API.
Everything is faked: no test here touches systemd, the real updater script, or
/var/log. The interesting contracts are (a) the capability gates that keep the
button away from installs where the updater would be wrong or dangerous, and
(b) that the launch goes through systemd-run (its own cgroup) — a plain child
process would die when the updater restarts the panel's own unit.
"""
import os
import pytest
from app.services import panel_update_service as svc
def _systemd_box(monkeypatch, tmp_path):
"""Fake a root systemd install with the updater script present."""
script_dir = tmp_path / 'scripts'
script_dir.mkdir(parents=True, exist_ok=True)
(script_dir / 'update.sh').write_text('#!/bin/bash\n')
monkeypatch.setattr(svc, '_is_windows', lambda: False)
monkeypatch.setattr(svc, '_in_docker', lambda: False)
monkeypatch.setattr(svc, '_is_root', lambda: True)
monkeypatch.setattr(svc, 'get_install_dir', lambda: str(tmp_path))
monkeypatch.setattr(svc.shutil, 'which', lambda tool: f'/usr/bin/{tool}')
# ── Capability gates ─────────────────────────────────────────────────────────
def test_windows_dev_server_is_unsupported(monkeypatch):
monkeypatch.setattr(svc, '_is_windows', lambda: True)
cap = svc.get_capability()
assert cap == {'supported': False, 'mode': 'unsupported',
'reason': 'Self-update requires a Linux install.'}
def test_docker_install_reports_docker_mode_with_host_instructions(monkeypatch):
monkeypatch.setattr(svc, '_is_windows', lambda: False)
monkeypatch.setattr(svc, '_in_docker', lambda: True)
cap = svc.get_capability()
assert cap['supported'] is False
assert cap['mode'] == 'docker'
assert 'docker compose pull' in cap['reason']
def test_non_root_process_is_unsupported(monkeypatch):
monkeypatch.setattr(svc, '_is_windows', lambda: False)
monkeypatch.setattr(svc, '_in_docker', lambda: False)
monkeypatch.setattr(svc, '_is_root', lambda: False)
cap = svc.get_capability()
assert cap['supported'] is False
assert 'root' in cap['reason']
def test_missing_updater_script_is_unsupported(monkeypatch, tmp_path):
_systemd_box(monkeypatch, tmp_path)
os.remove(tmp_path / 'scripts' / 'update.sh')
cap = svc.get_capability()
assert cap['supported'] is False
assert 'update.sh' in cap['reason']
def test_missing_systemd_run_is_unsupported(monkeypatch, tmp_path):
_systemd_box(monkeypatch, tmp_path)
monkeypatch.setattr(svc.shutil, 'which', lambda tool: None)
cap = svc.get_capability()
assert cap['supported'] is False
assert 'systemd-run' in cap['reason']
def test_root_systemd_install_is_supported(monkeypatch, tmp_path):
_systemd_box(monkeypatch, tmp_path)
cap = svc.get_capability()
assert cap['supported'] is True
assert cap['mode'] == 'systemd'
assert cap['script'] == os.path.join(str(tmp_path), 'scripts', 'update.sh')
# ── Launch contract ──────────────────────────────────────────────────────────
def test_start_update_launches_through_systemd_run(monkeypatch, tmp_path):
_systemd_box(monkeypatch, tmp_path)
monkeypatch.setattr(svc, 'is_running', lambda: False)
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
return {'success': True, 'output': '', 'stderr': '', 'error': None,
'returncode': 0}
monkeypatch.setattr(svc, 'run_checked', fake_run)
result = svc.start_update()
assert result == {'started': True, 'unit': svc.UPDATE_UNIT}
# reset-failed first (a stale failed unit blocks reusing the name) …
assert calls[0][:2] == ['systemctl', 'reset-failed']
# … then the actual launch, detached into its own transient unit.
launch = calls[1]
assert launch[0] == 'systemd-run'
assert '--unit' in launch and svc.UPDATE_UNIT in launch
assert '--collect' in launch
assert launch[-1].endswith('update.sh')
def test_start_update_refuses_unsupported_install(monkeypatch):
monkeypatch.setattr(svc, '_is_windows', lambda: True)
from app.exceptions import ValidationError
with pytest.raises(ValidationError):
svc.start_update()
def test_start_update_refuses_concurrent_run(monkeypatch, tmp_path):
_systemd_box(monkeypatch, tmp_path)
monkeypatch.setattr(svc, 'is_running', lambda: True)
from app.exceptions import ConflictError
with pytest.raises(ConflictError):
svc.start_update()
def test_start_update_surfaces_launch_failure(monkeypatch, tmp_path):
_systemd_box(monkeypatch, tmp_path)
monkeypatch.setattr(svc, 'is_running', lambda: False)
monkeypatch.setattr(svc, 'run_checked', lambda cmd, **kw: {
'success': False, 'output': '', 'stderr': 'unit exists',
'error': 'systemd-run failed', 'returncode': 1})
from app.exceptions import ValidationError
with pytest.raises(ValidationError, match='systemd-run failed'):
svc.start_update()
# ── Log parsing ──────────────────────────────────────────────────────────────
def test_latest_log_strips_ansi_and_detects_outcome(monkeypatch, tmp_path):
log_dir = tmp_path / 'logs'
log_dir.mkdir()
monkeypatch.setattr(svc, 'LOG_DIR', str(log_dir))
(log_dir / 'update-20260830-010101.log').write_text('older run\n')
newest = log_dir / 'update-20260831-020202.log'
newest.write_text('step one\n\x1b[1m\x1b[32m✔ Update complete\x1b[0m\n',
encoding='utf-8')
os.utime(log_dir / 'update-20260830-010101.log', (1, 1))
log = svc.latest_log()
assert log['path'] == str(newest)
assert '\x1b' not in log['tail']
assert log['outcome'] == 'success'
def test_latest_log_detects_rollback(monkeypatch, tmp_path):
log_dir = tmp_path / 'logs'
log_dir.mkdir()
monkeypatch.setattr(svc, 'LOG_DIR', str(log_dir))
(log_dir / 'update-20260831-030303.log').write_text(
'Rolled back to /opt/serverkit-slots/a and it is healthy.\n')
assert svc.latest_log()['outcome'] == 'rolled_back'
def test_latest_log_none_when_nothing_logged(monkeypatch, tmp_path):
monkeypatch.setattr(svc, 'LOG_DIR', str(tmp_path / 'nope'))
assert svc.latest_log() is None
# ── API surface ──────────────────────────────────────────────────────────────
def _fake_status():
return {'capability': {'supported': True, 'mode': 'systemd',
'reason': None, 'script': '/x/update.sh'},
'running': False, 'unit': svc.UPDATE_UNIT,
'version': '9.9.9', 'log': None}
def test_status_requires_admin(client, viewer_headers):
response = client.get('/api/v1/system/update', headers=viewer_headers)
assert response.status_code == 403
def test_status_reports_snapshot(client, auth_headers, monkeypatch):
monkeypatch.setattr(svc, 'get_status', _fake_status)
response = client.get('/api/v1/system/update', headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['version'] == '9.9.9'
def test_start_requires_admin(client, viewer_headers):
response = client.post('/api/v1/system/update', headers=viewer_headers,
json={'confirm': True})
assert response.status_code == 403
def test_start_requires_explicit_confirmation(client, auth_headers):
response = client.post('/api/v1/system/update', headers=auth_headers,
json={})
assert response.status_code == 400
assert 'confirm' in response.get_json()['error'].lower()
def test_start_launches_and_returns_202(client, auth_headers, monkeypatch):
monkeypatch.setattr(svc, 'start_update',
lambda: {'started': True, 'unit': svc.UPDATE_UNIT})
response = client.post('/api/v1/system/update', headers=auth_headers,
json={'confirm': True})
assert response.status_code == 202
assert response.get_json() == {'started': True, 'unit': svc.UPDATE_UNIT}
def test_start_conflict_when_already_running(client, auth_headers, monkeypatch):
from app.exceptions import ConflictError
def boom():
raise ConflictError('An update is already running.')
monkeypatch.setattr(svc, 'start_update', boom)
response = client.post('/api/v1/system/update', headers=auth_headers,
json={'confirm': True})
assert response.status_code == 409