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

+87
View File
@@ -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))
+30
View File
@@ -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
View File
@@ -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)))
+3
View File
@@ -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()