feat(updates): check Gitea automatically and deploy on owner confirmation

Add owner-only update controls, a read-only SSH checker and independent systemd deployment services. Build before maintenance; back up code and database; restore on failed deployment. Preserve runtime configuration and gate business writes during the switch.
This commit is contained in:
WangShen007 committed 2026-10-24 07:26:41 +00:00
1 parent 9b5463ecb3
commit e2bafd8bff
28 files changed
+1346 -1

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
# 从 Gitea 检查并手动更新
部署管理员在 **配置 → 系统更新** 查看当前版本、Gitea `main` 最新提交和上次检查时间。有新版本时,顶部也会出现入口。点击“更新到此版本”,确认暂停接待后才开始安装;每 30 分钟的自动检查不会安装或重启应用。
## 安装到现有 Ubuntu 服务器
本工具适用于 `/srv/replypier` 的 systemd 部署:`replypier-backend`、`replypier-knowledge`,以及 PostgreSQL/Redis 容器。不是 Mac 本机的 Colima 启动脚本。
1. 为服务器创建专用 SSH 密钥,将**公钥**添加为 Gitea 仓库的只读部署密钥;私钥留在 `/etc/replypier-updater/id_ed25519`,权限 `600`。通过可信渠道核实 Gitea 主机公钥并保存为同目录的 `known_hosts`。更新过程不使用账号密码或浏览器登录态。
2. 按 `config.example.json` 准备私有配置,填写当前安装提交和部署管理员用户 UUID。只有名单中的用户且持有 `super_admin` 权限才能更新;其他组织的管理员无权更新整个服务器。配置和 SSH 私钥不入库。
3. 以 root 执行 `./deploy/server/install-updater.sh /path/to/config.json`,然后执行 `python3 /opt/replypier-updater/cli.py check`。安装不会重置应用环境或数据库。
4. Caddy 必须对 `/sw.js` 返回 `Cache-Control: no-cache, no-store, must-revalidate`,以免 CDN 缓存旧的离线版本。首次修改该响应头后,单独清除 CDN 中 `/sw.js` 的已有缓存。不要缓存 HTML 入口。
## 更新时发生什么
- 通过固定的 SSH 仓库获取 `main`,只接受当前版本的后续提交;仓库历史被重写时停止更新。
- 当前源码若有未同步到 Gitea 的修改,停止更新,先保留并同步修改。
- 在独立目录安装前端依赖、构建控制台和网站组件。后端锁文件有变化时创建独立 Python 环境。准备期间原服务继续运行,磁盘可用空间须至少 4 GiB。
- 暂停业务请求和后台处理,停止应用服务,备份程序及 PostgreSQL;验证备份可读取后才修改在线版本。
- 安装源码、执行 Alembic 迁移、启动服务并检查健康,最后切换前端并恢复接待。`.env`、上传文件、数据库卷和已有运行配置不会被仓库文件覆盖。
- 安装失败时恢复程序和数据库;构建失败不影响在线版本。断电后由开机恢复服务先恢复中断的部署,再启动后端。
更新状态、备份和日志位于 `/var/lib/replypier-updater/`,由 systemd 服务独立执行,网页关闭或后端重启不会取消更新。备份可能含业务数据,不要公开。备份不自动删除;接近空间下限时更新会停止,管理员确认恢复需求后再清理旧备份。
## 排查与恢复
```bash
systemctl status replypier-update-check.timer replypier-update.service
journalctl -u replypier-update.service -n 60 --no-pager
sudo tail -n 80 /var/lib/replypier-updater/update.log
sudo python3 /opt/replypier-updater/cli.py recover
curl --fail http://127.0.0.1:8018/health
```
`recover` 用于仍有维护标记的失败或中断更新。如果自动恢复失败,维护模式保持关闭接待,先检查状态中的备份位置和错误,不要删除维护标记、数据库卷或密钥来绕过恢复。Git 拉取使用只读密钥;发布新版本仍通过正常的 Gitea 提交流程。
+15
View File
@@ -0,0 +1,15 @@
{
"project_dir": "/srv/replypier",
"state_dir": "/var/lib/replypier-updater",
"repository": "ssh://git@149.71.241.228:10513/WangShen007/replypier.git",
"web_url": "http://149.71.241.228:10489/WangShen007/replypier",
"branch": "main",
"public_url": "https://app.replypier.site",
"ssh_key": "/etc/replypier-updater/id_ed25519",
"known_hosts": "/etc/replypier-updater/known_hosts",
"owner_ids": ["REPLACE_WITH_DEPLOYMENT_OWNER_USER_ID"],
"initial_revision": "REPLACE_WITH_INSTALLED_COMMIT",
"db_container": "chattermate-local-db-1",
"db_user": "chattermate",
"db_name": "chattermate"
}
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
umask 077
if [[ "$(id -u)" != 0 || "$#" != 1 ]]; then
printf 'Usage (root): %s /path/to/config.json\n' "$0" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
python3 - "$1" <<'PY'
import json,re,sys
from pathlib import Path
from uuid import UUID
cfg=json.loads(Path(sys.argv[1]).read_text())
assert re.fullmatch(r'[0-9a-f]{40}',cfg['initial_revision']), 'Installed commit required'
assert cfg['owner_ids'], 'Deployment owner required'
for owner in cfg['owner_ids']:
UUID(owner)
for key in ('project_dir','ssh_key','known_hosts'):
assert Path(cfg[key]).exists(), f'Missing {key}'
assert Path(cfg['project_dir'],'backend/.env').exists()
assert Path(cfg['project_dir'],'deploy/local/.state/dashboard/config.js').exists()
assert cfg['repository'].startswith('ssh://'), 'Use authenticated SSH, not plain HTTP'
Path(cfg['state_dir']).mkdir(parents=True,exist_ok=True)
PY
install -d -m 700 /etc/replypier-updater
if [[ "$(realpath "$1")" != /etc/replypier-updater/config.json ]]; then
install -m 600 "$1" /etc/replypier-updater/config.json
else
chmod 600 /etc/replypier-updater/config.json
fi
install -d -m 755 /opt/replypier-updater
install -m 644 "${SCRIPT_DIR}"/updater/*.py /opt/replypier-updater/
install -m 644 "${SCRIPT_DIR}"/systemd/* /etc/systemd/system/
install -d -m 755 /etc/systemd/system/replypier-backend.service.d
cat > /etc/systemd/system/replypier-backend.service.d/update-recovery.conf <<'EOF'
[Unit]
Requires=replypier-update-recover.service
After=replypier-update-recover.service
EOF
python3 - <<'PY'
import sys
sys.path.insert(0,'/opt/replypier-updater')
from state import config,transaction
with transaction(config()):
pass
PY
systemctl daemon-reload
systemctl enable --now replypier-update-recover.service replypier-update-check.timer
printf '\nInstalled. Check manually: python3 /opt/replypier-updater/cli.py check\n'
@@ -0,0 +1,11 @@
[Unit]
Description=Check ReplyPier Gitea main for updates (never installs)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /opt/replypier-updater/cli.py check
ExecStopPost=/usr/bin/python3 /opt/replypier-updater/cli.py finish-check
TimeoutStartSec=180
UMask=0077
@@ -0,0 +1,11 @@
[Unit]
Description=Check ReplyPier updates every 30 minutes
[Timer]
OnBootSec=3min
OnUnitActiveSec=30min
Persistent=true
Unit=replypier-update-check.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,15 @@
[Unit]
Description=Recover an interrupted ReplyPier deployment before accepting traffic
After=docker.service
Requires=docker.service
Before=replypier-backend.service replypier-knowledge.service
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /opt/replypier-updater/cli.py recover-boot
RemainAfterExit=yes
TimeoutStartSec=20min
UMask=0077
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,14 @@
[Unit]
Description=Install the ReplyPier version explicitly confirmed by its owner
After=network-online.target docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /opt/replypier-updater/cli.py apply
ExecStopPost=/usr/bin/python3 /opt/replypier-updater/cli.py recover
TimeoutStartSec=2h
TimeoutStopSec=20min
KillMode=control-group
UMask=0077
Nice=10
+162
View File
@@ -0,0 +1,162 @@
import json
import sys
from pathlib import Path
from unittest.mock import Mock
import pytest
sys.path.insert(0, str(Path(__file__).parents[1] / 'updater'))
import cli
import deployment
import repository
import state
CURRENT = 'a' * 40
TARGET = 'b' * 40
@pytest.fixture
def cfg(tmp_path):
cfg = {'state_dir': str(tmp_path / 'state'), 'project_dir': str(tmp_path / 'project'),
'initial_revision': CURRENT, 'owner_ids': ['owner'], 'branch': 'main',
'web_url': 'https://gitea.example/repo'}
Path(cfg['project_dir']).mkdir()
with state.transaction(cfg) as data:
data['latest'] = {'revision': TARGET}
return cfg
def test_check_never_starts_deployment(cfg, monkeypatch):
monkeypatch.setattr(repository, 'fetch', lambda c: TARGET)
monkeypatch.setattr(repository, 'ensure_forward', Mock())
monkeypatch.setattr(repository, 'commit', lambda c, r: {'revision': r})
launch = Mock()
monkeypatch.setattr(cli, 'launch', launch)
cli.check(cfg)
assert state.read(cfg)['latest']['revision'] == TARGET
assert not state.read(cfg)['checking']
launch.assert_not_called()
def test_failed_check_is_visible_and_retryable(cfg, monkeypatch):
monkeypatch.setattr(repository, 'fetch', Mock(side_effect=RuntimeError('unreachable')))
with pytest.raises(RuntimeError):
cli.check(cfg)
data = state.read(cfg)
assert data['check_error'] == 'unreachable'
assert not data['checking']
assert not state.public_status(cfg)['update_available']
def test_request_pins_the_confirmed_revision_and_rejects_duplicates(cfg, monkeypatch):
launch = Mock()
monkeypatch.setattr(cli, 'launch', launch)
assert cli.request(cfg, TARGET, 'owner') == {'accepted': True}
assert state.read(cfg)['update']['target_revision'] == TARGET
launch.assert_called_once_with('replypier-update.service')
with pytest.raises(ValueError, match='已有更新'):
cli.request(cfg, TARGET, 'owner')
@pytest.mark.parametrize('revision,actor,error', [
(TARGET, 'another-admin', PermissionError),
('main;rm -rf /', 'owner', ValueError),
('c' * 40, 'owner', ValueError),
(CURRENT, 'owner', ValueError),
])
def test_unapproved_requests_do_not_start_a_service(cfg, monkeypatch, revision, actor, error):
launch = Mock()
monkeypatch.setattr(cli, 'launch', launch)
with pytest.raises(error):
cli.request(cfg, revision, actor)
launch.assert_not_called()
def test_failed_service_start_releases_the_queued_state(cfg, monkeypatch):
monkeypatch.setattr(cli, 'launch', Mock(side_effect=RuntimeError('systemd unavailable')))
with pytest.raises(RuntimeError):
cli.request(cfg, TARGET, 'owner')
assert state.read(cfg)['update']['status'] == 'failed'
def test_build_failure_keeps_live_service_and_version(cfg, monkeypatch):
d = deployment.Deployment(cfg)
monkeypatch.setattr(deployment.shutil, 'disk_usage', lambda p: type('Usage', (), {'free': 10 * 1024**3})())
monkeypatch.setattr(repository, 'git', Mock())
monkeypatch.setattr(repository, 'ensure_forward', Mock())
monkeypatch.setattr(repository, 'ensure_clean', Mock())
monkeypatch.setattr(d, 'prepare', Mock(side_effect=RuntimeError('build failed')))
services = Mock()
monkeypatch.setattr(d, 'services', services)
with pytest.raises(RuntimeError, match='build failed'):
d.apply(TARGET)
services.assert_not_called()
assert state.read(cfg)['current']['revision'] == CURRENT
assert state.read(cfg)['update']['status'] == 'failed'
def test_recovery_restores_the_previous_version_and_keeps_error(cfg, monkeypatch):
d = deployment.Deployment(cfg)
(state.directory(cfg) / 'maintenance').touch()
with state.transaction(cfg) as data:
data['current'] = {'revision': TARGET}
data['update'] = {'status': 'migrating', 'backup_ready': True,
'backup_path': '/backup', 'previous_revision': CURRENT, 'error': 'migration failed'}
restore = Mock()
monkeypatch.setattr(d, 'restore', restore)
monkeypatch.setattr(repository, 'commit', lambda c, r: {'revision': r})
d.recover()
restore.assert_called_once_with(Path('/backup'), boot=False)
assert state.read(cfg)['current']['revision'] == CURRENT
assert state.read(cfg)['update']['status'] == 'rolled_back'
assert state.read(cfg)['update']['error'] == 'migration failed'
assert not (state.directory(cfg) / 'maintenance').exists()
def test_boot_recovery_does_not_start_a_dependency_of_itself(cfg, monkeypatch):
d = deployment.Deployment(cfg)
(state.directory(cfg) / 'maintenance').touch()
with state.transaction(cfg) as data:
data['update'] = {'status': 'backing_up', 'backup_ready': False}
services = Mock()
monkeypatch.setattr(d, 'services', services)
d.recover(boot=True)
services.assert_not_called()
assert not (state.directory(cfg) / 'maintenance').exists()
def test_recovery_failure_keeps_the_maintenance_gate(cfg, monkeypatch):
d = deployment.Deployment(cfg)
(state.directory(cfg) / 'maintenance').touch()
with state.transaction(cfg) as data:
data['update'] = {'status': 'migrating', 'backup_ready': True, 'backup_path': '/backup'}
monkeypatch.setattr(d, 'restore', Mock(side_effect=RuntimeError('restore failed')))
with pytest.raises(RuntimeError, match='restore failed'):
d.recover()
assert (state.directory(cfg) / 'maintenance').exists()
@pytest.mark.parametrize('path', ['backend/.env','frontend/.env.prod','backend/.venv/a','deploy/local/.state/a','backend/uploads/a','../../etc/passwd','key.pem'])
def test_runtime_files_are_never_deployed(path):
assert repository.protected(path)
def test_env_templates_remain_deployable():
assert not repository.protected('backend/.env.example')
def test_cannot_replace_failed_recovery_with_a_new_job(cfg, monkeypatch):
(state.directory(cfg) / 'maintenance').touch()
launch = Mock()
monkeypatch.setattr(cli, 'launch', launch)
with pytest.raises(ValueError, match='尚未恢复'):
cli.request(cfg, TARGET, 'owner')
launch.assert_not_called()
def test_interrupted_build_does_not_leave_a_permanently_busy_job(cfg):
with state.transaction(cfg) as data:
data['update'] = {'status': 'building', 'message': 'building'}
deployment.Deployment(cfg).recover()
assert state.read(cfg)['update']['status'] == 'failed'
assert state.read(cfg)['current']['revision'] == CURRENT
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Systemd entry point; never launched as a child of the web process for deployment."""
from __future__ import annotations
import argparse
import fcntl
import json
import re
import subprocess
import sys
from pathlib import Path
import repository
from deployment import Deployment
from state import BUSY, config, directory, now, progress, read, transaction
def launch(unit: str) -> None:
result = subprocess.run(['systemctl', 'start', '--no-block', unit], capture_output=True, text=True, timeout=10)
if result.returncode:
raise RuntimeError('无法启动更新服务,请检查 systemd 配置。')
def request(cfg: dict, revision: str, actor: str) -> dict:
if (directory(cfg) / 'maintenance').exists():
raise ValueError('上次更新尚未恢复,请先执行服务器恢复命令。')
if actor not in cfg['owner_ids']:
raise PermissionError('仅部署管理员可以更新。')
if not re.fullmatch(r'[0-9a-f]{40}', revision):
raise ValueError('无效的版本号。')
with transaction(cfg) as state:
if state['update']['status'] in BUSY:
raise ValueError('已有更新正在执行。')
if state.get('checking'):
raise ValueError('正在检查版本,请稍后再试。')
if state.get('check_error') or (state.get('latest') or {}).get('revision') != revision:
raise ValueError('版本信息已变化,请重新检查更新。')
if state['current']['revision'] == revision:
raise ValueError('已经是最新版本。')
state['update'] = {'status': 'queued', 'message': '更新已排队。', 'target_revision': revision,
'actor': actor, 'started_at': now(), 'error': None}
try:
launch('replypier-update.service')
except Exception as exc:
progress(cfg, 'failed', str(exc), finished_at=now())
raise
return {'accepted': True}
def request_check(cfg: dict) -> dict:
with transaction(cfg) as state:
if state['update']['status'] in BUSY:
raise ValueError('更新正在进行,请稍后检查。')
if state.get('checking'):
return {'accepted': True}
state['checking'] = True
try:
launch('replypier-update-check.service')
except Exception:
with transaction(cfg) as state:
state['checking'] = False
raise
return {'accepted': True}
def check(cfg: dict) -> None:
try:
with transaction(cfg) as state:
state['checking'] = True
latest = repository.fetch(cfg)
current = read(cfg)['current']['revision']
repository.ensure_forward(cfg, current, latest)
with transaction(cfg) as state:
state.update(latest=repository.commit(cfg, latest), current=repository.commit(cfg, current),
checked_at=now(), check_error=None)
except Exception as exc:
with transaction(cfg) as state:
state.update(checked_at=now(), check_error=str(exc))
raise
finally:
with transaction(cfg) as state:
state['checking'] = False
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument('action', choices=['check', 'request-check', 'request', 'apply', 'recover', 'recover-boot', 'finish-check'])
parser.add_argument('--revision', default='')
parser.add_argument('--actor', default='')
args = parser.parse_args()
cfg = config()
if args.action == 'finish-check':
with transaction(cfg) as state:
state['checking'] = False
return
if args.action == 'request':
result = request(cfg, args.revision, args.actor)
elif args.action == 'request-check':
result = request_check(cfg)
else:
with (directory(cfg) / 'operation.lock').open('a') as lock:
# Timer checks do not queue behind an in-flight deployment.
try:
fcntl.flock(lock, fcntl.LOCK_EX | (fcntl.LOCK_NB if args.action == 'check' else 0))
except BlockingIOError:
return
if args.action == 'check':
if read(cfg)['update']['status'] in BUSY:
return
check(cfg)
elif args.action in {'recover', 'recover-boot'}:
Deployment(cfg).recover(boot=args.action == 'recover-boot')
else:
state = read(cfg)
if state['update']['status'] != 'queued':
raise ValueError('没有待执行的更新。')
Deployment(cfg).apply(state['update']['target_revision'])
result = {'ok': True}
print(json.dumps(result, ensure_ascii=False))
if __name__ == '__main__':
try:
main()
except Exception as exc:
print(json.dumps({'error': str(exc)}, ensure_ascii=False))
sys.exit(1)
+270
View File
@@ -0,0 +1,270 @@
"""Prepare without downtime; back up, switch, verify and recover independently of FastAPI."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import tarfile
import time
import urllib.request
from pathlib import Path
from typing import Any
import repository
from state import BUSY, directory, now, progress, read, transaction
class Deployment:
def __init__(self, cfg: dict[str, Any]):
self.cfg = cfg
self.root = Path(cfg['project_dir'])
self.state_dir = directory(cfg)
self.log = self.state_dir / 'update.log'
def run(self, args: list[str], *, cwd: Path | None = None,
env: dict[str, str] | None = None, timeout: int = 1800,
stdin: Any = None, stdout: Any = None) -> None:
with self.log.open('ab') as log:
result = subprocess.run(args, cwd=cwd, env={**os.environ, **(env or {})},
stdin=stdin, stdout=stdout or log, stderr=log, timeout=timeout)
if result.returncode:
raise RuntimeError(f'{args[0]} 执行失败(退出码 {result.returncode}),请查看服务器更新日志。')
def services(self, action: str) -> None:
self.run(['systemctl', action, 'replypier-backend.service', 'replypier-knowledge.service'], timeout=90)
def healthy(self) -> None:
for _ in range(45):
try:
with urllib.request.urlopen('http://127.0.0.1:8018/health', timeout=3) as response:
if json.load(response).get('status') == 'healthy':
return
except (OSError, ValueError):
pass
time.sleep(2)
raise RuntimeError('新版本后端未通过健康检查。')
def prepare(self, target: str) -> Path:
stage = self.state_dir / 'releases' / f'{target}-{int(time.time())}'
stage.mkdir(parents=True)
archive = stage / 'source.tar'
self.run(['git', '--git-dir', str(self.state_dir / 'repository.git'),
'archive', '--format=tar', target, '-o', str(archive)])
source = stage / 'source'
source.mkdir()
with tarfile.open(archive) as tar:
tar.extractall(source, filter='data')
archive.unlink()
self.new_paths = repository.paths(self.cfg, target)
if any(repository.protected(p) for p in self.new_paths):
raise RuntimeError('仓库包含受保护的密钥或运行目录,已停止更新。')
front = source / 'frontend'
shutil.copy2(self.root / 'frontend/.env', front / '.env')
progress(self.cfg, 'building', '正在安装前端依赖并构建,网站仍正常接待。')
self.run(['npm', 'ci', '--no-audit', '--no-fund'], cwd=front)
node_env = {'NODE_OPTIONS': '--max-old-space-size=1024'}
vite = str(front / 'node_modules/.bin/vite')
self.run([vite, 'build', '--outDir', str(stage / 'dashboard')], cwd=front, env=node_env)
self.run([vite, 'build', '--config', 'vite.widget.config.ts', '--outDir', str(stage / 'assets')],
cwd=front, env=node_env)
if not (stage / 'assets/widget.css').exists():
styles = list((stage / 'assets/unused').glob('*.css'))
if len(styles) != 1:
raise RuntimeError('网站组件的样式构建结果不完整。')
shutil.copy2(styles[0], stage / 'assets/widget.css')
loader = stage / 'dashboard/webclient/chattermate.min.js'
loader.parent.mkdir(exist_ok=True)
self.run([str(front / 'node_modules/.bin/esbuild'), 'src/webclient/chattermate.js',
'--bundle', '--minify', '--format=iife', '--target=es2015', '--loader:.svg=text',
'--define:process.env.NODE_ENV="production"',
'--define:__CHATTERMATE_API_URL__=' + json.dumps(self.cfg['public_url'] + '/api/v1'),
'--outfile=' + str(loader)], cwd=front)
# Runtime configuration belongs to this installation, not to the repository.
live_web = self.root / 'deploy/local/.state/dashboard'
shutil.copy2(live_web / 'config.js', stage / 'dashboard/config.js')
if (live_web / 'local-demo.html').exists():
shutil.copy2(live_web / 'local-demo.html', stage / 'dashboard/local-demo.html')
shutil.rmtree(front / 'node_modules')
lock = Path('deploy/local/requirements.lock.txt')
self.new_venv = None
if (source / lock).read_bytes() != (self.root / lock).read_bytes():
progress(self.cfg, 'preparing', '后端依赖已变更,正在创建独立环境,网站仍正常接待。')
self.new_venv = stage / 'venv'
self.run(['/usr/bin/python3', '-m', 'venv', str(self.new_venv)])
self.run([str(self.new_venv / 'bin/python'), '-m', 'pip', 'install', '-r', str(source / lock)], timeout=3600)
return stage
def backup(self, stage: Path, current: str) -> Path:
backup = self.state_dir / 'backups' / stage.name
backup.mkdir(parents=True)
(backup / 'agent').mkdir()
(backup / 'systemd').mkdir()
for file in Path('/opt/replypier-updater').glob('*.py'):
shutil.copy2(file, backup / 'agent' / file.name)
for file in Path('/etc/systemd/system').glob('replypier-update*'):
if file.is_file():
shutil.copy2(file, backup / 'systemd' / file.name)
old_paths = repository.paths(self.cfg, current)
with tarfile.open(backup / 'code.tar.gz', 'w:gz') as tar:
for path in old_paths:
file = self.root / path
if file.exists() and not repository.protected(path):
tar.add(file, arcname=path, recursive=False)
(backup / 'manifest.json').write_text(json.dumps({'old': old_paths, 'new': self.new_paths}))
venv = self.root / 'backend/.venv'
if venv.is_symlink():
(backup / 'venv-link').write_text(os.readlink(venv))
with transaction(self.cfg) as state:
state['update']['backup_path'] = str(backup)
state['update']['previous_revision'] = current
state['update']['backup_ready'] = False
progress(self.cfg, 'backing_up', '正在暂停接待并备份数据库;页面会自动恢复。')
(self.state_dir / 'maintenance').touch()
self.services('stop')
with (backup / 'database.dump').open('wb') as out:
self.run(['docker', 'exec', self.cfg['db_container'], 'pg_dump', '-Fc',
'-U', self.cfg['db_user'], '-d', self.cfg['db_name']], stdout=out)
with (backup / 'database.dump').open('rb') as dump:
self.run(['docker', 'exec', '-i', self.cfg['db_container'], 'pg_restore', '--list'], stdin=dump)
with transaction(self.cfg) as state:
state['update']['backup_ready'] = True
return backup
def switch(self, stage: Path, backup: Path) -> None:
progress(self.cfg, 'deploying', '正在安装新版本源码。')
manifest = json.loads((backup / 'manifest.json').read_text())
for path in manifest['new']:
target = self.root / path
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(stage / 'source' / path, target, follow_symlinks=False)
for path in set(manifest['old']) - set(manifest['new']):
if not repository.protected(path):
(self.root / path).unlink(missing_ok=True)
if self.new_venv:
venv = self.root / 'backend/.venv'
if venv.is_symlink():
venv.unlink()
else:
venv.rename(backup / 'original-venv')
venv.symlink_to(self.new_venv, target_is_directory=True)
progress(self.cfg, 'migrating', '正在应用数据库迁移。')
self.run([str(self.root / 'backend/.venv/bin/python'), '-m', 'alembic', 'upgrade', 'head'],
cwd=self.root / 'backend', env={'ENV_FILE': str(self.root / 'backend/.env'),
'NO_ENTERPRISE': '1', 'PYTHONPATH': str(self.root / 'backend')})
progress(self.cfg, 'verifying', '正在启动服务并检查新版本。')
self.run(['systemctl', 'start', 'replypier-backend.service'], timeout=90)
self.healthy()
state = self.root / 'deploy/local/.state'
for name in ('assets', 'dashboard'):
(state / name).rename(backup / name)
(stage / name).rename(state / name)
def restore(self, backup: Path, *, boot: bool = False) -> None:
if not boot:
self.services('stop')
manifest = json.loads((backup / 'manifest.json').read_text())
for path in set(manifest['new']) - set(manifest['old']):
if not repository.protected(path):
(self.root / path).unlink(missing_ok=True)
with tarfile.open(backup / 'code.tar.gz') as tar:
tar.extractall(self.root, filter='data')
venv = self.root / 'backend/.venv'
if (backup / 'original-venv').exists():
if venv.is_symlink():
venv.unlink()
(backup / 'original-venv').rename(venv)
elif (backup / 'venv-link').exists():
if venv.is_symlink():
venv.unlink()
venv.symlink_to((backup / 'venv-link').read_text(), target_is_directory=True)
state = self.root / 'deploy/local/.state'
for name in ('assets', 'dashboard'):
if (backup / name).exists():
if (state / name).exists():
(state / name).rename(backup / f'failed-{name}')
(backup / name).rename(state / name)
for _ in range(30):
ready = subprocess.run(['docker', 'exec', self.cfg['db_container'], 'pg_isready',
'-U', self.cfg['db_user'], '-d', 'postgres'], capture_output=True)
if ready.returncode == 0:
break
time.sleep(2)
else:
raise RuntimeError('数据库尚未就绪,备份和维护标记已保留。')
with (backup / 'database.dump').open('rb') as dump:
self.run(['docker', 'exec', '-i', self.cfg['db_container'], 'pg_restore',
'--clean', '--if-exists', '--create', '--exit-on-error', '--no-owner', '--no-privileges',
'-U', self.cfg['db_user'], '-d', 'postgres'], stdin=dump)
for source in (backup / 'agent').glob('*.py'):
shutil.copy2(source, Path('/opt/replypier-updater') / source.name)
for source in (backup / 'systemd').glob('*'):
shutil.copy2(source, Path('/etc/systemd/system') / source.name)
self.run(['systemctl', 'daemon-reload'], timeout=30)
if not boot:
self.services('start')
self.healthy()
def recover(self, *, boot: bool = False) -> None:
if not (self.state_dir / 'maintenance').exists():
if read(self.cfg)['update']['status'] in BUSY:
progress(self.cfg, 'failed', '更新进程已中断,在线版本未改变。', finished_at=now())
return
state = read(self.cfg)
update = state['update']
if update['status'] == 'succeeded':
if not boot:
self.services('start')
self.healthy()
(self.state_dir / 'maintenance').unlink(missing_ok=True)
return
progress(self.cfg, 'rolling_back', '正在恢复更新前的版本。')
if update.get('backup_ready'):
self.restore(Path(update['backup_path']), boot=boot)
with transaction(self.cfg) as state:
state['current'] = repository.commit(self.cfg, update['previous_revision'])
elif not boot:
self.services('start')
self.healthy()
(self.state_dir / 'maintenance').unlink(missing_ok=True)
progress(self.cfg, 'rolled_back', '更新未完成,已恢复原版本。', finished_at=now())
def apply(self, target: str) -> None:
backup = None
try:
if shutil.disk_usage(self.root).free < 4 * 1024**3:
raise RuntimeError('可用磁盘空间不足 4 GiB,请先清理磁盘。')
progress(self.cfg, 'preparing', '正在获取已确认的版本。', started_at=now(), error=None)
repository.git(self.cfg, 'cat-file', '-e', target + '^{commit}')
current = read(self.cfg)['current']['revision']
repository.ensure_forward(self.cfg, current, target)
repository.ensure_clean(self.cfg, current, self.root)
stage = self.prepare(target)
backup = self.backup(stage, current)
self.switch(stage, backup)
# Keep the independent agent recoverable if copying its next version fails.
agent = stage / 'source/deploy/server/updater'
for source in agent.glob('*.py'):
temp = Path('/opt/replypier-updater') / (source.name + '.next')
shutil.copy2(source, temp)
temp.replace(temp.with_suffix(''))
for source in (stage / 'source/deploy/server/systemd').glob('replypier-update*'):
shutil.copy2(source, Path('/etc/systemd/system') / source.name)
self.run(['systemctl', 'daemon-reload'], timeout=30)
self.run(['systemctl', 'start', 'replypier-knowledge.service'], timeout=90)
time.sleep(3)
self.run(['systemctl', 'is-active', '--quiet', 'replypier-knowledge.service'], timeout=10)
with transaction(self.cfg) as state:
state['current'] = repository.commit(self.cfg, target)
state['update'].update(status='succeeded', message='更新完成,服务已恢复。', finished_at=now())
(self.state_dir / 'maintenance').unlink(missing_ok=True)
except Exception as exc:
error = str(exc)
try:
self.recover()
status = 'rolled_back' if backup else 'failed'
progress(self.cfg, status, '更新失败,原版本仍可使用。', error=error, finished_at=now())
except Exception as recovery_error:
progress(self.cfg, 'recovery_failed', '自动恢复失败,请管理员执行服务器恢复命令。',
error=f'{error};恢复错误:{recovery_error}', finished_at=now())
raise
+71
View File
@@ -0,0 +1,71 @@
"""Read-only Git access, with a dedicated deployment key and pinned host key."""
from __future__ import annotations
import os
import io
import tarfile
import shlex
import subprocess
from pathlib import Path
from typing import Any
from state import directory
def git(cfg: dict[str, Any], *args: str, timeout: int = 120) -> str:
env = {**os.environ, 'GIT_TERMINAL_PROMPT': '0',
'GIT_SSH_COMMAND': shlex.join([
'ssh', '-i', cfg['ssh_key'], '-o', 'IdentitiesOnly=yes',
'-o', f"UserKnownHostsFile={cfg['known_hosts']}",
'-o', 'StrictHostKeyChecking=yes', '-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=15'])}
result = subprocess.run(['git', '--git-dir', str(directory(cfg) / 'repository.git'), *args],
env=env, capture_output=True, text=True, timeout=timeout)
if result.returncode:
raise RuntimeError(f"Git 读取失败:{result.stderr.strip()[-800:]}")
return result.stdout.strip()
def fetch(cfg: dict[str, Any]) -> str:
repo = directory(cfg) / 'repository.git'
if not repo.exists():
repo.mkdir()
subprocess.run(['git', 'init', '--bare', str(repo)], check=True, capture_output=True)
git(cfg, 'remote', 'add', 'origin', cfg['repository'])
git(cfg, 'fetch', 'origin', f"+refs/heads/{cfg['branch']}:refs/remotes/origin/{cfg['branch']}")
return git(cfg, 'rev-parse', f"refs/remotes/origin/{cfg['branch']}")
def commit(cfg: dict[str, Any], revision: str) -> dict[str, str]:
parts = git(cfg, 'show', '-s', '--format=%H%n%cI%n%s', revision).split('\n', 2)
return dict(zip(('revision', 'date', 'subject'), parts))
def paths(cfg: dict[str, Any], revision: str) -> list[str]:
return git(cfg, 'ls-tree', '-r', '--name-only', revision).splitlines()
def ensure_forward(cfg: dict[str, Any], current: str, target: str) -> None:
git(cfg, 'merge-base', '--is-ancestor', current, target)
def protected(path: str) -> bool:
p = Path(path)
return p.is_absolute() or '..' in p.parts or any(
part in {'.git', '.venv', 'node_modules', '.state', 'uploads'} for part in p.parts
) or p.name in {'.env', '.env.prod', '.env.dev'} or p.suffix in {'.pem', '.key'}
def ensure_clean(cfg: dict[str, Any], revision: str, root: Path) -> None:
result = subprocess.run(['git', '--git-dir', str(directory(cfg) / 'repository.git'),
'archive', '--format=tar', revision], capture_output=True, check=True)
different = []
with tarfile.open(fileobj=io.BytesIO(result.stdout)) as tar:
for member in tar:
if not member.isfile() or protected(member.name):
continue
actual = root / member.name
if not actual.exists() or actual.read_bytes() != tar.extractfile(member).read():
different.append(member.name)
if different:
raise RuntimeError('服务器有未同步的源码,请先同步到 Gitea:' + '、'.join(different[:5]))
+70
View File
@@ -0,0 +1,70 @@
"""Persistent state shared by the independent update services."""
from __future__ import annotations
import fcntl
import json
import os
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterator
CONFIG_FILE = Path('/etc/replypier-updater/config.json')
BUSY = {'queued', 'preparing', 'building', 'backing_up', 'deploying', 'migrating', 'verifying', 'rolling_back'}
def now() -> str:
return datetime.now(timezone.utc).isoformat()
def config() -> dict[str, Any]:
return json.loads(CONFIG_FILE.read_text())
def directory(cfg: dict[str, Any]) -> Path:
return Path(cfg['state_dir'])
def read(cfg: dict[str, Any]) -> dict[str, Any]:
p = directory(cfg) / 'status.json'
return json.loads(p.read_text()) if p.exists() else {
'current': {'revision': cfg['initial_revision'], 'subject': '已安装版本'},
'latest': None, 'checked_at': None, 'checking': False,
'check_error': None, 'update': {'status': 'idle', 'message': ''},
}
def write(cfg: dict[str, Any], state: dict[str, Any]) -> None:
target = directory(cfg) / 'status.json'
temp = target.with_suffix('.tmp')
with temp.open('w') as out:
json.dump(state, out, ensure_ascii=False, indent=2)
out.flush()
os.fsync(out.fileno())
temp.chmod(0o644)
temp.replace(target)
@contextmanager
def transaction(cfg: dict[str, Any]) -> Iterator[dict[str, Any]]:
directory(cfg).mkdir(parents=True, exist_ok=True)
with (directory(cfg) / 'state.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
state = read(cfg)
yield state
write(cfg, state)
def progress(cfg: dict[str, Any], status: str, message: str, **values: Any) -> None:
with transaction(cfg) as state:
state['update'].update(status=status, message=message, **values)
def public_status(cfg: dict[str, Any]) -> dict[str, Any]:
state = read(cfg)
current = state['current']['revision']
latest = (state.get('latest') or {}).get('revision')
return {**state, 'configured': True, 'can_manage': True,
'repository_url': cfg['web_url'], 'branch': cfg['branch'],
'check_interval_minutes': 30,
'update_available': bool(latest and latest != current and not state.get('check_error'))}