feat(updates): check Gitea automatically and deploy on owner confirmation

Add owner-only update controls, a read-only SSH checker and independent systemd deployment services. Build before maintenance; back up code and database; restore on failed deployment. Preserve runtime configuration and gate business writes during the switch.
This commit is contained in:
WangShen007 committed 2026-10-24 07:26:41 +00:00
1 parent ed8dc01ed9
commit f3ad539998
28 files changed
+1346 -1

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api import system_updates as updates
from app.core.auth import get_current_user
@pytest.fixture
def setup(tmp_path, monkeypatch):
owner = str(uuid4())
cfg = tmp_path / 'config.json'
cfg.write_text(json.dumps({'owner_ids': [owner], 'state_dir': str(tmp_path), 'web_url': 'https://gitea.example/repo', 'branch': 'main'}))
(tmp_path / 'status.json').write_text(json.dumps({'current': {'revision': 'a' * 40}, 'latest': {'revision': 'b' * 40}, 'update': {'status': 'idle'}}))
monkeypatch.setattr(updates, 'CONFIG_FILE', cfg)
command = AsyncMock(return_value={'accepted': True})
monkeypatch.setattr(updates, 'command', command)
app = FastAPI()
app.include_router(updates.router, prefix='/system-updates')
user = SimpleNamespace(id=owner, role=SimpleNamespace(permissions=[SimpleNamespace(name='super_admin')]))
app.dependency_overrides[get_current_user] = lambda: user
return TestClient(app), user, command
def test_owner_can_check_and_queue_only_a_valid_revision(setup):
client, user, command = setup
assert client.get('/system-updates').json()['update_available']
assert client.post('/system-updates/check').status_code == 202
assert client.post('/system-updates/apply', json={'revision': 'b' * 40}).status_code == 202
command.assert_awaited_with('request', '--revision', 'b' * 40, '--actor', user.id)
assert client.post('/system-updates/apply', json={'revision': 'main; echo unsafe'}).status_code == 422
def test_other_organization_admin_cannot_deploy_or_read_state(setup):
client, user, command = setup
user.id = str(uuid4())
assert client.get('/system-updates').json() == {'configured': True, 'can_manage': False}
assert client.post('/system-updates/check').status_code == 403
assert client.post('/system-updates/apply', json={'revision': 'b' * 40}).status_code == 403
command.assert_not_awaited()
def test_allowlisted_account_still_needs_super_admin(setup):
client, user, command = setup
user.role.permissions = [SimpleNamespace(name='manage_organization')]
assert client.post('/system-updates/check').status_code == 403
command.assert_not_awaited()
@@ -0,0 +1,27 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
from starlette.websockets import WebSocketDisconnect
from app.core import system_maintenance as maintenance
def test_maintenance_gates_business_requests_but_allows_status(tmp_path, monkeypatch):
marker = tmp_path / 'maintenance'
monkeypatch.setattr(maintenance, 'MAINTENANCE_FILE', marker)
app = FastAPI()
for path in ['/health', '/api/v1/system-updates', '/api/v1/chats']:
app.get(path)(lambda: {'ok': True})
client = TestClient(maintenance.SystemMaintenanceMiddleware(app))
assert client.get('/api/v1/chats').status_code == 200
marker.touch()
assert client.get('/health').status_code == 200
assert client.get('/api/v1/system-updates').status_code == 200
assert client.get('/api/v1/chats').status_code == 503
assert client.post('/api/v1/system-updates/check').status_code == 503
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect('/socket.io/'):
pass
assert exc.value.code == 1013