mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 00:27:11 +09:00
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>
217 lines
8.7 KiB
Python
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
|