fix(updates): verify readable frontend assets before reopening traffic
Keep backups private while publishing build directories as 755 and files as 644. Verify the served entry, JS/CSS and service worker before success. Document cache policy for stable widget and runtime entry URLs.
This commit is contained in:
1 parent
f3ad539998
commit
6a4127ed24
3 files changed
+60
-2
No files matched your search
@@ -9,7 +9,7 @@
|
||||
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 入口。
|
||||
4. Caddy 对稳定文件名的入口返回 `Cache-Control: no-cache, no-store, must-revalidate`:`/sw.js`、`/manifest.webmanifest`、`/config.js`、`/assets/widget.js`、`/assets/widget.css`、`/webclient/replypier.min.js`。首次设置后,清除 CDN 中这些 URL 的已有缓存;带内容版本号的构建资源可以正常缓存。不要缓存 HTML 入口。
|
||||
|
||||
## 更新时发生什么
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
- 当前源码若有未同步到 Gitea 的修改,停止更新,先保留并同步修改。
|
||||
- 在独立目录安装前端依赖、构建控制台和网站组件。后端锁文件有变化时创建独立 Python 环境。准备期间原服务继续运行,磁盘可用空间须至少 4 GiB。
|
||||
- 暂停业务请求和后台处理,停止应用服务,备份程序及 PostgreSQL;验证备份可读取后才修改在线版本。
|
||||
- 安装源码、执行 Alembic 迁移、启动服务并检查健康,最后切换前端并恢复接待。`.env`、上传文件、数据库卷和已有运行配置不会被仓库文件覆盖。
|
||||
- 安装源码、执行 Alembic 迁移、启动服务并检查健康,切换前端后验证入口、JS/CSS 和离线脚本可访问,再恢复接待。公开构建目录设置为 `755`、文件为 `644`,私有备份仍由 `0077` umask 保护。`.env`、上传文件、数据库卷和已有运行配置不会被仓库文件覆盖。
|
||||
- 安装失败时恢复程序和数据库;构建失败不影响在线版本。断电后由开机恢复服务先恢复中断的部署,再启动后端。
|
||||
|
||||
更新状态、备份和日志位于 `/var/lib/replypier-updater/`,由 systemd 服务独立执行,网页关闭或后端重启不会取消更新。备份可能含业务数据,不要公开。备份不自动删除;接近空间下限时更新会停止,管理员确认恢复需求后再清理旧备份。
|
||||
|
||||
@@ -160,3 +160,30 @@ def test_interrupted_build_does_not_leave_a_permanently_busy_job(cfg):
|
||||
deployment.Deployment(cfg).recover()
|
||||
assert state.read(cfg)['update']['status'] == 'failed'
|
||||
assert state.read(cfg)['current']['revision'] == CURRENT
|
||||
|
||||
|
||||
def test_public_build_permissions_do_not_inherit_private_backup_umask(tmp_path):
|
||||
root = tmp_path / 'dashboard'
|
||||
root.mkdir(mode=0o700)
|
||||
(root / 'assets').mkdir(mode=0o700)
|
||||
(root / 'assets/main.js').write_text('export default {}')
|
||||
(root / 'assets/main.js').chmod(0o600)
|
||||
deployment.Deployment.publish_permissions(root)
|
||||
assert root.stat().st_mode & 0o777 == 0o755
|
||||
assert (root / 'assets').stat().st_mode & 0o777 == 0o755
|
||||
assert (root / 'assets/main.js').stat().st_mode & 0o777 == 0o644
|
||||
|
||||
|
||||
def test_frontend_verification_rejects_html_instead_of_javascript(cfg, monkeypatch):
|
||||
web = Path(cfg['project_dir']) / 'deploy/local/.state/dashboard'
|
||||
web.mkdir(parents=True)
|
||||
html = b'<html><script type="module" src="/assets/main.js"></script></html>'
|
||||
(web / 'index.html').write_bytes(html)
|
||||
class Response:
|
||||
headers = {'Content-Type': 'text/html'}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_): return False
|
||||
def read(self): return html
|
||||
monkeypatch.setattr(deployment.urllib.request, 'urlopen', lambda *a, **k: Response())
|
||||
with pytest.raises(RuntimeError, match='静态资源响应异常'):
|
||||
deployment.Deployment(cfg).verify_frontend()
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
@@ -45,6 +46,32 @@ class Deployment:
|
||||
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)
|
||||
@@ -85,6 +112,8 @@ class Deployment:
|
||||
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
|
||||
@@ -159,6 +188,7 @@ class Deployment:
|
||||
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:
|
||||
@@ -204,6 +234,7 @@ class Deployment:
|
||||
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():
|
||||
|
||||
Reference in new issue
Block a user