87 lines
3.4 KiB
Python
87 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))
|