Files
replypier/backend/app/api/system_updates.py
T
WangShen007 f3ad539998 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.
2026-10-24 07:26:41 +00:00

88 lines
3.4 KiB
Python

"""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))