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
@@ -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