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 ed8dc01ed9
commit f3ad539998
28 files changed
+1346 -1

No files matched your search

+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/replypier.min.js'
loader.parent.mkdir(exist_ok=True)
self.run([str(front / 'node_modules/.bin/esbuild'), 'src/webclient/replypier.js',
'--bundle', '--minify', '--format=iife', '--target=es2015', '--loader:.svg=text',
'--define:process.env.NODE_ENV="production"',
'--define:__REPLYPIER_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'))}