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 9b5463ecb3
commit e2bafd8bff
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 from fastapi.staticfiles import StaticFiles
import socketio 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 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 as help_center_api
from app.api import help_center_images from app.api import help_center_images
from app.api import channels as channels_api 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()) api.state.auto_closer_task = asyncio.create_task(run_auto_closer_loop())
# Include routers # Include routers
app.include_router(system_updates.router, prefix=f"{settings.API_V1_STR}/system-updates", tags=["system-updates"])
app.include_router( app.include_router(
chat.router, chat.router,
prefix=f"{settings.API_V1_STR}/chats", 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.api.help_center_public import public_app
from app.core.help_center_host import HelpCenterHostMiddleware 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 # Add the parent directory to Python path to allow imports
sys.path.append(str(Path(__file__).parent.parent.parent)) 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.database import SessionLocal
from app.repositories.session_to_agent import SessionToAgentRepository from app.repositories.session_to_agent import SessionToAgentRepository
from app.core.logger import get_logger from app.core.logger import get_logger
@@ -64,6 +66,7 @@ async def run_auto_closer_loop():
iteration = 0 iteration = 0
while True: while True:
await wait_for_deployment()
iteration += 1 iteration += 1
start_time = asyncio.get_event_loop().time() 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 # Add the parent directory to Python path to allow imports
sys.path.append(str(Path(__file__).parent.parent.parent)) 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.workers.knowledge_processor import run_processor
from app.core.logger import get_logger from app.core.logger import get_logger
from app.services.firebase import initialize_firebase from app.services.firebase import initialize_firebase
@@ -54,6 +56,7 @@ async def run_processor_loop():
iteration = 0 iteration = 0
while True: while True:
await wait_for_deployment()
iteration += 1 iteration += 1
start_time = asyncio.get_event_loop().time() start_time = asyncio.get_event_loop().time()
+52
View File
@@ -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()
@@ -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
+35
View File
@@ -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 提交流程。
+15
View File
@@ -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"
}
+50
View File
@@ -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'
@@ -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
@@ -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
@@ -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
@@ -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
+162
View File
@@ -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
+127
View File
@@ -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)
+270
View File
@@ -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
+71
View File
@@ -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]))
+70
View File
@@ -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'))}
@@ -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<UpdateStatus>({ 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: '<div><slot /></div>' } }))
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()
})
})
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { hasPermission } from '@/utils/permissions'
import { useSystemUpdates } from '@/composables/useSystemUpdates'
const { state, busy, refresh } = useSystemUpdates()
let timer: ReturnType<typeof setInterval> | undefined
onMounted(() => {
if (!hasPermission('super_admin')) return
void refresh()
timer = setInterval(() => { void refresh() }, 60000)
})
onUnmounted(() => clearInterval(timer))
</script>
<template>
<RouterLink v-if="state?.can_manage && (state.update_available || busy)"
class="update-notice" to="/settings/system-updates">
<span class="pulse"></span>{{ busy ? '正在更新' : '有新版本' }}
</RouterLink>
</template>
<style scoped>
.update-notice { display: inline-flex; align-items: center; gap: 8px; padding: 7px 12px; border: 1px solid var(--celadon-line); border-radius: var(--radius-pill); color: var(--celadon); background: var(--celadon-soft); font-size: 12px; white-space: nowrap; }
</style>
@@ -15,6 +15,7 @@ limitations under the License.
*/ */
import { computed } from 'vue' import { computed } from 'vue'
import { useSystemUpdates } from '@/composables/useSystemUpdates'
import { permissionChecks } from '@/utils/permissions' import { permissionChecks } from '@/utils/permissions'
// Visibility comes from the same map the router enforces, so a link can never // 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). // 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 const PRIMARY_NAV_PATHS = ['/conversations', '/people', '/ai-agents', '/analytics']
export function useNavItems() { export function useNavItems() {
const { state: updateState } = useSystemUpdates()
const { hasEnterpriseModule } = useEnterpriseFeatures() const { hasEnterpriseModule } = useEnterpriseFeatures()
// Section membership is explicit rather than inferred from array position: // Section membership is explicit rather than inferred from array position:
@@ -167,6 +169,12 @@ export function useNavItems() {
label: 'AI 配置', label: 'AI 配置',
show: canAccessPath('/settings/ai-config'), 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', to: '/settings/user',
icon: 'usersettings', icon: 'usersettings',
@@ -0,0 +1,34 @@
import { computed, ref } from 'vue'
import { systemUpdates, type UpdateStatus } from '@/services/systemUpdates'
const state = ref<UpdateStatus | null>(null)
const error = ref('')
let inFlight: Promise<void> | 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<void> => {
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 }
}
+3
View File
@@ -18,6 +18,7 @@ limitations under the License.
import { ref, onMounted, watch, computed } from 'vue' import { ref, onMounted, watch, computed } from 'vue'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import SystemUpdateNotice from '@/components/layout/SystemUpdateNotice.vue'
import AppSidebar from '@/components/layout/AppSidebar.vue' import AppSidebar from '@/components/layout/AppSidebar.vue'
import BottomNav from '@/components/layout/BottomNav.vue' import BottomNav from '@/components/layout/BottomNav.vue'
import MoreSheet from '@/components/layout/MoreSheet.vue' import MoreSheet from '@/components/layout/MoreSheet.vue'
@@ -83,6 +84,7 @@ const PAGE_TITLES: Record<string, string> = {
'/settings/widget-apps': '网站组件', '/settings/widget-apps': '网站组件',
'/settings/ai-config': 'AI 配置', '/settings/ai-config': 'AI 配置',
'/settings/user': '个人设置', '/settings/user': '个人设置',
'/settings/system-updates': '系统更新',
} }
const pageTitle = computed(() => PAGE_TITLES[route.path] || '') const pageTitle = computed(() => PAGE_TITLES[route.path] || '')
@@ -319,6 +321,7 @@ const openNotificationsFromSheet = () => {
<div class="topbar-spacer"></div> <div class="topbar-spacer"></div>
<SystemUpdateNotice />
<SupportChatButton v-if="hasEnterpriseModule" /> <SupportChatButton v-if="hasEnterpriseModule" />
<PromoHeaderPill v-if="hasEnterpriseModule" /> <PromoHeaderPill v-if="hasEnterpriseModule" />
<div v-if="hasEnterpriseModule && (isLoadingPlan || isInTrial)" class="plan-display"> <div v-if="hasEnterpriseModule && (isLoadingPlan || isInTrial)" class="plan-display">
+6
View File
@@ -155,6 +155,12 @@ const baseRoutes = [
component: HumanAgentView, component: HumanAgentView,
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: '/settings/system-updates',
name: 'system-updates',
component: () => import('@/views/settings/SystemUpdates.vue'),
meta: { requiresAuth: true, layout: 'dashboard' },
},
{ {
path: '/settings/organization', path: '/settings/organization',
name: 'organization-settings', name: 'organization-settings',
+1
View File
@@ -47,6 +47,7 @@ export const ROUTE_PERMISSIONS: Record<string, readonly string[]> = {
'/faq': ['manage_knowledge'], '/faq': ['manage_knowledge'],
'/tickets': TICKET_PERMISSIONS, '/tickets': TICKET_PERMISSIONS,
'/tickets/:id': TICKET_PERMISSIONS, '/tickets/:id': TICKET_PERMISSIONS,
'/settings/system-updates': ['super_admin'],
'/settings/ticketing': ['manage_organization'], '/settings/ticketing': ['manage_organization'],
'/conversations': CHAT_VIEW_PERMISSIONS, '/conversations': CHAT_VIEW_PERMISSIONS,
'/people': PEOPLE_PERMISSIONS, '/people': PEOPLE_PERMISSIONS,
+34
View File
@@ -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<UpdateStatus>('/system-updates').then(r => r.data),
check: () => api.post('/system-updates/check'),
apply: (revision: string) => api.post('/system-updates/apply', { revision }),
}
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import DashboardLayout from '@/layouts/DashboardLayout.vue'
import { useSystemUpdates } from '@/composables/useSystemUpdates'
const { state, error, busy, refresh, check, apply } = useSystemUpdates()
const confirming = ref(false)
const dialog = ref<HTMLElement | null>(null)
const updateButton = ref<HTMLButtonElement | null>(null)
watch(confirming, async open => {
await nextTick()
if (open) dialog.value?.querySelector<HTMLButtonElement>('button')?.focus()
else updateButton.value?.focus()
})
const trapFocus = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return
const buttons = [...(dialog.value?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') || [])]
const first = buttons[0], last = buttons[buttons.length - 1]
if (!first || (event.shiftKey && document.activeElement === first) || (!event.shiftKey && document.activeElement === last)) {
event.preventDefault()
;(event.shiftKey ? last : first)?.focus()
}
}
const pending = ref(false)
const actionError = ref('')
const canUpdate = computed(() => state.value?.update_available && !busy.value && !state.value.checking && state.value.update?.status !== 'recovery_failed' && !pending.value)
const resultTitle = computed(() => {
if (busy.value) return '更新进行中'
if (state.value?.check_error) return '暂时无法检查新版本'
if (state.value?.update_available) return '有新版本可以更新'
return '当前已是最新版本'
})
const formatTime = (value?: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚未检查'
const short = (value?: string) => value?.slice(0, 8) || '—'
const run = async (action: () => Promise<void>) => {
pending.value = true
actionError.value = ''
try { await action(); confirming.value = false }
catch (reason: unknown) {
actionError.value = (reason as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '请求未完成,请刷新状态后再试。'
} finally { pending.value = false }
}
let disposed = false
let timer: ReturnType<typeof setTimeout>
const poll = async () => {
await refresh()
if (!disposed) timer = setTimeout(poll, busy.value || state.value?.checking ? 2000 : 60000)
}
onMounted(() => { void poll() })
onUnmounted(() => { disposed = true; clearTimeout(timer) })
</script>
<template>
<DashboardLayout>
<div class="updates-page">
<div class="eyebrow">SYSTEM · 系统</div>
<div class="page-heading"><div><h1>系统更新</h1><p>自动检查 Gitea,准备好后再由你决定更新。</p></div>
<button class="btn btn-secondary" :disabled="busy || pending || state?.checking" @click="run(check)">
{{ state?.checking ? '正在检查…' : '检查更新' }}
</button>
</div>
<p v-if="!state" role="status" class="muted">正在读取版本信息…</p>
<p v-else-if="!state.can_manage" class="muted">系统更新仅对部署管理员开放。</p>
<template v-else>
<section class="panel update-panel" aria-labelledby="update-title">
<div class="status-heading"><span class="status-dot" :class="{ available: state.update_available }"></span>
<h2 id="update-title">{{ resultTitle }}</h2></div>
<p class="muted">每 {{ state.check_interval_minutes }} 分钟自动检查一次,只检查、不自动安装。</p>
<div class="versions">
<div><span class="version-label">当前版本</span><code>{{ short(state.current?.revision) }}</code><p>{{ state.current?.subject }}</p></div>
<div><span class="version-label">Gitea · {{ state.branch }}</span><code>{{ short(state.latest?.revision) }}</code><p>{{ state.latest?.subject || '等待检查结果' }}</p></div>
</div>
<div v-if="busy" class="progress-note" role="status" aria-live="polite"><span class="spinner"></span>{{ state.update?.message }}</div>
<p v-else-if="state.update?.status === 'succeeded'" class="success" role="status">{{ state.update.message }} · {{ formatTime(state.update.finished_at) }}</p>
<div v-else-if="state.update?.error" role="alert" class="error-note"><strong>{{ state.update.message }}</strong><p>{{ state.update.error }}</p></div>
<p v-if="state.check_error" role="alert" class="error-note">{{ state.check_error }}</p>
<div class="update-actions"><span class="muted">上次检查:{{ formatTime(state.checked_at) }}</span>
<button ref="updateButton" class="btn btn-primary" :disabled="!canUpdate" @click="confirming = true">{{ busy ? '正在更新…' : '更新到此版本' }}</button>
</div>
</section>
<p class="update-note">更新前自动备份程序和数据库。构建时可正常接待,切换版本时会短暂停止接待;失败后自动恢复原版本。</p>
<a v-if="state.repository_url" :href="state.repository_url" target="_blank" rel="noopener noreferrer" class="repo-link">在 Gitea 查看源码 ↗</a>
</template>
<p v-if="error || actionError" role="alert" class="error-note">{{ actionError || error }}</p>
<div v-if="confirming" class="confirm-backdrop" @click.self="!pending && (confirming = false)" @keydown.esc="!pending && (confirming = false)">
<section ref="dialog" role="dialog" @keydown="trapFocus" aria-modal="true" aria-labelledby="confirm-title" class="confirm-panel">
<div class="eyebrow">确认更新</div><h2 id="confirm-title">更新到 {{ short(state?.latest?.revision) }}?</h2>
<p>切换版本时会短暂停止接待。请先处理正在进行的会话,更新完成后页面会自动显示结果。</p>
<div class="confirm-actions"><button class="btn btn-secondary" :disabled="pending" @click="confirming = false">暂不更新</button>
<button class="btn btn-primary" :disabled="pending" @click="run(apply)">{{ pending ? '正在提交…' : '备份并开始更新' }}</button></div>
<p v-if="actionError" role="alert" class="error-note">{{ actionError }}</p>
</section>
</div>
</div>
</DashboardLayout>
</template>
<style scoped>
.updates-page { max-width: 1040px; margin: 0 auto; }
.page-heading { display: flex; justify-content: space-between; gap: 24px; align-items: center; margin: 22px 0 38px; }
h1 { font: 600 38px/1.3 var(--font-serif); margin-bottom: 12px; }
.page-heading p, .update-note { color: var(--muted); line-height: 1.8; }
.update-panel { padding: 30px; }
.status-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
h2 { font-size: 20px; font-weight: 600; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--celadon); }
.status-dot.available { background: var(--vermilion); }
.muted { color: var(--muted); font-size: 13px; line-height: 1.7; }
.versions { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; margin: 28px 0; padding: 26px 0; border-block: 1px solid var(--line); }
.version-label { display: block; color: var(--muted); font-size: 12px; margin-bottom: 10px; }
code { font: 24px var(--font-mono); color: var(--ink); }
.versions p { font-size: 13px; color: var(--muted); margin-top: 8px; overflow-wrap: anywhere; }
.update-actions, .confirm-actions { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.update-note { font-size: 13px; margin: 22px 0 10px; }
.repo-link { font-size: 13px; color: var(--celadon); }
.error-note { color: var(--vermilion); background: var(--vermilion-soft); padding: 12px 16px; border-radius: var(--radius-md); margin-block: 16px; font-size: 13px; overflow-wrap: anywhere; }
.progress-note { display: flex; align-items: center; gap: 12px; color: var(--celadon); margin: 18px 0; font-size: 14px; }
.success { color: var(--celadon); margin-bottom: 18px; font-size: 13px; }
.spinner { width: 16px; height: 16px; border: 2px solid var(--celadon-line); border-top-color: var(--celadon); border-radius: 50%; animation: spin 1s linear infinite; }
.confirm-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); background: var(--scrim); display: grid; place-items: center; padding: 24px; }
.confirm-panel { max-width: 480px; background: var(--surface); padding: 32px; border: 1px solid var(--line); border-radius: var(--radius-card-lg); box-shadow: var(--shadow-lg); }
.confirm-panel h2 { margin: 20px 0 16px; }
.confirm-panel p { color: var(--muted); line-height: 1.8; font-size: 14px; }
.confirm-actions { justify-content: flex-end; margin-top: 28px; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 640px) { .page-heading { align-items: flex-start; flex-direction: column; gap: 18px; } h1 { font-size: 30px; } .update-panel { padding: 22px; } .versions { grid-template-columns: 1fr; gap: 24px; } .update-actions { align-items: flex-start; flex-direction: column; } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
</style>