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,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'))}
|
||||
Reference in new issue
Block a user