The upstream project name is gone from code, assets, docs and build
output: brand strings, domains (replypier.site and its app/api/docs/help
subdomains), env prefixes, image and network names, the widget's public
JS API (window.ReplyPier, replypierId/BaseUrl/Config/Token), CSS tokens
(--rp-*), file and directory names, and the WordPress plugin package.
- widget loader is now webclient/replypier.min.js; both bundles rebuilt
from source (backend/assets/widget.{js,css}, frontend/public/webclient)
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and config.dev.js
rewritten rather than string-swapped; the demo config no longer ships
the previous vendor's Firebase/Razorpay/Google keys
- new migration replypier_ai_model_001 adds REPLYPIER to the managed
AIModelType and rewrites pre-rename rows
- local compose project, database, role and volume renamed; LOCAL_DEPLOY
documents carrying the old local data over
302 lines
16 KiB
Python
302 lines
16 KiB
Python
"""Prepare without downtime; back up, switch, verify and recover independently of FastAPI."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
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 verify_frontend(self) -> None:
|
|
web = self.root / 'deploy/local/.state/dashboard'
|
|
with urllib.request.urlopen('http://127.0.0.1/index.html', timeout=10) as response:
|
|
html = response.read()
|
|
if html != (web / 'index.html').read_bytes():
|
|
raise RuntimeError('前端入口未切换到新构建。')
|
|
assets = re.findall(rb'<(?:script|link)\b[^>]*(?:src|href)="(/assets/[^" ]+)"', html)
|
|
if not assets:
|
|
raise RuntimeError('前端入口缺少构建资源。')
|
|
for asset in assets:
|
|
with urllib.request.urlopen('http://127.0.0.1' + asset.decode(), timeout=10) as response:
|
|
content_type = response.headers.get('Content-Type', '').split(';')[0]
|
|
expected = {'text/css'} if asset.endswith(b'.css') else {'text/javascript', 'application/javascript'}
|
|
if content_type not in expected:
|
|
raise RuntimeError('前端静态资源响应异常:' + asset.decode())
|
|
with urllib.request.urlopen('http://127.0.0.1/sw.js', timeout=10) as response:
|
|
if response.headers.get('Content-Type', '').split(';')[0] not in {'text/javascript', 'application/javascript'}:
|
|
raise RuntimeError('离线脚本响应异常。')
|
|
|
|
@staticmethod
|
|
def publish_permissions(root: Path) -> None:
|
|
# systemd uses umask 0077 for private backups, but Caddy must read public builds.
|
|
root.chmod(0o755)
|
|
for path in root.rglob('*'):
|
|
path.chmod(0o755 if path.is_dir() else 0o644)
|
|
|
|
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')
|
|
self.publish_permissions(stage / 'dashboard')
|
|
self.publish_permissions(stage / 'assets')
|
|
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)
|
|
self.verify_frontend()
|
|
|
|
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()
|
|
self.verify_frontend()
|
|
|
|
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
|