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:
1 parent
9b5463ecb3
commit
e2bafd8bff
28 files changed
+1346
-1
No files matched your search
@@ -0,0 +1,87 @@
|
||||
"""Installation-wide updates are restricted to the configured deployment owner."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.auth import check_permissions, get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
CONFIG_FILE = Path('/etc/replypier-updater/config.json')
|
||||
AGENT = '/opt/replypier-updater/cli.py'
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UpdateRequest(BaseModel):
|
||||
revision: str = Field(pattern=r'^[0-9a-f]{40}$')
|
||||
|
||||
|
||||
def installation_config() -> dict[str, Any] | None:
|
||||
if not CONFIG_FILE.exists():
|
||||
return None
|
||||
return json.loads(CONFIG_FILE.read_text())
|
||||
|
||||
|
||||
def can_manage(user: User, cfg: dict[str, Any] | None) -> bool:
|
||||
# super_admin is organization-scoped; other tenants must not deploy server code.
|
||||
return bool(cfg and str(user.id) in cfg['owner_ids'] and check_permissions(user, ['super_admin']))
|
||||
|
||||
|
||||
def require_owner(user: User) -> dict[str, Any]:
|
||||
cfg = installation_config()
|
||||
if not can_manage(user, cfg):
|
||||
raise HTTPException(status_code=403, detail='仅部署管理员可以管理系统更新。')
|
||||
return cfg # type: ignore[return-value]
|
||||
|
||||
|
||||
async def command(*args: str) -> dict[str, Any]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
'/usr/bin/python3', AGENT, *args,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=15)
|
||||
except TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
raise HTTPException(status_code=503, detail='更新服务响应超时,请刷新状态后再试。')
|
||||
try:
|
||||
result = json.loads(stdout)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
raise HTTPException(status_code=503, detail='更新服务响应异常,请检查服务器日志。')
|
||||
if proc.returncode:
|
||||
raise HTTPException(status_code=409, detail=result.get('error', '更新请求未被接受。'))
|
||||
return result
|
||||
|
||||
|
||||
@router.get('')
|
||||
async def get_status(current_user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||
cfg = installation_config()
|
||||
if not can_manage(current_user, cfg):
|
||||
return {'configured': bool(cfg), 'can_manage': False}
|
||||
state_file = Path(cfg['state_dir']) / 'status.json'
|
||||
if not state_file.exists():
|
||||
raise HTTPException(status_code=503, detail='更新服务尚未初始化。')
|
||||
state = json.loads(state_file.read_text())
|
||||
latest = (state.get('latest') or {}).get('revision')
|
||||
return {**state, 'configured': True, 'can_manage': True,
|
||||
'repository_url': cfg['web_url'], 'branch': cfg['branch'],
|
||||
'check_interval_minutes': 30,
|
||||
'update_available': bool(latest and latest != state['current']['revision'] and not state.get('check_error'))}
|
||||
|
||||
|
||||
@router.post('/check', status_code=status.HTTP_202_ACCEPTED)
|
||||
async def check_updates(current_user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||
require_owner(current_user)
|
||||
return await command('request-check')
|
||||
|
||||
|
||||
@router.post('/apply', status_code=status.HTTP_202_ACCEPTED)
|
||||
async def apply_update(body: UpdateRequest, current_user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||
require_owner(current_user)
|
||||
return await command('request', '--revision', body.revision, '--actor', str(current_user.id))
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Gate business traffic during deployment and database recovery, including sockets."""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
MAINTENANCE_FILE = Path('/var/lib/replypier-updater/maintenance')
|
||||
|
||||
|
||||
class SystemMaintenanceMiddleware:
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
path = scope.get('path', '')
|
||||
exempt = path == '/health' or (path == '/api/v1/system-updates' and scope.get('method') == 'GET')
|
||||
if scope['type'] in {'http', 'websocket'} and MAINTENANCE_FILE.exists() and not exempt:
|
||||
if scope['type'] == 'websocket':
|
||||
await send({'type': 'websocket.close', 'code': 1013})
|
||||
else:
|
||||
response = JSONResponse({'detail': '系统更新中,接待将在更新完成后恢复。'},
|
||||
status_code=503, headers={'Retry-After': '5'})
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
async def wait_for_deployment() -> None:
|
||||
while MAINTENANCE_FILE.exists():
|
||||
await asyncio.sleep(1)
|
||||
+5
-1
@@ -22,6 +22,7 @@ os.environ.setdefault('TOKENIZERS_PARALLELISM', 'false')
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import socketio
|
||||
from app.api import chat, organizations, users, ai_setup, knowledge, agent, notification, widget, widget_apps, user_groups, roles, analytics, jira, shopify, workflow, workflow_node, mcp_tool, file_upload, token, lead_capture, people, tickets
|
||||
from app.api import system_updates
|
||||
from app.api import help_center as help_center_api
|
||||
from app.api import help_center_images
|
||||
from app.api import channels as channels_api
|
||||
@@ -113,6 +114,7 @@ async def startup_event(api: FastAPI):
|
||||
api.state.auto_closer_task = asyncio.create_task(run_auto_closer_loop())
|
||||
|
||||
# Include routers
|
||||
app.include_router(system_updates.router, prefix=f"{settings.API_V1_STR}/system-updates", tags=["system-updates"])
|
||||
app.include_router(
|
||||
chat.router,
|
||||
prefix=f"{settings.API_V1_STR}/chats",
|
||||
@@ -385,4 +387,6 @@ install_crawler_policy(app)
|
||||
from app.api.help_center_public import public_app
|
||||
from app.core.help_center_host import HelpCenterHostMiddleware
|
||||
|
||||
app = socketio.ASGIApp(sio, HelpCenterHostMiddleware(app, public_app))
|
||||
from app.core.system_maintenance import SystemMaintenanceMiddleware
|
||||
|
||||
app = SystemMaintenanceMiddleware(socketio.ASGIApp(sio, HelpCenterHostMiddleware(app, public_app)))
|
||||
@@ -22,6 +22,8 @@ from pathlib import Path
|
||||
# Add the parent directory to Python path to allow imports
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from app.core.system_maintenance import wait_for_deployment
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.core.logger import get_logger
|
||||
@@ -64,6 +66,7 @@ async def run_auto_closer_loop():
|
||||
iteration = 0
|
||||
|
||||
while True:
|
||||
await wait_for_deployment()
|
||||
iteration += 1
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ from pathlib import Path
|
||||
# Add the parent directory to Python path to allow imports
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from app.core.system_maintenance import wait_for_deployment
|
||||
|
||||
from app.workers.knowledge_processor import run_processor
|
||||
from app.core.logger import get_logger
|
||||
from app.services.firebase import initialize_firebase
|
||||
@@ -54,6 +56,7 @@ async def run_processor_loop():
|
||||
iteration = 0
|
||||
|
||||
while True:
|
||||
await wait_for_deployment()
|
||||
iteration += 1
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user