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