修复维护页品牌图片加载
Nginx 精确放行维护页背景图与品牌图标 Pingora 同步维护资产白名单 补充维护模式网关回归测试 更新生产运维与共享决策文档
This commit is contained in:
@@ -9,6 +9,17 @@ if ($genarrative_internal_client) {
|
||||
set $genarrative_maintenance 0;
|
||||
}
|
||||
|
||||
# 维护页自身依赖的品牌图片必须在维护期间保持可读;只放行精确文件,避免扩大公网静态面。
|
||||
location = /branding/taonier-maintenance-page.png {
|
||||
root /srv/genarrative/web;
|
||||
try_files /branding/taonier-maintenance-page.png =404;
|
||||
}
|
||||
|
||||
location = /branding/taonier-product-ip.png {
|
||||
root /srv/genarrative/web;
|
||||
try_files /branding/taonier-product-ip.png =404;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
root /var/lib/genarrative/maintenance;
|
||||
try_files /page.html @genarrative_default_maintenance;
|
||||
|
||||
@@ -4307,7 +4307,7 @@
|
||||
## 2026-07-13 临时维护公告改为 release 外运行态覆盖
|
||||
|
||||
- 背景:一次性停服公告曾直接提交到 `public/maintenance.html`,后续 Web Build 将它持续打入 `web.tar.gz`,每次 Web Deploy 或再次进入维护都会重新显示已经过期的公告。
|
||||
- 决策:`public/maintenance.html` 永久作为无日期、无具体时段的默认维护页,并使用 `public/branding/taonier-maintenance-page.png` 作为品牌视觉;生产 Web 打包必须对最终 `web/maintenance.html` 执行临时文案门禁。临时公告通过 `maintenance-on.sh --page-file <公告HTML>` 原子安装到 `/var/lib/genarrative/maintenance/page.html`,Nginx 与 Pingora 优先读取该运行态文件,缺失时回退 Web 制品默认页。
|
||||
- 决策:`public/maintenance.html` 永久作为无日期、无具体时段的默认维护页,并使用 `public/branding/taonier-maintenance-page.png` 作为品牌视觉;生产 Web 打包必须对最终 `web/maintenance.html` 执行临时文案门禁。临时公告通过 `maintenance-on.sh --page-file <公告HTML>` 原子安装到 `/var/lib/genarrative/maintenance/page.html`,Nginx 与 Pingora 优先读取该运行态文件,缺失时回退 Web 制品默认页。维护期间只精确放行 `/branding/taonier-maintenance-page.png` 与 `/branding/taonier-product-ip.png`,不得扩大到整个品牌或静态资源目录;网关 smoke 必须验证这两个路径仍返回 PNG,同时其它公网页面、API 与后台静态资源继续命中维护门禁。
|
||||
- 生命周期:新维护窗口未提供 `--page-file` 时清理 marker 外残留公告;同一窗口内 Stdb / API 发布重复调用 `maintenance-on.sh` 时保留已安装公告;`maintenance-off.sh` 同时清理 marker 和公告页。Web Deploy 不再拥有临时公告事实源。
|
||||
- 影响范围:默认维护页、维护开关脚本、Nginx snippet、Pingora 配置与 smoke、生产 Web 发布包门禁和生产运维文档。
|
||||
- 验证方式:`npm run check:maintenance-page`、`npm run check:nginx-spa-routes`、`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml`、`npm run check:pingora-gateway-smoke`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
@@ -92,6 +92,8 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去
|
||||
|
||||
Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发布成功后是否退出维护,默认勾选以保持历史行为。Full 对 Stdb Publish 和 API Deploy 两个下游阶段都固定传 `KEEP_MAINTENANCE_MODE=true`,让 maintenance marker 持续覆盖 Stdb → API → Web 整段发布;Web Deploy 成功后才进入独立 `Exit Maintenance` 阶段。该阶段只能通过 `agent none` 和显式 `node(...)` 分配目标机,直接执行 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`;目标机不得 checkout Git、挂载 Git SSH 凭据或依赖 Jenkins workspace 源码。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。
|
||||
|
||||
维护门禁启用时,Nginx 与 Pingora 只精确放行默认维护页依赖的 `/branding/taonier-maintenance-page.png` 和 `/branding/taonier-product-ip.png`;不得放开整个 `/branding/`、`/assets/` 或后台静态目录。公网验收除主站、后台和 API 继续返回维护响应外,还必须确认这两个品牌图片返回 `200 image/png`,避免维护页 HTML 正常但背景图请求被再次改写成 `503`。
|
||||
|
||||
需要验证“更新 API 不停 worker”和“worker 是否持续消费队列”时,优先使用隔离容器 smoke:`npm run container:worker-smoke -- smoke`。该脚本生成 gitignored 的 `deploy/container/worker-smoke/api-server.env`,启动独立 compose project 与独立 SpacetimeDB,发布当前 `spacetime-module` 后写入 `source_module = editor-canvas`、`job_kind = worker_smoke_unsupported` 的测试 job;预期 worker claim 后执行 unsupported 失败分支,再执行 API-only recreate 并确认 worker 容器 ID 不变,最后再次入队验证 API 更新后队列仍可消费。`external_generation_job` 是 private table,脚本通过 worker 日志确认 job_id 被消费,不用 CLI SQL 查询私表。该 smoke 不读取 `.env.local`,也不依赖真实 VectorEngine / OSS 密钥;真实生图链路联调再在本地私有 env 中补齐 provider 配置。worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免本机首次 smoke 依赖官方大镜像下载。若容器内 Cargo 拉取 crates.io 依赖不稳定,可用 `npm run container:worker-smoke -- smoke --local-binary` 让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入 Debian bookworm smoke runtime 临时镜像;可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;若隔离端口或库数据需要重建,追加 `--force`。完成 queue 链路验证时,用队列概览 BFF、单 job 状态接口和 worker 日志确认任务从 queued/running 收敛到预期失败态。
|
||||
|
||||
本地只做账号/UI smoke 且需要短信登录时,`SMS_AUTH_PROVIDER` 应显式设为 `mock`,并把 `SMS_AUTH_MOCK_VERIFY_CODE` 设为固定值(当前常用 `123456`),再重启 `npm run dev` 或 `npm run dev:api-server`。如果 `.env.local` 还保留 `SMS_AUTH_PROVIDER=aliyun`,`POST /api/auth/phone/login` 用 mock 验证码会稳定报“验证码错误”,不是前端表单问题。真实短信联调再切回 `aliyun` 并重启。
|
||||
|
||||
@@ -148,6 +148,10 @@ function validateGatewayConfiguration() {
|
||||
'try_files /page.html @genarrative_default_maintenance;',
|
||||
'location @genarrative_default_maintenance',
|
||||
'root /srv/genarrative/web;',
|
||||
'location = /branding/taonier-maintenance-page.png',
|
||||
'try_files /branding/taonier-maintenance-page.png =404;',
|
||||
'location = /branding/taonier-product-ip.png',
|
||||
'try_files /branding/taonier-product-ip.png =404;',
|
||||
]) {
|
||||
if (!nginxSnippet.includes(expected)) {
|
||||
fail(`Nginx 维护页配置缺少运行态覆盖约束: ${expected}`);
|
||||
@@ -161,6 +165,7 @@ function validateGatewayConfiguration() {
|
||||
for (const expected of [
|
||||
'GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE',
|
||||
'maintenance_page_file',
|
||||
'is_maintenance_page_asset(path)',
|
||||
]) {
|
||||
if (!pingoraSource.includes(expected)) {
|
||||
fail(`Pingora 维护页配置缺少运行态覆盖约束: ${expected}`);
|
||||
|
||||
@@ -164,6 +164,10 @@ function validateMaintenanceInternalBypass() {
|
||||
fail(`${MAINTENANCE_SNIPPET_PATH} 不应保留仅后台使用的维护变量。`);
|
||||
}
|
||||
for (const fragment of [
|
||||
'location = /branding/taonier-maintenance-page.png {',
|
||||
'try_files /branding/taonier-maintenance-page.png =404;',
|
||||
'location = /branding/taonier-product-ip.png {',
|
||||
'try_files /branding/taonier-product-ip.png =404;',
|
||||
'location = /404.html {',
|
||||
'if ($http_accept !~* "text/html") {',
|
||||
'try_files /404.html =404;',
|
||||
|
||||
@@ -347,6 +347,19 @@ async function prepareStaticRoots(webRoot, acmeRoot) {
|
||||
path.join(webRoot, 'maintenance.html'),
|
||||
'<main>default-maintenance</main>',
|
||||
);
|
||||
await mkdir(path.join(webRoot, 'branding'), { recursive: true });
|
||||
for (const fileName of [
|
||||
'taonier-maintenance-page.png',
|
||||
'taonier-product-ip.png',
|
||||
]) {
|
||||
await writeFile(
|
||||
path.join(webRoot, 'branding', fileName),
|
||||
Buffer.concat([
|
||||
PNG_MAGIC_BYTES,
|
||||
Buffer.from([0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
await writeFile(path.join(webRoot, '404.html'), '<main>not-found-page</main>');
|
||||
await writeFile(
|
||||
path.join(acmeRoot, '.well-known', 'acme-challenge', 'token'),
|
||||
@@ -1204,6 +1217,14 @@ async function runSmokeCases(
|
||||
},
|
||||
);
|
||||
}
|
||||
for (const path of [
|
||||
'/branding/taonier-maintenance-page.png',
|
||||
'/branding/taonier-product-ip.png',
|
||||
]) {
|
||||
await expectStaticPng(baseUrl, path, `维护模式放行品牌图片 ${path}`, {
|
||||
headers: publicClientHeaders,
|
||||
});
|
||||
}
|
||||
await rm(maintenancePageFile, { force: true });
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
@@ -1433,9 +1454,10 @@ async function expectNotCompressedResponse(baseUrl, route, bodyNeedle, label) {
|
||||
}
|
||||
}
|
||||
|
||||
async function expectStaticPng(baseUrl, route, label) {
|
||||
async function expectStaticPng(baseUrl, route, label, options = {}) {
|
||||
console.log(`[pingora-gateway-smoke] ${label}`);
|
||||
const response = await requestHttp(`${baseUrl}${route}`, {
|
||||
headers: options.headers,
|
||||
rawBody: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1117,6 +1117,7 @@ impl ProxyHttp for GenarrativeGateway {
|
||||
let internal_bypass =
|
||||
allows_internal_maintenance_bypass(request_source_ip(session).as_ref());
|
||||
let should_apply_maintenance = !internal_bypass
|
||||
&& !is_maintenance_page_asset(path)
|
||||
&& (is_admin_request_path(path) || ctx.route.applies_maintenance_gate());
|
||||
if should_apply_maintenance {
|
||||
respond_maintenance(
|
||||
@@ -1887,6 +1888,13 @@ fn is_main_spa_path(path: &str) -> bool {
|
||||
.any(|candidate| normalized.eq_ignore_ascii_case(candidate))
|
||||
}
|
||||
|
||||
fn is_maintenance_page_asset(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/branding/taonier-maintenance-page.png" | "/branding/taonier-product-ip.png"
|
||||
)
|
||||
}
|
||||
|
||||
fn classify_http_redirect_path(path: &str) -> RouteDecision {
|
||||
if path.starts_with("/.well-known/acme-challenge/") {
|
||||
return RouteDecision::Local(LocalResponse::Static {
|
||||
@@ -3308,6 +3316,23 @@ mod tests {
|
||||
assert!(!allows_internal_maintenance_bypass(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maintenance_only_allows_required_branding_assets() {
|
||||
for path in [
|
||||
"/branding/taonier-maintenance-page.png",
|
||||
"/branding/taonier-product-ip.png",
|
||||
] {
|
||||
assert!(is_maintenance_page_asset(path));
|
||||
}
|
||||
for path in [
|
||||
"/branding/other.png",
|
||||
"/branding/taonier-maintenance-page.png/extra",
|
||||
"/assets/app.js",
|
||||
] {
|
||||
assert!(!is_maintenance_page_asset(path));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_gateway_hosts_for_matching() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user