diff --git a/backend/app/api/system_updates.py b/backend/app/api/system_updates.py new file mode 100644 index 0000000..9365437 --- /dev/null +++ b/backend/app/api/system_updates.py @@ -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)) diff --git a/backend/app/core/system_maintenance.py b/backend/app/core/system_maintenance.py new file mode 100644 index 0000000..c72522e --- /dev/null +++ b/backend/app/core/system_maintenance.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 039cc40..25fcbc1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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))) diff --git a/backend/app/workers/chat_auto_closer.py b/backend/app/workers/chat_auto_closer.py index 469fd16..756111d 100644 --- a/backend/app/workers/chat_auto_closer.py +++ b/backend/app/workers/chat_auto_closer.py @@ -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() diff --git a/backend/app/workers/run_knowledge_processor.py b/backend/app/workers/run_knowledge_processor.py index c899e9e..25a9961 100644 --- a/backend/app/workers/run_knowledge_processor.py +++ b/backend/app/workers/run_knowledge_processor.py @@ -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() diff --git a/backend/tests/api/test_system_updates.py b/backend/tests/api/test_system_updates.py new file mode 100644 index 0000000..35f6210 --- /dev/null +++ b/backend/tests/api/test_system_updates.py @@ -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() diff --git a/backend/tests/core/test_system_maintenance.py b/backend/tests/core/test_system_maintenance.py new file mode 100644 index 0000000..62e158c --- /dev/null +++ b/backend/tests/core/test_system_maintenance.py @@ -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 diff --git a/deploy/server/README.md b/deploy/server/README.md new file mode 100644 index 0000000..43bea74 --- /dev/null +++ b/deploy/server/README.md @@ -0,0 +1,35 @@ +# 从 Gitea 检查并手动更新 + +部署管理员在 **配置 → 系统更新** 查看当前版本、Gitea `main` 最新提交和上次检查时间。有新版本时,顶部也会出现入口。点击“更新到此版本”,确认暂停接待后才开始安装;每 30 分钟的自动检查不会安装或重启应用。 + +## 安装到现有 Ubuntu 服务器 + +本工具适用于 `/srv/replypier` 的 systemd 部署:`replypier-backend`、`replypier-knowledge`,以及 PostgreSQL/Redis 容器。不是 Mac 本机的 Colima 启动脚本。 + +1. 为服务器创建专用 SSH 密钥,将**公钥**添加为 Gitea 仓库的只读部署密钥;私钥留在 `/etc/replypier-updater/id_ed25519`,权限 `600`。通过可信渠道核实 Gitea 主机公钥并保存为同目录的 `known_hosts`。更新过程不使用账号密码或浏览器登录态。 +2. 按 `config.example.json` 准备私有配置,填写当前安装提交和部署管理员用户 UUID。只有名单中的用户且持有 `super_admin` 权限才能更新;其他组织的管理员无权更新整个服务器。配置和 SSH 私钥不入库。 +3. 以 root 执行 `./deploy/server/install-updater.sh /path/to/config.json`,然后执行 `python3 /opt/replypier-updater/cli.py check`。安装不会重置应用环境或数据库。 +4. Caddy 必须对 `/sw.js` 返回 `Cache-Control: no-cache, no-store, must-revalidate`,以免 CDN 缓存旧的离线版本。首次修改该响应头后,单独清除 CDN 中 `/sw.js` 的已有缓存。不要缓存 HTML 入口。 + +## 更新时发生什么 + +- 通过固定的 SSH 仓库获取 `main`,只接受当前版本的后续提交;仓库历史被重写时停止更新。 +- 当前源码若有未同步到 Gitea 的修改,停止更新,先保留并同步修改。 +- 在独立目录安装前端依赖、构建控制台和网站组件。后端锁文件有变化时创建独立 Python 环境。准备期间原服务继续运行,磁盘可用空间须至少 4 GiB。 +- 暂停业务请求和后台处理,停止应用服务,备份程序及 PostgreSQL;验证备份可读取后才修改在线版本。 +- 安装源码、执行 Alembic 迁移、启动服务并检查健康,最后切换前端并恢复接待。`.env`、上传文件、数据库卷和已有运行配置不会被仓库文件覆盖。 +- 安装失败时恢复程序和数据库;构建失败不影响在线版本。断电后由开机恢复服务先恢复中断的部署,再启动后端。 + +更新状态、备份和日志位于 `/var/lib/replypier-updater/`,由 systemd 服务独立执行,网页关闭或后端重启不会取消更新。备份可能含业务数据,不要公开。备份不自动删除;接近空间下限时更新会停止,管理员确认恢复需求后再清理旧备份。 + +## 排查与恢复 + +```bash +systemctl status replypier-update-check.timer replypier-update.service +journalctl -u replypier-update.service -n 60 --no-pager +sudo tail -n 80 /var/lib/replypier-updater/update.log +sudo python3 /opt/replypier-updater/cli.py recover +curl --fail http://127.0.0.1:8018/health +``` + +`recover` 用于仍有维护标记的失败或中断更新。如果自动恢复失败,维护模式保持关闭接待,先检查状态中的备份位置和错误,不要删除维护标记、数据库卷或密钥来绕过恢复。Git 拉取使用只读密钥;发布新版本仍通过正常的 Gitea 提交流程。 diff --git a/deploy/server/config.example.json b/deploy/server/config.example.json new file mode 100644 index 0000000..2e0bb24 --- /dev/null +++ b/deploy/server/config.example.json @@ -0,0 +1,15 @@ +{ + "project_dir": "/srv/replypier", + "state_dir": "/var/lib/replypier-updater", + "repository": "ssh://git@149.71.241.228:10513/WangShen007/replypier.git", + "web_url": "http://149.71.241.228:10489/WangShen007/replypier", + "branch": "main", + "public_url": "https://app.replypier.site", + "ssh_key": "/etc/replypier-updater/id_ed25519", + "known_hosts": "/etc/replypier-updater/known_hosts", + "owner_ids": ["REPLACE_WITH_DEPLOYMENT_OWNER_USER_ID"], + "initial_revision": "REPLACE_WITH_INSTALLED_COMMIT", + "db_container": "chattermate-local-db-1", + "db_user": "chattermate", + "db_name": "chattermate" +} diff --git a/deploy/server/install-updater.sh b/deploy/server/install-updater.sh new file mode 100755 index 0000000..f50197b --- /dev/null +++ b/deploy/server/install-updater.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +if [[ "$(id -u)" != 0 || "$#" != 1 ]]; then + printf 'Usage (root): %s /path/to/config.json\n' "$0" >&2 + exit 1 +fi +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +python3 - "$1" <<'PY' +import json,re,sys +from pathlib import Path +from uuid import UUID +cfg=json.loads(Path(sys.argv[1]).read_text()) +assert re.fullmatch(r'[0-9a-f]{40}',cfg['initial_revision']), 'Installed commit required' +assert cfg['owner_ids'], 'Deployment owner required' +for owner in cfg['owner_ids']: + UUID(owner) +for key in ('project_dir','ssh_key','known_hosts'): + assert Path(cfg[key]).exists(), f'Missing {key}' +assert Path(cfg['project_dir'],'backend/.env').exists() +assert Path(cfg['project_dir'],'deploy/local/.state/dashboard/config.js').exists() +assert cfg['repository'].startswith('ssh://'), 'Use authenticated SSH, not plain HTTP' +Path(cfg['state_dir']).mkdir(parents=True,exist_ok=True) +PY +install -d -m 700 /etc/replypier-updater +if [[ "$(realpath "$1")" != /etc/replypier-updater/config.json ]]; then + install -m 600 "$1" /etc/replypier-updater/config.json +else + chmod 600 /etc/replypier-updater/config.json +fi +install -d -m 755 /opt/replypier-updater +install -m 644 "${SCRIPT_DIR}"/updater/*.py /opt/replypier-updater/ +install -m 644 "${SCRIPT_DIR}"/systemd/* /etc/systemd/system/ +install -d -m 755 /etc/systemd/system/replypier-backend.service.d +cat > /etc/systemd/system/replypier-backend.service.d/update-recovery.conf <<'EOF' +[Unit] +Requires=replypier-update-recover.service +After=replypier-update-recover.service +EOF +python3 - <<'PY' +import sys +sys.path.insert(0,'/opt/replypier-updater') +from state import config,transaction +with transaction(config()): + pass +PY +systemctl daemon-reload +systemctl enable --now replypier-update-recover.service replypier-update-check.timer +printf '\nInstalled. Check manually: python3 /opt/replypier-updater/cli.py check\n' diff --git a/deploy/server/systemd/replypier-update-check.service b/deploy/server/systemd/replypier-update-check.service new file mode 100644 index 0000000..af5724a --- /dev/null +++ b/deploy/server/systemd/replypier-update-check.service @@ -0,0 +1,11 @@ +[Unit] +Description=Check ReplyPier Gitea main for updates (never installs) +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 /opt/replypier-updater/cli.py check +ExecStopPost=/usr/bin/python3 /opt/replypier-updater/cli.py finish-check +TimeoutStartSec=180 +UMask=0077 diff --git a/deploy/server/systemd/replypier-update-check.timer b/deploy/server/systemd/replypier-update-check.timer new file mode 100644 index 0000000..7bddb41 --- /dev/null +++ b/deploy/server/systemd/replypier-update-check.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Check ReplyPier updates every 30 minutes + +[Timer] +OnBootSec=3min +OnUnitActiveSec=30min +Persistent=true +Unit=replypier-update-check.service + +[Install] +WantedBy=timers.target diff --git a/deploy/server/systemd/replypier-update-recover.service b/deploy/server/systemd/replypier-update-recover.service new file mode 100644 index 0000000..7ac6cc6 --- /dev/null +++ b/deploy/server/systemd/replypier-update-recover.service @@ -0,0 +1,15 @@ +[Unit] +Description=Recover an interrupted ReplyPier deployment before accepting traffic +After=docker.service +Requires=docker.service +Before=replypier-backend.service replypier-knowledge.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 /opt/replypier-updater/cli.py recover-boot +RemainAfterExit=yes +TimeoutStartSec=20min +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/server/systemd/replypier-update.service b/deploy/server/systemd/replypier-update.service new file mode 100644 index 0000000..e993fa5 --- /dev/null +++ b/deploy/server/systemd/replypier-update.service @@ -0,0 +1,14 @@ +[Unit] +Description=Install the ReplyPier version explicitly confirmed by its owner +After=network-online.target docker.service +Requires=docker.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 /opt/replypier-updater/cli.py apply +ExecStopPost=/usr/bin/python3 /opt/replypier-updater/cli.py recover +TimeoutStartSec=2h +TimeoutStopSec=20min +KillMode=control-group +UMask=0077 +Nice=10 diff --git a/deploy/server/tests/test_updater.py b/deploy/server/tests/test_updater.py new file mode 100644 index 0000000..c505d5a --- /dev/null +++ b/deploy/server/tests/test_updater.py @@ -0,0 +1,162 @@ +import json +import sys +from pathlib import Path +from unittest.mock import Mock + +import pytest + +sys.path.insert(0, str(Path(__file__).parents[1] / 'updater')) +import cli +import deployment +import repository +import state + +CURRENT = 'a' * 40 +TARGET = 'b' * 40 + + +@pytest.fixture +def cfg(tmp_path): + cfg = {'state_dir': str(tmp_path / 'state'), 'project_dir': str(tmp_path / 'project'), + 'initial_revision': CURRENT, 'owner_ids': ['owner'], 'branch': 'main', + 'web_url': 'https://gitea.example/repo'} + Path(cfg['project_dir']).mkdir() + with state.transaction(cfg) as data: + data['latest'] = {'revision': TARGET} + return cfg + + +def test_check_never_starts_deployment(cfg, monkeypatch): + monkeypatch.setattr(repository, 'fetch', lambda c: TARGET) + monkeypatch.setattr(repository, 'ensure_forward', Mock()) + monkeypatch.setattr(repository, 'commit', lambda c, r: {'revision': r}) + launch = Mock() + monkeypatch.setattr(cli, 'launch', launch) + cli.check(cfg) + assert state.read(cfg)['latest']['revision'] == TARGET + assert not state.read(cfg)['checking'] + launch.assert_not_called() + + +def test_failed_check_is_visible_and_retryable(cfg, monkeypatch): + monkeypatch.setattr(repository, 'fetch', Mock(side_effect=RuntimeError('unreachable'))) + with pytest.raises(RuntimeError): + cli.check(cfg) + data = state.read(cfg) + assert data['check_error'] == 'unreachable' + assert not data['checking'] + assert not state.public_status(cfg)['update_available'] + + +def test_request_pins_the_confirmed_revision_and_rejects_duplicates(cfg, monkeypatch): + launch = Mock() + monkeypatch.setattr(cli, 'launch', launch) + assert cli.request(cfg, TARGET, 'owner') == {'accepted': True} + assert state.read(cfg)['update']['target_revision'] == TARGET + launch.assert_called_once_with('replypier-update.service') + with pytest.raises(ValueError, match='已有更新'): + cli.request(cfg, TARGET, 'owner') + + +@pytest.mark.parametrize('revision,actor,error', [ + (TARGET, 'another-admin', PermissionError), + ('main;rm -rf /', 'owner', ValueError), + ('c' * 40, 'owner', ValueError), + (CURRENT, 'owner', ValueError), +]) +def test_unapproved_requests_do_not_start_a_service(cfg, monkeypatch, revision, actor, error): + launch = Mock() + monkeypatch.setattr(cli, 'launch', launch) + with pytest.raises(error): + cli.request(cfg, revision, actor) + launch.assert_not_called() + + +def test_failed_service_start_releases_the_queued_state(cfg, monkeypatch): + monkeypatch.setattr(cli, 'launch', Mock(side_effect=RuntimeError('systemd unavailable'))) + with pytest.raises(RuntimeError): + cli.request(cfg, TARGET, 'owner') + assert state.read(cfg)['update']['status'] == 'failed' + + +def test_build_failure_keeps_live_service_and_version(cfg, monkeypatch): + d = deployment.Deployment(cfg) + monkeypatch.setattr(deployment.shutil, 'disk_usage', lambda p: type('Usage', (), {'free': 10 * 1024**3})()) + monkeypatch.setattr(repository, 'git', Mock()) + monkeypatch.setattr(repository, 'ensure_forward', Mock()) + monkeypatch.setattr(repository, 'ensure_clean', Mock()) + monkeypatch.setattr(d, 'prepare', Mock(side_effect=RuntimeError('build failed'))) + services = Mock() + monkeypatch.setattr(d, 'services', services) + with pytest.raises(RuntimeError, match='build failed'): + d.apply(TARGET) + services.assert_not_called() + assert state.read(cfg)['current']['revision'] == CURRENT + assert state.read(cfg)['update']['status'] == 'failed' + + +def test_recovery_restores_the_previous_version_and_keeps_error(cfg, monkeypatch): + d = deployment.Deployment(cfg) + (state.directory(cfg) / 'maintenance').touch() + with state.transaction(cfg) as data: + data['current'] = {'revision': TARGET} + data['update'] = {'status': 'migrating', 'backup_ready': True, + 'backup_path': '/backup', 'previous_revision': CURRENT, 'error': 'migration failed'} + restore = Mock() + monkeypatch.setattr(d, 'restore', restore) + monkeypatch.setattr(repository, 'commit', lambda c, r: {'revision': r}) + d.recover() + restore.assert_called_once_with(Path('/backup'), boot=False) + assert state.read(cfg)['current']['revision'] == CURRENT + assert state.read(cfg)['update']['status'] == 'rolled_back' + assert state.read(cfg)['update']['error'] == 'migration failed' + assert not (state.directory(cfg) / 'maintenance').exists() + + +def test_boot_recovery_does_not_start_a_dependency_of_itself(cfg, monkeypatch): + d = deployment.Deployment(cfg) + (state.directory(cfg) / 'maintenance').touch() + with state.transaction(cfg) as data: + data['update'] = {'status': 'backing_up', 'backup_ready': False} + services = Mock() + monkeypatch.setattr(d, 'services', services) + d.recover(boot=True) + services.assert_not_called() + assert not (state.directory(cfg) / 'maintenance').exists() + + +def test_recovery_failure_keeps_the_maintenance_gate(cfg, monkeypatch): + d = deployment.Deployment(cfg) + (state.directory(cfg) / 'maintenance').touch() + with state.transaction(cfg) as data: + data['update'] = {'status': 'migrating', 'backup_ready': True, 'backup_path': '/backup'} + monkeypatch.setattr(d, 'restore', Mock(side_effect=RuntimeError('restore failed'))) + with pytest.raises(RuntimeError, match='restore failed'): + d.recover() + assert (state.directory(cfg) / 'maintenance').exists() + + +@pytest.mark.parametrize('path', ['backend/.env','frontend/.env.prod','backend/.venv/a','deploy/local/.state/a','backend/uploads/a','../../etc/passwd','key.pem']) +def test_runtime_files_are_never_deployed(path): + assert repository.protected(path) + + +def test_env_templates_remain_deployable(): + assert not repository.protected('backend/.env.example') + + +def test_cannot_replace_failed_recovery_with_a_new_job(cfg, monkeypatch): + (state.directory(cfg) / 'maintenance').touch() + launch = Mock() + monkeypatch.setattr(cli, 'launch', launch) + with pytest.raises(ValueError, match='尚未恢复'): + cli.request(cfg, TARGET, 'owner') + launch.assert_not_called() + + +def test_interrupted_build_does_not_leave_a_permanently_busy_job(cfg): + with state.transaction(cfg) as data: + data['update'] = {'status': 'building', 'message': 'building'} + deployment.Deployment(cfg).recover() + assert state.read(cfg)['update']['status'] == 'failed' + assert state.read(cfg)['current']['revision'] == CURRENT diff --git a/deploy/server/updater/cli.py b/deploy/server/updater/cli.py new file mode 100644 index 0000000..4cd97b5 --- /dev/null +++ b/deploy/server/updater/cli.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Systemd entry point; never launched as a child of the web process for deployment.""" +from __future__ import annotations + +import argparse +import fcntl +import json +import re +import subprocess +import sys +from pathlib import Path + +import repository +from deployment import Deployment +from state import BUSY, config, directory, now, progress, read, transaction + + +def launch(unit: str) -> None: + result = subprocess.run(['systemctl', 'start', '--no-block', unit], capture_output=True, text=True, timeout=10) + if result.returncode: + raise RuntimeError('无法启动更新服务,请检查 systemd 配置。') + + +def request(cfg: dict, revision: str, actor: str) -> dict: + if (directory(cfg) / 'maintenance').exists(): + raise ValueError('上次更新尚未恢复,请先执行服务器恢复命令。') + if actor not in cfg['owner_ids']: + raise PermissionError('仅部署管理员可以更新。') + if not re.fullmatch(r'[0-9a-f]{40}', revision): + raise ValueError('无效的版本号。') + with transaction(cfg) as state: + if state['update']['status'] in BUSY: + raise ValueError('已有更新正在执行。') + if state.get('checking'): + raise ValueError('正在检查版本,请稍后再试。') + if state.get('check_error') or (state.get('latest') or {}).get('revision') != revision: + raise ValueError('版本信息已变化,请重新检查更新。') + if state['current']['revision'] == revision: + raise ValueError('已经是最新版本。') + state['update'] = {'status': 'queued', 'message': '更新已排队。', 'target_revision': revision, + 'actor': actor, 'started_at': now(), 'error': None} + try: + launch('replypier-update.service') + except Exception as exc: + progress(cfg, 'failed', str(exc), finished_at=now()) + raise + return {'accepted': True} + + +def request_check(cfg: dict) -> dict: + with transaction(cfg) as state: + if state['update']['status'] in BUSY: + raise ValueError('更新正在进行,请稍后检查。') + if state.get('checking'): + return {'accepted': True} + state['checking'] = True + try: + launch('replypier-update-check.service') + except Exception: + with transaction(cfg) as state: + state['checking'] = False + raise + return {'accepted': True} + + +def check(cfg: dict) -> None: + try: + with transaction(cfg) as state: + state['checking'] = True + latest = repository.fetch(cfg) + current = read(cfg)['current']['revision'] + repository.ensure_forward(cfg, current, latest) + with transaction(cfg) as state: + state.update(latest=repository.commit(cfg, latest), current=repository.commit(cfg, current), + checked_at=now(), check_error=None) + except Exception as exc: + with transaction(cfg) as state: + state.update(checked_at=now(), check_error=str(exc)) + raise + finally: + with transaction(cfg) as state: + state['checking'] = False + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument('action', choices=['check', 'request-check', 'request', 'apply', 'recover', 'recover-boot', 'finish-check']) + parser.add_argument('--revision', default='') + parser.add_argument('--actor', default='') + args = parser.parse_args() + cfg = config() + if args.action == 'finish-check': + with transaction(cfg) as state: + state['checking'] = False + return + if args.action == 'request': + result = request(cfg, args.revision, args.actor) + elif args.action == 'request-check': + result = request_check(cfg) + else: + with (directory(cfg) / 'operation.lock').open('a') as lock: + # Timer checks do not queue behind an in-flight deployment. + try: + fcntl.flock(lock, fcntl.LOCK_EX | (fcntl.LOCK_NB if args.action == 'check' else 0)) + except BlockingIOError: + return + if args.action == 'check': + if read(cfg)['update']['status'] in BUSY: + return + check(cfg) + elif args.action in {'recover', 'recover-boot'}: + Deployment(cfg).recover(boot=args.action == 'recover-boot') + else: + state = read(cfg) + if state['update']['status'] != 'queued': + raise ValueError('没有待执行的更新。') + Deployment(cfg).apply(state['update']['target_revision']) + result = {'ok': True} + print(json.dumps(result, ensure_ascii=False)) + + +if __name__ == '__main__': + try: + main() + except Exception as exc: + print(json.dumps({'error': str(exc)}, ensure_ascii=False)) + sys.exit(1) diff --git a/deploy/server/updater/deployment.py b/deploy/server/updater/deployment.py new file mode 100644 index 0000000..7fec72f --- /dev/null +++ b/deploy/server/updater/deployment.py @@ -0,0 +1,270 @@ +"""Prepare without downtime; back up, switch, verify and recover independently of FastAPI.""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tarfile +import time +import urllib.request +from pathlib import Path +from typing import Any + +import repository +from state import BUSY, directory, now, progress, read, transaction + + +class Deployment: + def __init__(self, cfg: dict[str, Any]): + self.cfg = cfg + self.root = Path(cfg['project_dir']) + self.state_dir = directory(cfg) + self.log = self.state_dir / 'update.log' + + def run(self, args: list[str], *, cwd: Path | None = None, + env: dict[str, str] | None = None, timeout: int = 1800, + stdin: Any = None, stdout: Any = None) -> None: + with self.log.open('ab') as log: + result = subprocess.run(args, cwd=cwd, env={**os.environ, **(env or {})}, + stdin=stdin, stdout=stdout or log, stderr=log, timeout=timeout) + if result.returncode: + raise RuntimeError(f'{args[0]} 执行失败(退出码 {result.returncode}),请查看服务器更新日志。') + + def services(self, action: str) -> None: + self.run(['systemctl', action, 'replypier-backend.service', 'replypier-knowledge.service'], timeout=90) + + def healthy(self) -> None: + for _ in range(45): + try: + with urllib.request.urlopen('http://127.0.0.1:8018/health', timeout=3) as response: + if json.load(response).get('status') == 'healthy': + return + except (OSError, ValueError): + pass + time.sleep(2) + raise RuntimeError('新版本后端未通过健康检查。') + + def prepare(self, target: str) -> Path: + stage = self.state_dir / 'releases' / f'{target}-{int(time.time())}' + stage.mkdir(parents=True) + archive = stage / 'source.tar' + self.run(['git', '--git-dir', str(self.state_dir / 'repository.git'), + 'archive', '--format=tar', target, '-o', str(archive)]) + source = stage / 'source' + source.mkdir() + with tarfile.open(archive) as tar: + tar.extractall(source, filter='data') + archive.unlink() + self.new_paths = repository.paths(self.cfg, target) + if any(repository.protected(p) for p in self.new_paths): + raise RuntimeError('仓库包含受保护的密钥或运行目录,已停止更新。') + front = source / 'frontend' + shutil.copy2(self.root / 'frontend/.env', front / '.env') + progress(self.cfg, 'building', '正在安装前端依赖并构建,网站仍正常接待。') + self.run(['npm', 'ci', '--no-audit', '--no-fund'], cwd=front) + node_env = {'NODE_OPTIONS': '--max-old-space-size=1024'} + vite = str(front / 'node_modules/.bin/vite') + self.run([vite, 'build', '--outDir', str(stage / 'dashboard')], cwd=front, env=node_env) + self.run([vite, 'build', '--config', 'vite.widget.config.ts', '--outDir', str(stage / 'assets')], + cwd=front, env=node_env) + if not (stage / 'assets/widget.css').exists(): + styles = list((stage / 'assets/unused').glob('*.css')) + if len(styles) != 1: + raise RuntimeError('网站组件的样式构建结果不完整。') + shutil.copy2(styles[0], stage / 'assets/widget.css') + loader = stage / 'dashboard/webclient/chattermate.min.js' + loader.parent.mkdir(exist_ok=True) + self.run([str(front / 'node_modules/.bin/esbuild'), 'src/webclient/chattermate.js', + '--bundle', '--minify', '--format=iife', '--target=es2015', '--loader:.svg=text', + '--define:process.env.NODE_ENV="production"', + '--define:__CHATTERMATE_API_URL__=' + json.dumps(self.cfg['public_url'] + '/api/v1'), + '--outfile=' + str(loader)], cwd=front) + # Runtime configuration belongs to this installation, not to the repository. + live_web = self.root / 'deploy/local/.state/dashboard' + shutil.copy2(live_web / 'config.js', stage / 'dashboard/config.js') + if (live_web / 'local-demo.html').exists(): + shutil.copy2(live_web / 'local-demo.html', stage / 'dashboard/local-demo.html') + shutil.rmtree(front / 'node_modules') + lock = Path('deploy/local/requirements.lock.txt') + self.new_venv = None + if (source / lock).read_bytes() != (self.root / lock).read_bytes(): + progress(self.cfg, 'preparing', '后端依赖已变更,正在创建独立环境,网站仍正常接待。') + self.new_venv = stage / 'venv' + self.run(['/usr/bin/python3', '-m', 'venv', str(self.new_venv)]) + self.run([str(self.new_venv / 'bin/python'), '-m', 'pip', 'install', '-r', str(source / lock)], timeout=3600) + return stage + + def backup(self, stage: Path, current: str) -> Path: + backup = self.state_dir / 'backups' / stage.name + backup.mkdir(parents=True) + (backup / 'agent').mkdir() + (backup / 'systemd').mkdir() + for file in Path('/opt/replypier-updater').glob('*.py'): + shutil.copy2(file, backup / 'agent' / file.name) + for file in Path('/etc/systemd/system').glob('replypier-update*'): + if file.is_file(): + shutil.copy2(file, backup / 'systemd' / file.name) + old_paths = repository.paths(self.cfg, current) + with tarfile.open(backup / 'code.tar.gz', 'w:gz') as tar: + for path in old_paths: + file = self.root / path + if file.exists() and not repository.protected(path): + tar.add(file, arcname=path, recursive=False) + (backup / 'manifest.json').write_text(json.dumps({'old': old_paths, 'new': self.new_paths})) + venv = self.root / 'backend/.venv' + if venv.is_symlink(): + (backup / 'venv-link').write_text(os.readlink(venv)) + with transaction(self.cfg) as state: + state['update']['backup_path'] = str(backup) + state['update']['previous_revision'] = current + state['update']['backup_ready'] = False + progress(self.cfg, 'backing_up', '正在暂停接待并备份数据库;页面会自动恢复。') + (self.state_dir / 'maintenance').touch() + self.services('stop') + with (backup / 'database.dump').open('wb') as out: + self.run(['docker', 'exec', self.cfg['db_container'], 'pg_dump', '-Fc', + '-U', self.cfg['db_user'], '-d', self.cfg['db_name']], stdout=out) + with (backup / 'database.dump').open('rb') as dump: + self.run(['docker', 'exec', '-i', self.cfg['db_container'], 'pg_restore', '--list'], stdin=dump) + with transaction(self.cfg) as state: + state['update']['backup_ready'] = True + return backup + + def switch(self, stage: Path, backup: Path) -> None: + progress(self.cfg, 'deploying', '正在安装新版本源码。') + manifest = json.loads((backup / 'manifest.json').read_text()) + for path in manifest['new']: + target = self.root / path + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(stage / 'source' / path, target, follow_symlinks=False) + for path in set(manifest['old']) - set(manifest['new']): + if not repository.protected(path): + (self.root / path).unlink(missing_ok=True) + if self.new_venv: + venv = self.root / 'backend/.venv' + if venv.is_symlink(): + venv.unlink() + else: + venv.rename(backup / 'original-venv') + venv.symlink_to(self.new_venv, target_is_directory=True) + progress(self.cfg, 'migrating', '正在应用数据库迁移。') + self.run([str(self.root / 'backend/.venv/bin/python'), '-m', 'alembic', 'upgrade', 'head'], + cwd=self.root / 'backend', env={'ENV_FILE': str(self.root / 'backend/.env'), + 'NO_ENTERPRISE': '1', 'PYTHONPATH': str(self.root / 'backend')}) + progress(self.cfg, 'verifying', '正在启动服务并检查新版本。') + self.run(['systemctl', 'start', 'replypier-backend.service'], timeout=90) + self.healthy() + state = self.root / 'deploy/local/.state' + for name in ('assets', 'dashboard'): + (state / name).rename(backup / name) + (stage / name).rename(state / name) + + def restore(self, backup: Path, *, boot: bool = False) -> None: + if not boot: + self.services('stop') + manifest = json.loads((backup / 'manifest.json').read_text()) + for path in set(manifest['new']) - set(manifest['old']): + if not repository.protected(path): + (self.root / path).unlink(missing_ok=True) + with tarfile.open(backup / 'code.tar.gz') as tar: + tar.extractall(self.root, filter='data') + venv = self.root / 'backend/.venv' + if (backup / 'original-venv').exists(): + if venv.is_symlink(): + venv.unlink() + (backup / 'original-venv').rename(venv) + elif (backup / 'venv-link').exists(): + if venv.is_symlink(): + venv.unlink() + venv.symlink_to((backup / 'venv-link').read_text(), target_is_directory=True) + state = self.root / 'deploy/local/.state' + for name in ('assets', 'dashboard'): + if (backup / name).exists(): + if (state / name).exists(): + (state / name).rename(backup / f'failed-{name}') + (backup / name).rename(state / name) + for _ in range(30): + ready = subprocess.run(['docker', 'exec', self.cfg['db_container'], 'pg_isready', + '-U', self.cfg['db_user'], '-d', 'postgres'], capture_output=True) + if ready.returncode == 0: + break + time.sleep(2) + else: + raise RuntimeError('数据库尚未就绪,备份和维护标记已保留。') + with (backup / 'database.dump').open('rb') as dump: + self.run(['docker', 'exec', '-i', self.cfg['db_container'], 'pg_restore', + '--clean', '--if-exists', '--create', '--exit-on-error', '--no-owner', '--no-privileges', + '-U', self.cfg['db_user'], '-d', 'postgres'], stdin=dump) + for source in (backup / 'agent').glob('*.py'): + shutil.copy2(source, Path('/opt/replypier-updater') / source.name) + for source in (backup / 'systemd').glob('*'): + shutil.copy2(source, Path('/etc/systemd/system') / source.name) + self.run(['systemctl', 'daemon-reload'], timeout=30) + if not boot: + self.services('start') + self.healthy() + + def recover(self, *, boot: bool = False) -> None: + if not (self.state_dir / 'maintenance').exists(): + if read(self.cfg)['update']['status'] in BUSY: + progress(self.cfg, 'failed', '更新进程已中断,在线版本未改变。', finished_at=now()) + return + state = read(self.cfg) + update = state['update'] + if update['status'] == 'succeeded': + if not boot: + self.services('start') + self.healthy() + (self.state_dir / 'maintenance').unlink(missing_ok=True) + return + progress(self.cfg, 'rolling_back', '正在恢复更新前的版本。') + if update.get('backup_ready'): + self.restore(Path(update['backup_path']), boot=boot) + with transaction(self.cfg) as state: + state['current'] = repository.commit(self.cfg, update['previous_revision']) + elif not boot: + self.services('start') + self.healthy() + (self.state_dir / 'maintenance').unlink(missing_ok=True) + progress(self.cfg, 'rolled_back', '更新未完成,已恢复原版本。', finished_at=now()) + + def apply(self, target: str) -> None: + backup = None + try: + if shutil.disk_usage(self.root).free < 4 * 1024**3: + raise RuntimeError('可用磁盘空间不足 4 GiB,请先清理磁盘。') + progress(self.cfg, 'preparing', '正在获取已确认的版本。', started_at=now(), error=None) + repository.git(self.cfg, 'cat-file', '-e', target + '^{commit}') + current = read(self.cfg)['current']['revision'] + repository.ensure_forward(self.cfg, current, target) + repository.ensure_clean(self.cfg, current, self.root) + stage = self.prepare(target) + backup = self.backup(stage, current) + self.switch(stage, backup) + # Keep the independent agent recoverable if copying its next version fails. + agent = stage / 'source/deploy/server/updater' + for source in agent.glob('*.py'): + temp = Path('/opt/replypier-updater') / (source.name + '.next') + shutil.copy2(source, temp) + temp.replace(temp.with_suffix('')) + for source in (stage / 'source/deploy/server/systemd').glob('replypier-update*'): + shutil.copy2(source, Path('/etc/systemd/system') / source.name) + self.run(['systemctl', 'daemon-reload'], timeout=30) + self.run(['systemctl', 'start', 'replypier-knowledge.service'], timeout=90) + time.sleep(3) + self.run(['systemctl', 'is-active', '--quiet', 'replypier-knowledge.service'], timeout=10) + with transaction(self.cfg) as state: + state['current'] = repository.commit(self.cfg, target) + state['update'].update(status='succeeded', message='更新完成,服务已恢复。', finished_at=now()) + (self.state_dir / 'maintenance').unlink(missing_ok=True) + except Exception as exc: + error = str(exc) + try: + self.recover() + status = 'rolled_back' if backup else 'failed' + progress(self.cfg, status, '更新失败,原版本仍可使用。', error=error, finished_at=now()) + except Exception as recovery_error: + progress(self.cfg, 'recovery_failed', '自动恢复失败,请管理员执行服务器恢复命令。', + error=f'{error};恢复错误:{recovery_error}', finished_at=now()) + raise diff --git a/deploy/server/updater/repository.py b/deploy/server/updater/repository.py new file mode 100644 index 0000000..35be405 --- /dev/null +++ b/deploy/server/updater/repository.py @@ -0,0 +1,71 @@ +"""Read-only Git access, with a dedicated deployment key and pinned host key.""" +from __future__ import annotations + +import os +import io +import tarfile +import shlex +import subprocess +from pathlib import Path +from typing import Any + +from state import directory + + +def git(cfg: dict[str, Any], *args: str, timeout: int = 120) -> str: + env = {**os.environ, 'GIT_TERMINAL_PROMPT': '0', + 'GIT_SSH_COMMAND': shlex.join([ + 'ssh', '-i', cfg['ssh_key'], '-o', 'IdentitiesOnly=yes', + '-o', f"UserKnownHostsFile={cfg['known_hosts']}", + '-o', 'StrictHostKeyChecking=yes', '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=15'])} + result = subprocess.run(['git', '--git-dir', str(directory(cfg) / 'repository.git'), *args], + env=env, capture_output=True, text=True, timeout=timeout) + if result.returncode: + raise RuntimeError(f"Git 读取失败:{result.stderr.strip()[-800:]}") + return result.stdout.strip() + + +def fetch(cfg: dict[str, Any]) -> str: + repo = directory(cfg) / 'repository.git' + if not repo.exists(): + repo.mkdir() + subprocess.run(['git', 'init', '--bare', str(repo)], check=True, capture_output=True) + git(cfg, 'remote', 'add', 'origin', cfg['repository']) + git(cfg, 'fetch', 'origin', f"+refs/heads/{cfg['branch']}:refs/remotes/origin/{cfg['branch']}") + return git(cfg, 'rev-parse', f"refs/remotes/origin/{cfg['branch']}") + + +def commit(cfg: dict[str, Any], revision: str) -> dict[str, str]: + parts = git(cfg, 'show', '-s', '--format=%H%n%cI%n%s', revision).split('\n', 2) + return dict(zip(('revision', 'date', 'subject'), parts)) + + +def paths(cfg: dict[str, Any], revision: str) -> list[str]: + return git(cfg, 'ls-tree', '-r', '--name-only', revision).splitlines() + + +def ensure_forward(cfg: dict[str, Any], current: str, target: str) -> None: + git(cfg, 'merge-base', '--is-ancestor', current, target) + + +def protected(path: str) -> bool: + p = Path(path) + return p.is_absolute() or '..' in p.parts or any( + part in {'.git', '.venv', 'node_modules', '.state', 'uploads'} for part in p.parts + ) or p.name in {'.env', '.env.prod', '.env.dev'} or p.suffix in {'.pem', '.key'} + + +def ensure_clean(cfg: dict[str, Any], revision: str, root: Path) -> None: + result = subprocess.run(['git', '--git-dir', str(directory(cfg) / 'repository.git'), + 'archive', '--format=tar', revision], capture_output=True, check=True) + different = [] + with tarfile.open(fileobj=io.BytesIO(result.stdout)) as tar: + for member in tar: + if not member.isfile() or protected(member.name): + continue + actual = root / member.name + if not actual.exists() or actual.read_bytes() != tar.extractfile(member).read(): + different.append(member.name) + if different: + raise RuntimeError('服务器有未同步的源码,请先同步到 Gitea:' + '、'.join(different[:5])) diff --git a/deploy/server/updater/state.py b/deploy/server/updater/state.py new file mode 100644 index 0000000..9a920df --- /dev/null +++ b/deploy/server/updater/state.py @@ -0,0 +1,70 @@ +"""Persistent state shared by the independent update services.""" +from __future__ import annotations + +import fcntl +import json +import os +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterator + +CONFIG_FILE = Path('/etc/replypier-updater/config.json') +BUSY = {'queued', 'preparing', 'building', 'backing_up', 'deploying', 'migrating', 'verifying', 'rolling_back'} + + +def now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def config() -> dict[str, Any]: + return json.loads(CONFIG_FILE.read_text()) + + +def directory(cfg: dict[str, Any]) -> Path: + return Path(cfg['state_dir']) + + +def read(cfg: dict[str, Any]) -> dict[str, Any]: + p = directory(cfg) / 'status.json' + return json.loads(p.read_text()) if p.exists() else { + 'current': {'revision': cfg['initial_revision'], 'subject': '已安装版本'}, + 'latest': None, 'checked_at': None, 'checking': False, + 'check_error': None, 'update': {'status': 'idle', 'message': ''}, + } + + +def write(cfg: dict[str, Any], state: dict[str, Any]) -> None: + target = directory(cfg) / 'status.json' + temp = target.with_suffix('.tmp') + with temp.open('w') as out: + json.dump(state, out, ensure_ascii=False, indent=2) + out.flush() + os.fsync(out.fileno()) + temp.chmod(0o644) + temp.replace(target) + + +@contextmanager +def transaction(cfg: dict[str, Any]) -> Iterator[dict[str, Any]]: + directory(cfg).mkdir(parents=True, exist_ok=True) + with (directory(cfg) / 'state.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + state = read(cfg) + yield state + write(cfg, state) + + +def progress(cfg: dict[str, Any], status: str, message: str, **values: Any) -> None: + with transaction(cfg) as state: + state['update'].update(status=status, message=message, **values) + + +def public_status(cfg: dict[str, Any]) -> dict[str, Any]: + state = read(cfg) + current = state['current']['revision'] + 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 != current and not state.get('check_error'))} diff --git a/frontend/src/__tests__/views/settings/SystemUpdates.spec.ts b/frontend/src/__tests__/views/settings/SystemUpdates.spec.ts new file mode 100644 index 0000000..6d0f532 --- /dev/null +++ b/frontend/src/__tests__/views/settings/SystemUpdates.spec.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { computed, ref } from 'vue' +import SystemUpdates from '@/views/settings/SystemUpdates.vue' +import type { UpdateStatus } from '@/services/systemUpdates' +const state = ref({ configured: true, can_manage: true }) +const apply = vi.fn().mockResolvedValue(undefined) +const check = vi.fn().mockResolvedValue(undefined) +vi.mock('@/composables/useSystemUpdates', () => ({ useSystemUpdates: () => ({ + state, busy: computed(() => state.value.update?.status === 'building'), error: ref(''), + refresh: vi.fn().mockResolvedValue(undefined), check, apply, +}) })) +vi.mock('@/layouts/DashboardLayout.vue', () => ({ default: { template: '
' } })) + +beforeEach(() => { + vi.clearAllMocks() + state.value = { configured: true, can_manage: true, current: { revision: 'a'.repeat(40), subject: 'current' }, + latest: { revision: 'b'.repeat(40), subject: 'new' }, check_interval_minutes: 30, update_available: true, + update: { status: 'idle', message: '' } } +}) + +describe('SystemUpdates', () => { + it('requires explicit confirmation before starting an update', async () => { + const wrapper = mount(SystemUpdates) + await flushPromises() + const button = wrapper.findAll('button').find(b => b.text() === '更新到此版本')! + await button.trigger('click') + expect(wrapper.find('[role=dialog]').exists()).toBe(true) + expect(apply).not.toHaveBeenCalled() + await wrapper.findAll('button').find(b => b.text() === '备份并开始更新')!.trigger('click') + await flushPromises() + expect(apply).toHaveBeenCalledOnce() + wrapper.unmount() + }) + it('disables installation when there is no newer version or an update is running', async () => { + state.value.update_available = false + const wrapper = mount(SystemUpdates) + await flushPromises() + expect(wrapper.find('.update-actions button').attributes('disabled')).toBeDefined() + state.value.update_available = true + state.value.update = { status: 'building', message: '正在构建' } + await flushPromises() + expect(wrapper.find('.update-actions button').attributes('disabled')).toBeDefined() + expect(wrapper.text()).toContain('正在构建') + wrapper.unmount() + }) + it('shows a failed update and recovery result', async () => { + state.value.update = { status: 'rolled_back', message: '已恢复原版本', error: '构建失败' } + const wrapper = mount(SystemUpdates) + await flushPromises() + expect(wrapper.text()).toContain('已恢复原版本') + expect(wrapper.text()).toContain('构建失败') + wrapper.unmount() + }) +}) diff --git a/frontend/src/components/layout/SystemUpdateNotice.vue b/frontend/src/components/layout/SystemUpdateNotice.vue new file mode 100644 index 0000000..5685b5f --- /dev/null +++ b/frontend/src/components/layout/SystemUpdateNotice.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/layout/navItems.ts b/frontend/src/components/layout/navItems.ts index 21f8785..60ca9ae 100644 --- a/frontend/src/components/layout/navItems.ts +++ b/frontend/src/components/layout/navItems.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { computed } from 'vue' +import { useSystemUpdates } from '@/composables/useSystemUpdates' import { permissionChecks } from '@/utils/permissions' // Visibility comes from the same map the router enforces, so a link can never // appear for a page the guard will refuse (People used to do exactly that). @@ -47,6 +48,7 @@ export const formatBadgeCount = (count?: number) => export const PRIMARY_NAV_PATHS = ['/conversations', '/people', '/ai-agents', '/analytics'] export function useNavItems() { + const { state: updateState } = useSystemUpdates() const { hasEnterpriseModule } = useEnterpriseFeatures() // Section membership is explicit rather than inferred from array position: @@ -167,6 +169,12 @@ export function useNavItems() { label: 'AI 配置', show: canAccessPath('/settings/ai-config'), }, + { + to: '/settings/system-updates', + icon: 'integrations', + label: '系统更新', + show: updateState.value?.can_manage === true && canAccessPath('/settings/system-updates'), + }, { to: '/settings/user', icon: 'usersettings', diff --git a/frontend/src/composables/useSystemUpdates.ts b/frontend/src/composables/useSystemUpdates.ts new file mode 100644 index 0000000..ce768af --- /dev/null +++ b/frontend/src/composables/useSystemUpdates.ts @@ -0,0 +1,34 @@ +import { computed, ref } from 'vue' +import { systemUpdates, type UpdateStatus } from '@/services/systemUpdates' + +const state = ref(null) +const error = ref('') +let inFlight: Promise | null = null +const busyStates = new Set(['queued', 'preparing', 'building', 'backing_up', 'deploying', 'migrating', 'verifying', 'rolling_back']) + +export function useSystemUpdates() { + const busy = computed(() => busyStates.has(state.value?.update?.status || '')) + const refresh = (): Promise => { + inFlight ??= systemUpdates.status().then(data => { + state.value = data + error.value = '' + }).catch((reason: unknown) => { + const detail = (reason as { response?: { data?: { detail?: string } } })?.response?.data?.detail + error.value = busy.value ? '后台正在切换版本,连接恢复后会自动显示结果。' : detail || '暂时无法读取更新状态,请稍后重试。' + }).finally(() => { inFlight = null }) + return inFlight + } + const check = async () => { + await systemUpdates.check() + if (state.value) state.value.checking = true + await refresh() + } + const apply = async () => { + const revision = state.value?.latest?.revision + if (!revision) return + await systemUpdates.apply(revision) + if (state.value) state.value.update = { status: 'queued', message: '更新已排队。', target_revision: revision } + await refresh() + } + return { state, error, busy, refresh, check, apply } +} diff --git a/frontend/src/layouts/DashboardLayout.vue b/frontend/src/layouts/DashboardLayout.vue index 2bb2f08..bb8aca5 100644 --- a/frontend/src/layouts/DashboardLayout.vue +++ b/frontend/src/layouts/DashboardLayout.vue @@ -18,6 +18,7 @@ limitations under the License. import { ref, onMounted, watch, computed } from 'vue' import { useAuth } from '@/composables/useAuth' import { useTheme } from '@/composables/useTheme' +import SystemUpdateNotice from '@/components/layout/SystemUpdateNotice.vue' import AppSidebar from '@/components/layout/AppSidebar.vue' import BottomNav from '@/components/layout/BottomNav.vue' import MoreSheet from '@/components/layout/MoreSheet.vue' @@ -83,6 +84,7 @@ const PAGE_TITLES: Record = { '/settings/widget-apps': '网站组件', '/settings/ai-config': 'AI 配置', '/settings/user': '个人设置', + '/settings/system-updates': '系统更新', } const pageTitle = computed(() => PAGE_TITLES[route.path] || '') @@ -319,6 +321,7 @@ const openNotificationsFromSheet = () => {
+
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index bc792a5..960b1ad 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -155,6 +155,12 @@ const baseRoutes = [ component: HumanAgentView, meta: { requiresAuth: true }, }, + { + path: '/settings/system-updates', + name: 'system-updates', + component: () => import('@/views/settings/SystemUpdates.vue'), + meta: { requiresAuth: true, layout: 'dashboard' }, + }, { path: '/settings/organization', name: 'organization-settings', diff --git a/frontend/src/router/routePermissions.ts b/frontend/src/router/routePermissions.ts index 669a5a3..85dbb55 100644 --- a/frontend/src/router/routePermissions.ts +++ b/frontend/src/router/routePermissions.ts @@ -47,6 +47,7 @@ export const ROUTE_PERMISSIONS: Record = { '/faq': ['manage_knowledge'], '/tickets': TICKET_PERMISSIONS, '/tickets/:id': TICKET_PERMISSIONS, + '/settings/system-updates': ['super_admin'], '/settings/ticketing': ['manage_organization'], '/conversations': CHAT_VIEW_PERMISSIONS, '/people': PEOPLE_PERMISSIONS, diff --git a/frontend/src/services/systemUpdates.ts b/frontend/src/services/systemUpdates.ts new file mode 100644 index 0000000..75ffdef --- /dev/null +++ b/frontend/src/services/systemUpdates.ts @@ -0,0 +1,34 @@ +import api from './api' + +export interface Revision { + revision: string + subject: string + date?: string +} +export interface UpdateStatus { + configured: boolean + can_manage: boolean + current?: Revision + latest?: Revision | null + repository_url?: string + branch?: string + check_interval_minutes?: number + checked_at?: string | null + checking?: boolean + check_error?: string | null + update_available?: boolean + update?: { + status: string + message: string + error?: string | null + target_revision?: string + started_at?: string + finished_at?: string + } +} + +export const systemUpdates = { + status: () => api.get('/system-updates').then(r => r.data), + check: () => api.post('/system-updates/check'), + apply: (revision: string) => api.post('/system-updates/apply', { revision }), +} diff --git a/frontend/src/views/settings/SystemUpdates.vue b/frontend/src/views/settings/SystemUpdates.vue new file mode 100644 index 0000000..700fffc --- /dev/null +++ b/frontend/src/views/settings/SystemUpdates.vue @@ -0,0 +1,125 @@ + + +