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:
1 parent
9b5463ecb3
commit
e2bafd8bff
28 files changed
+1346
-1
No files matched your search
@@ -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))
|
||||
@@ -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
@@ -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)))
|
||||
@@ -22,6 +22,8 @@ from pathlib import Path
|
||||
# Add the parent directory to Python path to allow imports
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from app.core.system_maintenance import wait_for_deployment
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.core.logger import get_logger
|
||||
@@ -64,6 +66,7 @@ async def run_auto_closer_loop():
|
||||
iteration = 0
|
||||
|
||||
while True:
|
||||
await wait_for_deployment()
|
||||
iteration += 1
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ from pathlib import Path
|
||||
# Add the parent directory to Python path to allow imports
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from app.core.system_maintenance import wait_for_deployment
|
||||
|
||||
from app.workers.knowledge_processor import run_processor
|
||||
from app.core.logger import get_logger
|
||||
from app.services.firebase import initialize_firebase
|
||||
@@ -54,6 +56,7 @@ async def run_processor_loop():
|
||||
iteration = 0
|
||||
|
||||
while True:
|
||||
await wait_for_deployment()
|
||||
iteration += 1
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 提交流程。
|
||||
@@ -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"
|
||||
}
|
||||
Executable
+50
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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]))
|
||||
@@ -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 { 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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
'/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 = () => {
|
||||
|
||||
<div class="topbar-spacer"></div>
|
||||
|
||||
<SystemUpdateNotice />
|
||||
<SupportChatButton v-if="hasEnterpriseModule" />
|
||||
<PromoHeaderPill v-if="hasEnterpriseModule" />
|
||||
<div v-if="hasEnterpriseModule && (isLoadingPlan || isInTrial)" class="plan-display">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -47,6 +47,7 @@ export const ROUTE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
'/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,
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user