From 0cc2a79c7bff9743c940579f0688a81dfc2d9166 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 14:53:03 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=8B=89=E8=B5=B7=20Pingora?= =?UTF-8?q?=20shadow=20=E5=8F=91=E5=B8=83=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 API deploy 在发布包包含 Pingora 时先校验 shadow systemd 和 env 安全边界 在 shadow 配置安全时自动启动或重启 genarrative-pingora-gateway.service 拒绝 direct-entry capability、公网监听 env,并在 current 切换前失败 补充 production-api-deploy 动态烟测、生产运维护栏和 Pingora 运维文档 在 dev 服务器真实验证 shadow inactive 场景可由发布脚本自动拉起 --- .../shared-memory/decision-log.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 4 +- ...开发运维】Pingora独立网关试点-2026-06-11.md | 4 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- scripts/check-production-api-deploy.mjs | 161 +++++++++++++++--- scripts/check-production-ops-guardrails.mjs | 40 ++++- scripts/deploy/production-api-deploy.sh | 109 ++++++++++-- 7 files changed, 278 insertions(+), 44 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d85d12761..35f2461da 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2329,7 +2329,7 @@ - 决策补充:`/api` 通用路由必须同时检查 `Content-Length` 与实际流式请求体累计字节数;缺少长度头时超过上限也返回统一 `PAYLOAD_TOO_LARGE` JSON。影子部署模板使用 `deploy/systemd/genarrative-pingora-gateway.service`,默认读取 `/etc/genarrative/pingora-gateway.env`,仍只监听本机高端口;`/__genarrative_pingora/healthz` 只在配置并匹配 `X-Genarrative-Pingora-Probe` token 时返回 shadow JSON。 - 决策补充:生产 `genarrative-health-patrol.service` 只在显式配置 `GENARRATIVE_HEALTH_PATROL_PINGORA_BASE_URL` 与 `GENARRATIVE_HEALTH_PATROL_PINGORA_PROBE_TOKEN` 时检查 Pingora shadow probe;未配置时巡检口径不变。Pingora shadow 日志必须保留 request/route/upstream/body 字段,方便和 Nginx access log 做 canary 对照。 - 决策补充:生产健康巡检的公网入口模式必须显式区分 `nginx` 和 `pingora-direct`。默认 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=nginx` 检查 API、SpacetimeDB 和 Nginx;Pingora 直连接管公网后切到 `pingora-direct`,改为检查 API、SpacetimeDB 和 `genarrative-pingora-gateway.service`,不再要求 `nginx.service` active。目标机本机探测 `127.0.0.1` 时用 `GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST=<域名>` 保留正式 Host / vhost 语义。 -- 决策补充:Pingora 影子网关产物不进入默认 API release;只有显式传 `--include-pingora-gateway` 或在 `Genarrative-Api-Build` 勾选 `INCLUDE_PINGORA_GATEWAY` 时,才构建并打包 `pingora-gateway` / `pingora-gateway.sha256`。真实构建 Pingora 前必须先检查 `cmake`、C 编译器和 C++ 编译器;Jenkins 勾选 `INCLUDE_PINGORA_GATEWAY` 时也要先 fail-fast 检查这些工具,避免进入 Cargo 后才因 `libz-ng-sys` 构建依赖缺失失败。`production-api-deploy.sh` 仅在两者同时存在时校验并复制到 current release,避免现有 API 流水线被 Pingora 构建依赖影响。若发布包包含 Pingora 且 `genarrative-pingora-gateway.service` 已经处于 `active`,API deploy 在 `current` 链接切换后执行 `systemctl try-restart genarrative-pingora-gateway.service` 并复核仍为 `active`,让已启用的 shadow / canary / direct 机器加载同一份 current release 网关二进制;若服务未运行则只跳过,不主动拉起 Pingora。`npm run check:production-api-release` 必须同时验证默认 API release 不登记 Pingora,以及显式 `--include-pingora-gateway --skip-pingora-gateway-build` 时发布包包含 `pingora-gateway`、`pingora-gateway.sha256` 并写入 manifest。 +- 决策补充:Pingora 影子网关产物不进入默认 API release;只有显式传 `--include-pingora-gateway` 或在 `Genarrative-Api-Build` 勾选 `INCLUDE_PINGORA_GATEWAY` 时,才构建并打包 `pingora-gateway` / `pingora-gateway.sha256`。真实构建 Pingora 前必须先检查 `cmake`、C 编译器和 C++ 编译器;Jenkins 勾选 `INCLUDE_PINGORA_GATEWAY` 时也要先 fail-fast 检查这些工具,避免进入 Cargo 后才因 `libz-ng-sys` 构建依赖缺失失败。`production-api-deploy.sh` 仅在两者同时存在时校验并复制到 current release,避免现有 API 流水线被 Pingora 构建依赖影响。发布包包含 Pingora 时,API deploy 会在提升 release 前读取 `systemctl cat genarrative-pingora-gateway.service` 和其 `EnvironmentFile`,拒绝 direct-entry `CAP_NET_BIND_SERVICE`、拒绝非 `127.0.0.1:18081` 的 shadow listen、拒绝 `TLS_LISTEN` / `HTTP_REDIRECT_LISTEN`,确认仍是本机 shadow 高端口后才切换 current;切换后执行 `systemctl restart genarrative-pingora-gateway.service` 并复核 active,让 shadow / canary 机器加载同一份 current release 网关二进制。该自动拉起不会启用公网 `80/443` 直连入口;已经进入 direct-entry 状态的机器应走正式直连 runbook 或先回退到 shadow。`npm run check:production-api-release` 必须同时验证默认 API release 不登记 Pingora,以及显式 `--include-pingora-gateway --skip-pingora-gateway-build` 时发布包包含 `pingora-gateway`、`pingora-gateway.sha256` 并写入 manifest。 - 决策补充:生产健康巡检的显式 `--timeout-ms`、`--slow-ms`、`GENARRATIVE_HEALTH_PATROL_TIMEOUT_MS` 和 `GENARRATIVE_HEALTH_PATROL_SLOW_MS` 必须是正整数,非法值直接失败,不静默回退默认 `5000ms` / `3000ms`。Pingora canary live 的 `--timeout-ms` / `GENARRATIVE_PINGORA_CANARY_TIMEOUT_MS`、direct live 的 `--timeout-ms` / `GENARRATIVE_PINGORA_DIRECT_TIMEOUT_MS`、canary access log 对账的 `--since-lines` / `GENARRATIVE_PINGORA_CANARY_ACCESS_LOG_SINCE_LINES` 也必须正整数。Pingora direct live 和 release readiness 读取的直连布尔 env 必须严格解析,只接受 `true/false`、`1/0`、`yes/no`、`on/off` 或空值,非法值直接失败,避免 `REQUIRE_WSS_UPGRADE`、preflight 开关或 `SKIP_WSS` 因拼写错误被当成 false。canary live 的 base URL、prefix、Host、额外 path 和 timeout 不能包含换行或 NUL;脚本必须在发起 canary 请求前失败,避免污染参数进入 URL、Host header 或 JSON 输出。canary access log 对账的日志路径、prefix、必需路径、tail 行数以及日志行中解析出的 URI / path 也不能包含换行或 NUL;脚本必须失败并给出对应参数或日志行诊断,不能把污染值写入 JSON 对账输出。direct live 的 HTTPS / HTTP base URL、Host、redirect Host、probe token、额外 path、SpacetimeDB 数据库名、access log 路径、timeout 和布尔 env 都不能包含换行或 NUL;脚本必须在发起 HTTPS / HTTP / WSS 请求前失败,避免污染参数进入请求头、URL、日志对账或 JSON 证据。Pingora 切换窗口调整巡检、live smoke、日志对账阈值或直连布尔开关时,把参数解析失败视为配置错误,而不是继续执行检查。 - 决策补充:即使不打包 Pingora 二进制,API release 也必须随包携带 Pingora release readiness 聚合门禁、直连启用 / 回退 / preflight / live smoke / current release 自审脚本、直连彩排状态脚本,以及 `deploy/systemd/`、`deploy/pingora/` 支撑配置;`pingora-direct-enable.sh` 和正式 cutover runbook 默认从 `/opt/genarrative/current` 推导这些路径,启用前 release readiness 基础门禁和启用后 `--require-direct` 复核也必须调用 `/opt/genarrative/current/scripts/check-pingora-release-readiness.mjs`,切换窗口不得依赖 Jenkins 工作区或目标机源码 checkout。API release 还必须携带 `build//scripts/deploy/production-api-deploy.sh` 和同目录 `maintenance-on.sh` / `maintenance-off.sh`;`Genarrative-Api-Deploy` 只能复制并执行 build 产物内的 deploy 脚本,禁止继续执行部署工作区根部脚本,避免 workspace 中的旧脚本掩盖发布包布局缺陷。`production-api-deploy.sh` 对数据库备份脚本、健康巡检脚本和 Pingora 直连依赖都执行 fail-fast,发布产物缺失时保持维护模式并停止部署,不再从部署工作区兜底复制;API deploy 必须要求 `--release-root`、`--current-link`、`--api-env-file` 使用绝对路径,且 `--version` 必须以数字或字母开头并拒绝点目录,再先写 `${RELEASE_ROOT}/.${VERSION}.staging.$`,全部复制完成后再用非合并语义提升为 `${RELEASE_ROOT}/${VERSION}`,并用固定替换语义切换 current 符号链接,同版本 release 已存在、提升前竞态出现或 current 路径不是符号链接时拒绝覆盖 / 合并,失败时清理 staging 且不留下正式 release;`npm run check:production-api-release` 与 `npm run check:production-api-deploy` 必须进入 `check:pingora-release-readiness` 聚合门禁,前者用临时 `CARGO_TARGET_DIR` 和假 `api-server` release binary 验证 `build-production-release.sh --component api-server --skip-api-build` 产物自包含,后者用临时 release 和 fake `systemctl` / `curl` 验证从发布产物内执行 deploy 脚本后 current release 自包含,并覆盖缺少备份脚本、健康巡检脚本、release readiness 聚合门禁脚本、current release 自审脚本、直连彩排状态脚本、direct live smoke 脚本、相对 release root / current link / api env file、点目录或点开头 version、既有 release 目录、目录型 current 或提升前 release 目录竞态时的失败维护模式。 - 决策补充:正式直连 runbook 在采集状态快照前必须先执行 current release 自审:`/opt/genarrative/current/scripts/ops/pingora-current-release-audit.mjs --release-root /opt/genarrative/current --require-pingora-gateway --systemd-show`。该脚本只读检查发布包自包含、`pingora-gateway` 可执行,以及 systemd `ExecStart` 是否指向 current release 网关二进制;失败时应先修发布包、Jenkins 归档过滤、deploy 复制或 systemd 指向,再继续切换。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 01b2673b0..df4a1823d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -18,7 +18,7 @@ ## Pingora 直连 80/443 不能只改 env - 现象:`/etc/genarrative/pingora-gateway.env` 已把 `GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN` / `HTTP_REDIRECT_LISTEN` 改到 `0.0.0.0:443` / `0.0.0.0:80`,但 `genarrative-pingora-gateway.service` 启动失败,日志出现低端口绑定权限错误。 -- 原因:默认 service 用非 root `genarrative` 用户运行,并且主模板为了保持 shadow 安全边界不带 `CAP_NET_BIND_SERVICE`。低端口直连必须通过显式 systemd drop-in 单独授予 capability;同时 Certbot 私钥默认未必允许 `genarrative` 读取,Nginx 也可能仍占用 `80/443`。另一个常见误区是 API release 只带 `pingora-direct-enable.sh` / rollback 壳脚本,却漏带 `pingora-current-release-audit.mjs`、`pingora-direct-rehearsal-status.mjs`、`check-pingora-direct-preflight.mjs`、`check-pingora-direct-live.mjs`、`deploy/systemd/`、`deploy/env/` 或 `deploy/pingora/`,导致从 `/opt/genarrative/current` 启用时依赖 Jenkins 工作区、源码 checkout 或 `/etc` 里某份参考模板;或者 release 已经包含新版 `pingora-gateway`,但已运行的 shadow / canary / direct service 没有随 `current` 链接切换重启,仍在跑旧二进制。Server-Provision 安装到 `/etc/genarrative/pingora/genarrative-pingora-gateway-direct-entry.conf` 的 drop-in 只用于人工审阅和显式覆盖;直连启用脚本默认必须读取 current release 随包 `deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`,否则旧 `/etc` 模板会掩盖发布包缺失。API deploy 脚本本身也不能继续用部署工作区根部的 `scripts/deploy/production-api-deploy.sh`,否则 Jenkins workspace 里的脚本会掩盖 `build/` 发布包缺少 deploy / maintenance 同目录脚本的问题;备份脚本、健康巡检脚本和 env 示例目录同样不能从部署工作区兜底,切换命令证据脚本也不能从部署工作区兜底,否则 current release 会和上游构建归档漂移。Pingora 直连依赖、备份脚本、巡检脚本、env 示例目录和 API deploy 执行入口都必须来自上游发布产物;随包 `api-server.sha256` 和可选 `pingora-gateway.sha256` 也必须复制进 current release,供随包 current release 自审校验二进制;随包 `deploy/pingora/pingora-gateway.env.example` 也不能只检查存在,还要保持 gzip-only、不信任 XFF、前置代理确认关闭、接流保护开启和空 probe token 这些生产安全默认值;`production-api-deploy.sh` 发现缺失时应 fail-fast 并保持维护模式,不应从部署工作区兜底补齐;所有 API 发布包都必须携带 `release-manifest.json` 且登记 `api-server` artifact,发布包包含 Pingora 时还必须登记 `pingora-gateway` artifact,否则 deploy 应在切换 current 前失败;deploy 必须要求 release root、current link 和 api env file 都是绝对路径,release version 以数字或字母开头并拒绝点目录,再先写 staging release,全部复制完成后用非合并语义提升为正式 release,失败时清理 staging 且不留下正式 release,同版本 release 已存在、提升前竞态出现或 current 路径不是符号链接时拒绝覆盖 / 合并,避免旧文件混入 current;发布包包含 Pingora 且服务已 active 时,deploy 必须在 current 链接切换后 `try-restart` 并复核 active,服务未运行时只跳过,不主动拉起。 +- 原因:默认 service 用非 root `genarrative` 用户运行,并且主模板为了保持 shadow 安全边界不带 `CAP_NET_BIND_SERVICE`。低端口直连必须通过显式 systemd drop-in 单独授予 capability;同时 Certbot 私钥默认未必允许 `genarrative` 读取,Nginx 也可能仍占用 `80/443`。另一个常见误区是 API release 只带 `pingora-direct-enable.sh` / rollback 壳脚本,却漏带 `pingora-current-release-audit.mjs`、`pingora-direct-rehearsal-status.mjs`、`check-pingora-direct-preflight.mjs`、`check-pingora-direct-live.mjs`、`deploy/systemd/`、`deploy/env/` 或 `deploy/pingora/`,导致从 `/opt/genarrative/current` 启用时依赖 Jenkins 工作区、源码 checkout 或 `/etc` 里某份参考模板;或者 release 已经包含新版 `pingora-gateway`,但已运行的 shadow / canary / direct service 没有随 `current` 链接切换重启,仍在跑旧二进制。Server-Provision 安装到 `/etc/genarrative/pingora/genarrative-pingora-gateway-direct-entry.conf` 的 drop-in 只用于人工审阅和显式覆盖;直连启用脚本默认必须读取 current release 随包 `deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`,否则旧 `/etc` 模板会掩盖发布包缺失。API deploy 脚本本身也不能继续用部署工作区根部的 `scripts/deploy/production-api-deploy.sh`,否则 Jenkins workspace 里的脚本会掩盖 `build/` 发布包缺少 deploy / maintenance 同目录脚本的问题;备份脚本、健康巡检脚本和 env 示例目录同样不能从部署工作区兜底,切换命令证据脚本也不能从部署工作区兜底,否则 current release 会和上游构建归档漂移。Pingora 直连依赖、备份脚本、巡检脚本、env 示例目录和 API deploy 执行入口都必须来自上游发布产物;随包 `api-server.sha256` 和可选 `pingora-gateway.sha256` 也必须复制进 current release,供随包 current release 自审校验二进制;随包 `deploy/pingora/pingora-gateway.env.example` 也不能只检查存在,还要保持 gzip-only、不信任 XFF、前置代理确认关闭、接流保护开启和空 probe token 这些生产安全默认值;`production-api-deploy.sh` 发现缺失时应 fail-fast 并保持维护模式,不应从部署工作区兜底补齐;所有 API 发布包都必须携带 `release-manifest.json` 且登记 `api-server` artifact,发布包包含 Pingora 时还必须登记 `pingora-gateway` artifact,否则 deploy 应在切换 current 前失败;deploy 必须要求 release root、current link 和 api env file 都是绝对路径,release version 以数字或字母开头并拒绝点目录,再先写 staging release,全部复制完成后用非合并语义提升为正式 release,失败时清理 staging 且不留下正式 release,同版本 release 已存在、提升前竞态出现或 current 路径不是符号链接时拒绝覆盖 / 合并,避免旧文件混入 current;发布包包含 Pingora 时,deploy 必须先确认 systemd 最终配置没有 direct-entry `CAP_NET_BIND_SERVICE`、env 仍是 `127.0.0.1:18081` shadow 且未配置 `TLS_LISTEN` / `HTTP_REDIRECT_LISTEN`,再提升 release、切换 current 并 `restart` Pingora shadow;配置不安全时必须在切换 current 前失败并保持维护模式。 - 处理:确认真实 TLS 证书和 redirect env 已写入 `/etc/genarrative/pingora-gateway.env`、service 模板和 `systemctl cat` 最终配置读取的 `EnvironmentFile=` 都包含这份 env、当前执行用户和 `genarrative-pingora-gateway.service` 的 `User=` 服务用户都能读取证书链 / 私钥、current release 的 `pingora-gateway` 已存在且可执行、Nginx 或其它进程已释放 `80/443` 后,先用 `npm run plan:pingora-direct-cutover -- --require-direct ...` 生成只读 JSON runbook,并逐条审阅 Host 与回退巡检入口确认、current release 自包含自审、current release preflight、启用前基础 readiness、direct enable dry-run、direct enable apply、启用后 `--require-direct` 复核、rollback dry-run、rollback apply、回退后 health patrol 切回 Nginx 并恢复切换前 public base URL / Host、回退后 health patrol env 复核;runbook 只用于审阅,不修改系统。正式 runbook 中 `--direct-redirect-host`、`--rollback-nginx-smoke-host` 和 `--direct-host` 必须使用同一 hostname,只允许端口不同,避免 redirect 或回退 smoke 各自验证到不同入口;同时必须提供 `--rollback-health-patrol-public-base-url <切换前Nginx巡检入口>`,若切换前 Nginx 巡检需要 Host 覆盖,再追加 `--rollback-health-patrol-public-host <切换前Host>`,确认步骤会展示回退后要恢复的 public base URL / Host,避免回退 runbook 把现场巡检入口覆盖成仓库默认值;如需把回退后 Pingora shadow 探针复核纳入 runbook,追加 `--rollback-pingora-shadow-probe-url` / `--rollback-pingora-shadow-probe-token`,JSON 输出会隐藏 token 原文。随后先执行 `/opt/genarrative/current/scripts/ops/pingora-current-release-audit.mjs --release-root /opt/genarrative/current --require-pingora-gateway --systemd-show`,再 dry-run `/opt/genarrative/current/scripts/deploy/pingora-direct-enable.sh --no-status`,最后执行 `/opt/genarrative/current/scripts/deploy/pingora-direct-enable.sh --apply --preflight-env-file /etc/genarrative/pingora-gateway.env --preflight-check-cert-readable --preflight-check-service-env-file --preflight-check-service-user-cert-readable --preflight-check-service-binary-executable --preflight-check-ports-free --direct-https-base-url https://127.0.0.1 --direct-http-base-url http://127.0.0.1 --direct-host <域名> --direct-redirect-host <域名或host:port> --direct-spacetime-database <库名> --direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log`,由脚本先跑 direct preflight,再安装 drop-in、reload systemd、重启 Pingora,并用 `systemctl cat` 核验 capability 和 `EnvironmentFile=/etc/genarrative/pingora-gateway.env` 已生效、用 `systemctl show ... ExecStart` 核验最终 service 仍指向随包主 service 模板里的 current release `pingora-gateway`、用 `systemctl is-active` 确认服务 active,再以 JSON 模式执行 direct live smoke,验证 HTTPS / HTTP redirect / ACME / WSS 101 和 Pingora access log request_id 落盘,并要求 `direct-access-log` 结构化结果 `matchedCount == checked`、`missingCount=0`、`mismatchCount=0`;如果 direct live 退出 0 但缺少该结构化证据,也必须视为启用失败。直连启用后同步调整 `/etc/genarrative/health-patrol.env`:设置 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=pingora-direct`,本机打 `127.0.0.1` 时设置 `GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST=<域名>`,否则巡检会继续按 Nginx 模式误报。验证失败时执行 `/opt/genarrative/current/scripts/deploy/pingora-direct-rollback.sh --apply --reload-nginx --nginx-smoke-url http://127.0.0.1/healthz --nginx-smoke-host <域名>` 或 `npm run deploy:pingora-direct-rollback -- --apply --reload-nginx --nginx-smoke-url http://127.0.0.1/healthz --nginx-smoke-host <域名>`;回退脚本先跑 `nginx -t`,通过后才移除 drop-in、reload systemd、重启 Pingora,并用 `systemctl cat` 核验 capability 已移除、用 `systemctl show ... ExecStart` 核验最终 service 仍指向随包主 service 模板里的 current release `pingora-gateway`,随后 reload Nginx、确认 Nginx service 仍为 active,并用 curl smoke URL 证明公网入口已回到 Nginx;回退脚本 `--apply` 不允许省略 `--reload-nginx` 或 `--nginx-smoke-url`,当 smoke URL 指向本机地址时必须同时提供 `--nginx-smoke-host <域名>`,且 host 值不能包含 URL、路径或查询;回退后把 health patrol gateway mode 改回 `nginx`,恢复切换前 public base URL / Host,并用 `node -- /opt/genarrative/current/scripts/check-production-health-patrol-env.mjs --env-file /etc/genarrative/health-patrol.env --expected-gateway-mode nginx --expected-public-base-url <切换前Nginx巡检入口> --require-empty-public-host` 复核;若切换前 Nginx 巡检需要 Host 覆盖,则把 `--require-empty-public-host` 换成 `--expected-public-host <切换前Host>`。若 env 已在回退命令前切回 Nginx,也可给 rollback 脚本追加 `--health-patrol-env-file /etc/genarrative/health-patrol.env --health-patrol-expected-public-base-url <切换前Nginx巡检入口> --health-patrol-require-empty-public-host` 让它在 Nginx smoke 后自动复核;切换前 Nginx 巡检需要 Host 覆盖时把最后一项换成 `--health-patrol-expected-public-host <切换前Host>`。若要同时证明 Pingora shadow 高端口仍活着,追加 `--pingora-shadow-probe-url http://127.0.0.1:18081/__genarrative_pingora/healthz --pingora-shadow-probe-token `,脚本会隐藏 token 并要求响应为 `gateway=pingora-shadow`。 - 处理补充:不要直接 chmod `/etc/letsencrypt/live` 或 `archive` 来让 Pingora 读取证书;Certbot live 路径通常是 symlink,即使 `stat -L` 看起来是普通文件,父目录权限也会让非 root `genarrative` 用户不可达。先用随包 `node -- /opt/genarrative/current/scripts/deploy/pingora-tls-cert-sync.mjs --apply --source-cert-file /etc/letsencrypt/live/<域名>/fullchain.pem --source-key-file /etc/letsencrypt/live/<域名>/privkey.pem --target-dir /etc/genarrative/pingora-tls/<域名>` 把证书同步到 Pingora 私有目录,再让 `GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE` / `TLS_KEY_FILE` 指向 `/etc/genarrative/pingora-tls/<域名>/fullchain.pem` 和 `privkey.pem`。脚本默认 dry-run,`--apply` 才写入,目标目录默认 `root:genarrative 0750`,文件默认 `root:genarrative 0640`,并拒绝符号链接目标目录或目标文件。 - 处理补充:不要在切换窗口手工编辑 `/etc/genarrative/health-patrol.env` 的三项网关变量;使用 `node -- /opt/genarrative/current/scripts/deploy/pingora-health-patrol-env-switch.mjs --apply --env-file /etc/genarrative/health-patrol.env --gateway-mode pingora-direct --public-base-url <直连HTTPS入口> --public-host <域名>` 切到直连,回退前用同一脚本传 `--gateway-mode nginx --public-base-url <切换前Nginx巡检入口>` 并按切换前记录选择 `--clear-public-host` 或 `--public-host <切换前Host>`。脚本只改 gateway mode / public base URL / public Host,并立即复用随包 env 复核脚本,减少空 Host 和旧值残留;生产巡检、env 复核和 env 切换脚本读取的布尔 env 都必须是明确布尔值,非法值直接失败,不能把拼写错误当成 false;env 复核脚本的 `--env-file` 与 env 切换脚本的 `--env-file` / `--check-script` 必须是绝对路径且不能是文件系统根目录,也不能包含换行或 NUL;env 切换脚本写入的 public base URL / Host 同样不能包含换行或 NUL。Node 22 已内置 `--env-file` 启动参数,直接用 `node script.mjs --env-file ...` 或 shebang 执行 `.mjs --env-file ...` 都可能让 Node 抢走业务参数;所有这类命令都必须写成 `node -- script.mjs --env-file ...`,或通过已内置 `node --` 的 npm script 执行。 @@ -58,7 +58,7 @@ - 踩坑补充:证据根目录总审计选择“每类最新证据”后,还必须证明这些证据来自同一次切换时间线。最新证据选择和标准五段时间线证明只接受 `schemaVersion=1` 且带合法、规范 UTC 毫秒格式 `manifest.generatedAt` 的 manifest,命令记录 `startedAt` / `finishedAt` 也必须是 `new Date().toISOString()` 形式;缺失、非法、省略毫秒、本地时区或其它宽松可解析格式都会直接失败,不能用目录 mtime 兜底;证据目录被复制、归档或恢复后,也必须以 manifest 时间为准。同一阶段或同一命令如果出现多个候选共享最新 `manifest.generatedAt`,总审计会以 `AMBIGUOUS_LATEST` 失败并列出重复目录,不能按目录名排序打平;应重新归档该阶段 / 命令证据,或把旧证据移出正式证据根目录后再审计。标准五段证据都被要求时,每段审计状态都必须是 `OK`,`manifest.generatedAt` 必须满足 `pre-cutover -> enable-apply -> post-enable -> rollback-apply -> post-rollback`,且默认五段跨度不能超过 24 小时;非 OK、倒序或跨度过大都代表可能混入不同切换窗口遗留证据或现场状态未达标,必须失败后重新归档或清理证据根目录。任何证据 manifest 只要显式写入 `cutoverRunId` 字段,就必须是安全非空 ID,不能用空字符串伪装成缺省字段。确需跨更长维护窗口时,只能在生成 runbook 时显式传 `--cutover-evidence-timeline-max-span-ms `,让最终总审计 JSON 记录本次放宽后的 `timeline.maxSpanMs` 与实际 `timeline.spanMs`。 - 踩坑补充:标准五段时间线失败时不要只看顶层 `ok=false` 或 `diagnostics` 文本。`timeline.failedCount` 会按具体失败项累计,`timeline.failureBreakdown` 会把非 OK 证据、缺少时间、cutoverRunId 混入、时间倒序和跨度超限拆开计数;同一次审计可能同时暴露多个证据问题,应逐项修复后重新归档。 - 踩坑补充:同一天多次演练或切换时,只靠“最新证据”和 24 小时窗口仍可能把两轮证据拼在一起。正式 runbook 会生成或接受 `--cutover-run-id `,并把同一 `manifest.cutoverRunId` 写入三阶段证据包、enable / rollback apply 命令证据和最终总审计;最终审计必须带 `--require-cutover-run-id <本次cutoverRunId>`,缺少该字段或 ID 不一致时必须失败。即使人工临时总审计忘记带 `--require-cutover-run-id`,标准五段时间线里只要任一证据声明了 `manifest.cutoverRunId`,五段也必须全部声明同一个值,否则总审计失败。 -- 验证:先运行 `npm run check:pingora-direct-preflight -- --env-file /etc/genarrative/pingora-gateway.env --require-live-env --systemd-cat --check-cert-readable --check-service-env-file --check-service-user-cert-readable --check-service-binary-executable --check-ports-free`,确认 env、drop-in、service EnvironmentFile 一致性、当前用户证书权限、服务用户证书权限、service 二进制可执行性和 80/443 已释放;`systemctl cat genarrative-pingora-gateway.service` 必须显示 `AmbientCapabilities=CAP_NET_BIND_SERVICE`、`CapabilityBoundingSet=CAP_NET_BIND_SERVICE` 和 `EnvironmentFile=/etc/genarrative/pingora-gateway.env`;启用脚本 apply 必须先通过 current release 自审,失败时不安装 direct-entry drop-in;还必须带 direct HTTPS / HTTP / Host / redirect host / SpacetimeDB database / Pingora access log 参数,并在重启后直接完成 direct live smoke 和 direct-access-log JSON 证据校验;也可用 release readiness `--require-direct --direct-https-base-url https://127.0.0.1 --direct-http-base-url http://127.0.0.1 --direct-host <域名> --direct-redirect-host <域名或host:port> --direct-spacetime-database <库名> --direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log --direct-health-patrol-env-file /etc/genarrative/health-patrol.env --direct-preflight-env-file /etc/genarrative/pingora-gateway.env --direct-preflight-systemd --direct-preflight-check-cert-readable --direct-preflight-check-service-env-file --direct-preflight-check-service-user-cert-readable --direct-preflight-check-service-binary-executable --direct-preflight-check-ports-free` 把 HTTPS、HTTP redirect / ACME、正式域名 Host/SNI、redirect Location host、Pingora access log request_id 落盘、env 预检、systemd drop-in、service EnvironmentFile 一致性、当前用户和服务用户证书可读、service 二进制可执行、端口释放、显式目标库和 WSS 101 一起纳入硬门禁,并拒绝 `--direct-skip-wss`,避免 TLS 证书只按 `127.0.0.1` 误测、HTTP redirect Location 指错域名、Nginx 仍占用 80/443、service 实际读取另一份 env、root / deploy 用户可读但 systemd 服务用户不可读、current release 缺少可执行 `pingora-gateway`,或 WSS subscribe 隐式打到默认 SpacetimeDB 库。`check-pingora-release-readiness.mjs --help` 的正式直连和只生成 runbook 示例也必须带 `--direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log`,不要让值班人员复制示例后才被 `--require-direct` 拦截。current release 自审、状态快照和证据包的布尔 env 必须是明确布尔值,非法值会失败,不得把拼错的 run / require / fail 开关当成 false。`npm run plan:pingora-direct-cutover -- --require-direct ...` 输出必须包含 Host 与回退巡检入口确认、current release preflight、启用前不带 `--require-direct` 的基础 readiness、direct enable dry-run/apply、启用后带 `--require-direct` 的复核、rollback dry-run/apply、回退后 health patrol 切回 Nginx 并恢复切换前 public base URL / Host、回退后 health patrol env 复核;缺少 `--require-direct`、缺少 `--rollback-health-patrol-public-base-url`、缺少 `--direct-pingora-access-log`、redirect Host 漂移或 rollback smoke Host 漂移时必须失败,避免生成缺少正式直连硬门禁或验证不同入口的切换计划。Host 与回退巡检入口确认步骤必须展示回退后要恢复的 health patrol public base URL / Host。直连后 `genarrative-health-patrol.service` 应使用 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=pingora-direct`,状态 JSON 中 `gatewayMode` 应为 `pingora-direct`,并检查 `genarrative-pingora-gateway.service` 而不是 `nginx.service`;public probe 走 `127.0.0.1` 时应带 `GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST=<域名>`。回退后 `nginx -t` 必须先通过,`systemctl cat genarrative-pingora-gateway.service` 不应再显示这两条 capability,`systemctl show genarrative-pingora-gateway.service --property=ExecStart --value --no-pager` 必须仍指向 current release 的 `pingora-gateway`,`systemctl is-active nginx.service` 应为 `active`,`curl --fail --max-time 5` 访问 `--nginx-smoke-url` 应成功;若 smoke URL 为本机地址必须带 `--nginx-smoke-host <域名>`,证明正式 vhost 已回到 Nginx;随后用 `node -- /opt/genarrative/current/scripts/check-production-health-patrol-env.mjs ...` 复核 health patrol env,必须显示 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=nginx` 且 public base URL / Host 与切换前记录一致,shadow probe 可选复核必须返回 `gateway=pingora-shadow`。本机提交前还要运行 `npm run check:pingora-direct-enable`、`npm run check:pingora-direct-rollback`、`npm run check:production-health-patrol`、`npm run check:production-api-release`、`npm run check:pingora-production-release-build` 和 `npm run check:production-api-deploy`,确保脚本默认 dry-run 不会安装或删除 drop-in、current release 自审失败时启用脚本不会安装 drop-in、direct live 退出 0 但缺少 `direct-access-log` 结构化证据时启用失败,API release 布局自包含,真实 Pingora release 二进制能构建并进入发布包,API deploy 从发布产物内执行后 current release 自包含;缺少数据库备份脚本、健康巡检脚本、健康巡检 env 复核脚本、切换命令证据脚本、env 示例目录或 direct live smoke 脚本的发布包都必须部署失败并保持维护模式。正式直连 readiness 必须带 `--direct-health-patrol-env-file /etc/genarrative/health-patrol.env`,并用 `scripts/check-production-health-patrol-env.mjs` 阻断 health patrol 仍停在 Nginx 模式或本机 direct probe 缺少正式 Host;发布包包含 `pingora-gateway` 时,`npm run check:production-api-deploy` 必须覆盖服务 active 时执行 `systemctl try-restart genarrative-pingora-gateway.service` 并复核 active,以及服务 inactive 时跳过且不主动拉起。 +- 验证:先运行 `npm run check:pingora-direct-preflight -- --env-file /etc/genarrative/pingora-gateway.env --require-live-env --systemd-cat --check-cert-readable --check-service-env-file --check-service-user-cert-readable --check-service-binary-executable --check-ports-free`,确认 env、drop-in、service EnvironmentFile 一致性、当前用户证书权限、服务用户证书权限、service 二进制可执行性和 80/443 已释放;`systemctl cat genarrative-pingora-gateway.service` 必须显示 `AmbientCapabilities=CAP_NET_BIND_SERVICE`、`CapabilityBoundingSet=CAP_NET_BIND_SERVICE` 和 `EnvironmentFile=/etc/genarrative/pingora-gateway.env`;启用脚本 apply 必须先通过 current release 自审,失败时不安装 direct-entry drop-in;还必须带 direct HTTPS / HTTP / Host / redirect host / SpacetimeDB database / Pingora access log 参数,并在重启后直接完成 direct live smoke 和 direct-access-log JSON 证据校验;也可用 release readiness `--require-direct --direct-https-base-url https://127.0.0.1 --direct-http-base-url http://127.0.0.1 --direct-host <域名> --direct-redirect-host <域名或host:port> --direct-spacetime-database <库名> --direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log --direct-health-patrol-env-file /etc/genarrative/health-patrol.env --direct-preflight-env-file /etc/genarrative/pingora-gateway.env --direct-preflight-systemd --direct-preflight-check-cert-readable --direct-preflight-check-service-env-file --direct-preflight-check-service-user-cert-readable --direct-preflight-check-service-binary-executable --direct-preflight-check-ports-free` 把 HTTPS、HTTP redirect / ACME、正式域名 Host/SNI、redirect Location host、Pingora access log request_id 落盘、env 预检、systemd drop-in、service EnvironmentFile 一致性、当前用户和服务用户证书可读、service 二进制可执行、端口释放、显式目标库和 WSS 101 一起纳入硬门禁,并拒绝 `--direct-skip-wss`,避免 TLS 证书只按 `127.0.0.1` 误测、HTTP redirect Location 指错域名、Nginx 仍占用 80/443、service 实际读取另一份 env、root / deploy 用户可读但 systemd 服务用户不可读、current release 缺少可执行 `pingora-gateway`,或 WSS subscribe 隐式打到默认 SpacetimeDB 库。`check-pingora-release-readiness.mjs --help` 的正式直连和只生成 runbook 示例也必须带 `--direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log`,不要让值班人员复制示例后才被 `--require-direct` 拦截。current release 自审、状态快照和证据包的布尔 env 必须是明确布尔值,非法值会失败,不得把拼错的 run / require / fail 开关当成 false。`npm run plan:pingora-direct-cutover -- --require-direct ...` 输出必须包含 Host 与回退巡检入口确认、current release preflight、启用前不带 `--require-direct` 的基础 readiness、direct enable dry-run/apply、启用后带 `--require-direct` 的复核、rollback dry-run/apply、回退后 health patrol 切回 Nginx 并恢复切换前 public base URL / Host、回退后 health patrol env 复核;缺少 `--require-direct`、缺少 `--rollback-health-patrol-public-base-url`、缺少 `--direct-pingora-access-log`、redirect Host 漂移或 rollback smoke Host 漂移时必须失败,避免生成缺少正式直连硬门禁或验证不同入口的切换计划。Host 与回退巡检入口确认步骤必须展示回退后要恢复的 health patrol public base URL / Host。直连后 `genarrative-health-patrol.service` 应使用 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=pingora-direct`,状态 JSON 中 `gatewayMode` 应为 `pingora-direct`,并检查 `genarrative-pingora-gateway.service` 而不是 `nginx.service`;public probe 走 `127.0.0.1` 时应带 `GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST=<域名>`。回退后 `nginx -t` 必须先通过,`systemctl cat genarrative-pingora-gateway.service` 不应再显示这两条 capability,`systemctl show genarrative-pingora-gateway.service --property=ExecStart --value --no-pager` 必须仍指向 current release 的 `pingora-gateway`,`systemctl is-active nginx.service` 应为 `active`,`curl --fail --max-time 5` 访问 `--nginx-smoke-url` 应成功;若 smoke URL 为本机地址必须带 `--nginx-smoke-host <域名>`,证明正式 vhost 已回到 Nginx;随后用 `node -- /opt/genarrative/current/scripts/check-production-health-patrol-env.mjs ...` 复核 health patrol env,必须显示 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=nginx` 且 public base URL / Host 与切换前记录一致,shadow probe 可选复核必须返回 `gateway=pingora-shadow`。本机提交前还要运行 `npm run check:pingora-direct-enable`、`npm run check:pingora-direct-rollback`、`npm run check:production-health-patrol`、`npm run check:production-api-release`、`npm run check:pingora-production-release-build` 和 `npm run check:production-api-deploy`,确保脚本默认 dry-run 不会安装或删除 drop-in、current release 自审失败时启用脚本不会安装 drop-in、direct live 退出 0 但缺少 `direct-access-log` 结构化证据时启用失败,API release 布局自包含,真实 Pingora release 二进制能构建并进入发布包,API deploy 从发布产物内执行后 current release 自包含;缺少数据库备份脚本、健康巡检脚本、健康巡检 env 复核脚本、切换命令证据脚本、env 示例目录或 direct live smoke 脚本的发布包都必须部署失败并保持维护模式。正式直连 readiness 必须带 `--direct-health-patrol-env-file /etc/genarrative/health-patrol.env`,并用 `scripts/check-production-health-patrol-env.mjs` 阻断 health patrol 仍停在 Nginx 模式或本机 direct probe 缺少正式 Host;发布包包含 `pingora-gateway` 时,`npm run check:production-api-deploy` 必须覆盖服务 active / inactive 都会在 shadow 配置安全时执行 `systemctl restart genarrative-pingora-gateway.service` 并复核 active,同时覆盖 direct-entry capability 或公网监听 env 下不会提升 release、不会切 current、不会自动 restart。 - 顺序补充:正式 runbook 必须先通过 health patrol env 切换脚本预置回 Nginx 和切换前 public base URL / Host,再执行 `rollback apply`;回退脚本内置 env 复核和独立 env 复核都会阻断 public base URL / Host 漂移。 - 关联:`deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`、`deploy/env/health-patrol.env.example`、`deploy/env/pingora-direct-live.env.example`、`deploy/env/pingora-canary-live.env.example`、`scripts/deploy/pingora-direct-enable.sh`、`scripts/deploy/pingora-direct-rollback.sh`、`scripts/deploy/pingora-tls-cert-sync.mjs`、`scripts/check-pingora-direct-preflight.mjs`、`scripts/check-pingora-direct-live.mjs`、`scripts/ops/pingora-cutover-command-evidence.mjs`、`scripts/ops/pingora-cutover-evidence-verify.mjs`、`scripts/ops/pingora-cutover-evidence-audit.mjs`、`scripts/jenkins-server-provision.sh`、`scripts/build-production-release.sh`、`scripts/deploy/production-api-deploy.sh`、`docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`。 diff --git a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md index debc09d7f..8e8476b0a 100644 --- a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md +++ b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md @@ -240,7 +240,7 @@ Jenkins `Genarrative-Api-Build` 对应参数是 `INCLUDE_PINGORA_GATEWAY`,默 API release 还必须携带 `scripts/check-pingora-release-readiness.mjs`、`scripts/check-pingora-canary-live.mjs` 与 `scripts/ops/pingora-direct-rehearsal-status.mjs`。前者支撑 current release 的 `--release-runtime-only` 聚合复核,canary live 脚本支撑目标 Nginx canary live smoke,直连彩排状态脚本支撑目标机切换前只读确认 Nginx 仍接公网、Pingora shadow / realpath canary 高端口和 current release 自审均可用;缺少任一脚本时 `check:production-api-release`、`check:production-api-deploy` 和生产运维护栏都必须失败。 -发布包会额外包含 `pingora-gateway` 与 `pingora-gateway.sha256`。`production-api-deploy.sh` 看到这两个文件时会校验并把 `pingora-gateway` / `pingora-gateway.sha256` 一起复制到 `/opt/genarrative/current`,供 shadow systemd 模板和 current release 自审使用;`api-server.sha256` 也必须随 `api-server` 一起进入 current release。没有这两个 Pingora 文件时现有 API 发布行为不变。API 发布仍只重启 `genarrative-api.service`;如果 `genarrative-pingora-gateway.service` 已经处于 `active`,部署脚本会在 `current` 链接切换后执行 `systemctl try-restart genarrative-pingora-gateway.service` 并复核仍为 `active`,让已启用的 shadow / canary / direct 机器加载同一份 current release 网关二进制。若 Pingora 服务未运行,部署脚本只打印跳过信息,不主动拉起影子服务,也不改变默认接流边界。 +发布包会额外包含 `pingora-gateway` 与 `pingora-gateway.sha256`。`production-api-deploy.sh` 看到这两个文件时会校验并把 `pingora-gateway` / `pingora-gateway.sha256` 一起复制到 `/opt/genarrative/current`,供 shadow systemd 模板和 current release 自审使用;`api-server.sha256` 也必须随 `api-server` 一起进入 current release。没有这两个 Pingora 文件时现有 API 发布行为不变。API 发布仍只重启 `genarrative-api.service`;包含 Pingora 时,部署脚本会在提升 release 和切换 `current` 前读取 `systemctl cat genarrative-pingora-gateway.service` 与其 `EnvironmentFile`,拒绝已出现 `CAP_NET_BIND_SERVICE` direct-entry capability、拒绝 `GENARRATIVE_PINGORA_GATEWAY_LISTEN` 不是 `127.0.0.1:18081`、拒绝已配置 `TLS_LISTEN` 或 `HTTP_REDIRECT_LISTEN`,确认仍是本机 shadow 高端口后才切换 current,并执行 `systemctl restart genarrative-pingora-gateway.service` 后复核 active。该自动拉起只覆盖 shadow 服务,不会启用公网 `80/443` 直连入口;若现场已经处于 direct-entry 状态,应走正式直连 runbook 或先回退到 shadow 后再执行 API deploy。 也可以复制 `deploy/pingora/pingora-gateway.env.example` 到部署环境的非 Git 配置文件,由 systemd 或容器注入。仓库提供 `deploy/systemd/genarrative-pingora-gateway.service` 作为影子服务模板,默认读取 `/etc/genarrative/pingora-gateway.env`,仍只应监听本机高端口,再由 Nginx 或本机 smoke 主动访问。Server-Provision 会把主 service 安装到 `/etc/systemd/system/genarrative-pingora-gateway.service`,并把直连低端口 drop-in 模板安装到 `/etc/genarrative/pingora/genarrative-pingora-gateway-direct-entry.conf` 作为参考和手动覆盖来源;该模板不会默认生效,`/opt/genarrative/current/scripts/deploy/pingora-direct-enable.sh` 默认使用随 current release 发布的 `deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`。`deploy/nginx/snippets/genarrative-pingora-canary.conf` 是可选的 Nginx -> Pingora 前缀 canary 模板,Server-Provision 会安装到 `/etc/nginx/snippets/`,但主站配置默认不 include;启用前必须把 `__GENARRATIVE_PINGORA_PROBE_TOKEN__` 替换为真实 token,并确认 `allow/deny` 来源边界符合当次验证窗口。 @@ -399,7 +399,7 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清 本阶段验收只证明 API release 的正式打包、复制、current 切换、服务重启和 shadow Pingora 随 current 重启可用,不切 `80/443` 到 Pingora。验收结果: - `production-api-deploy.sh` 进入维护模式后完成 `api-server.sha256` 和 `pingora-gateway.sha256` 校验,复制二进制、checksum、manifest 和 Pingora 直连依赖到新 release,再把 `/opt/genarrative/current` 切到 `/opt/genarrative/releases/dev-pingora-api-20260617140915`,最后退出维护模式。 -- 部署时 `genarrative-pingora-gateway.service` 已为 `active`,脚本在 current 切换后执行 `systemctl try-restart genarrative-pingora-gateway.service` 并复核 active;`genarrative-api.service`、外部生成 worker 和 worker controller 也完成重启和 active 等待。 +- 部署时 `genarrative-pingora-gateway.service` 仍是 shadow 配置,脚本在 current 切换前拒绝 direct-entry capability 和公网监听 env,切换后执行 `systemctl restart genarrative-pingora-gateway.service` 并复核 active;`genarrative-api.service`、外部生成 worker 和 worker controller 也完成重启和 active 等待。 - `/opt/genarrative/current/release-manifest.api-server.json` 记录 `api-server` 和 `pingora-gateway` 两个 artifact,`cd /opt/genarrative/current && sha256sum -c api-server.sha256 && sha256sum -c pingora-gateway.sha256` 均为 `OK`。 - `/opt/genarrative/current/scripts/ops/pingora-current-release-audit.mjs --release-root /opt/genarrative/current --require-pingora-gateway --systemd-show` 返回 `summary.status=OK`,确认 current release 自包含、checksum 匹配、`pingora-gateway` 可执行,且 systemd `ExecStart` 指向 `/opt/genarrative/current/pingora-gateway`。 - `/opt/genarrative/current/scripts/check-pingora-release-readiness.mjs --release-runtime-only` 通过,证明目标机可以只依赖 current release 运行 runtime-only 发布自审,不需要源码 checkout 或 Jenkins 工作区。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 98cd0ff95..3e2750dcc 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -351,7 +351,7 @@ cat /var/lib/genarrative/health-patrol/status.json `Genarrative-Web-Build` 会把 `build//web.tar.gz`、`web.tar.gz.sha256`、`release-manifest.json` 和 `scripts/deploy/production-web-deploy.sh` 直接归档为 Jenkins 构建产物;`Genarrative-Web-Deploy` 只通过 `copyArtifacts` 从指定上游构建复制这些产物和部署脚本,不再在目标机器 checkout Git,再执行随构建归档的 `scripts/deploy/production-web-deploy.sh`。Web 发布不再读取构建机本地缓存目录,也不再通过 release agent `rsync` 回构建机拉取大包;如果 deploy 找不到 `web.tar.gz`,应先检查上游 Web Build 是否按同一 `BUILD_VERSION` 成功归档产物。 -`Genarrative-Api-Build` 的 Jenkins 归档产物必须包含 `build//api-server`、`api-server.sha256`、`release-manifest.json`、`build//scripts/deploy/production-api-deploy.sh`、`build//scripts/deploy/maintenance-on.sh`、`build//scripts/deploy/maintenance-off.sh`、`scripts/database-backup-to-oss.mjs`、`scripts/ops/production-health-patrol.mjs`、`scripts/ops/pingora-current-release-audit.mjs`、`scripts/ops/pingora-cutover-status-snapshot.mjs`、`scripts/ops/pingora-cutover-evidence-bundle.mjs`、`scripts/ops/pingora-cutover-command-evidence.mjs`、`scripts/ops/pingora-cutover-evidence-verify.mjs`、`scripts/ops/pingora-cutover-evidence-audit.mjs`、`scripts/check-pingora-direct-preflight.mjs`、`scripts/check-pingora-direct-live.mjs`、`scripts/check-pingora-canary-access-log-parity.mjs`、`scripts/check-production-health-patrol-env.mjs`、`scripts/deploy/pingora-direct-enable.sh`、`scripts/deploy/pingora-direct-rollback.sh`、`deploy/systemd/**`、`deploy/env/**` 和 `deploy/pingora/**`。`deploy/systemd/genarrative-database-backup.service` 从 `/opt/genarrative/current/scripts/database-backup-to-oss.mjs` 执行冷备份,`deploy/systemd/genarrative-health-patrol.service` 从 `/opt/genarrative/current/scripts/ops/production-health-patrol.mjs` 执行巡检;`Genarrative-Api-Deploy` 会从上游 API 构建产物复制并执行 `build//scripts/deploy/production-api-deploy.sh`,同目录的 `maintenance-on.sh` / `maintenance-off.sh` 也必须来自同一 build 产物;部署脚本会先写入 `${RELEASE_ROOT}/.${VERSION}.staging.$$`,把 `release-manifest.json` 校验后复制为 current release 的 `release-manifest.api-server.json`,并把备份脚本、巡检脚本、Pingora 直连启用 / 回退 / 预检 / live smoke / canary access log 对账 / health patrol env 复核 / current release 自审 / 状态快照 / 证据包 / 命令证据 / 证据验真 / 证据根目录审计脚本,以及 `deploy/systemd`、`deploy/env`、`deploy/pingora` 支撑配置全部复制完成后,才用非合并语义提升为 `${RELEASE_ROOT}/${VERSION}` 并用固定替换语义切换 `current` 符号链接,不再在目标机器 checkout Git,也不再执行部署工作区根部脚本。Pingora 直连启用脚本必须能从 `/opt/genarrative/current` 独立执行 preflight 和 direct live smoke,并默认读取 current release 随包 `deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`,不依赖 Jenkins 工作区、源码 checkout 或 `/etc` 参考模板;`plan:pingora-direct-cutover` 必须能用同一组参数生成 current release 切换 / 回退 runbook。`production-api-deploy.sh` 对 release manifest、备份脚本、巡检脚本、env 示例目录和 Pingora 直连依赖都执行 fail-fast,且 `--release-root`、`--current-link`、`--api-env-file` 必须是绝对路径,`--version` 必须以数字或字母开头并只能包含数字、字母、点、下划线和短横线,禁止 `.` / `..` 点目录;发布产物缺少 manifest、manifest 未登记 `api-server`、缺少脚本 / 配置目录、同版本 release 目录已存在、current 路径不是符号链接、提升前 release 目录竞态出现或 staging 构建中失败时会保留维护模式并停止部署;失败会清理 staging 目录且不会留下正式 release 目录,不再从部署机工作区兜底补文件,也不把旧同名 release 目录和新文件混合。如果 API 发布后 current release 中缺少这些脚本或目录,应先检查 `Genarrative-Api-Build` 的 `archiveArtifacts` 和 `Genarrative-Api-Deploy` 的 `copyArtifacts` 过滤器是否仍包含 `build//release-manifest.json`、`build//scripts/deploy/production-api-deploy.sh`、`build//scripts/deploy/maintenance-on.sh`、`build//scripts/deploy/maintenance-off.sh`、`build//scripts/database-backup-to-oss.mjs`、`build//scripts/ops/production-health-patrol.mjs`、`build//scripts/ops/pingora-current-release-audit.mjs`、`build//scripts/ops/pingora-cutover-status-snapshot.mjs`、`build//scripts/ops/pingora-cutover-evidence-bundle.mjs`、`build//scripts/ops/pingora-cutover-command-evidence.mjs`、`build//scripts/ops/pingora-cutover-evidence-verify.mjs`、`build//scripts/ops/pingora-cutover-evidence-audit.mjs`、`build//scripts/check-pingora-direct-preflight.mjs`、`build//scripts/check-pingora-direct-live.mjs`、`build//scripts/check-pingora-canary-access-log-parity.mjs`、`build//scripts/check-production-health-patrol-env.mjs`、`build//scripts/deploy/pingora-direct-enable.sh`、`build//scripts/deploy/pingora-direct-rollback.sh`、`build//deploy/systemd/**`、`build//deploy/env/**` 与 `build//deploy/pingora/**`,不要只在部署机工作区手工补文件。本机用 `npm run check:production-api-release` 通过临时 `CARGO_TARGET_DIR` 和假 `api-server` / `pingora-gateway` release binary 验证 `build-production-release.sh --component api-server --skip-api-build` 会把这些文件打进 API release,并验证显式 `--include-pingora-gateway --skip-pingora-gateway-build` 时发布包包含 `pingora-gateway`、`pingora-gateway.sha256` 和 manifest 登记;`npm run check:pingora-production-release-build` 则用假 `api-server` 和真实 `cargo build -p pingora-gateway --release --target x86_64-unknown-linux-gnu` 验证显式 include 路径能构出可执行网关二进制、checksum 和 manifest 登记;再用 `npm run check:production-api-deploy` 通过临时 release、fake `systemctl` / `curl` 验证从发布产物内执行 `production-api-deploy.sh` 会把这些文件复制到 current release,并验证缺少 release manifest、manifest 未登记 `api-server`、缺少数据库备份脚本、健康巡检脚本、健康巡检 env 复核脚本、current release 自审脚本、状态快照脚本、证据包脚本、证据验真脚本、证据根目录审计脚本、canary access log 对账脚本、env 示例目录或 direct live smoke 脚本时都会失败且保持维护模式,还会验证相对 release root / current link / api env file、点目录或点开头 version 被拒绝、失败时不留下 staging / 正式 release 目录、同版本 release 目录已存在、current 路径不是符号链接或提升前 release 目录竞态出现时拒绝覆盖 / 合并。Pingora 影子网关不是默认 API 归档物;只有显式用 `npm run build:production-release -- --component api-server --include-pingora-gateway` 或在 `Genarrative-Api-Build` 勾选 `INCLUDE_PINGORA_GATEWAY` 时,发布包才包含 `pingora-gateway` / `pingora-gateway.sha256`,API deploy 会在两者同时存在且 manifest 登记 `pingora-gateway` 时校验并复制到 current release;此时 build 脚本和 Jenkins 会先检查 `cmake`、C 编译器和 C++ 编译器,避免进入 Cargo 后才因 `libz-ng-sys` 构建依赖缺失失败。若 `genarrative-pingora-gateway.service` 已处于 `active`,deploy 会在 current 链接切换后 `try-restart` 并复核仍为 `active`,让已启用的 shadow / canary / direct 机器加载新网关二进制;若服务未运行则只跳过,不主动拉起 Pingora。 +`Genarrative-Api-Build` 的 Jenkins 归档产物必须包含 `build//api-server`、`api-server.sha256`、`release-manifest.json`、`build//scripts/deploy/production-api-deploy.sh`、`build//scripts/deploy/maintenance-on.sh`、`build//scripts/deploy/maintenance-off.sh`、`scripts/database-backup-to-oss.mjs`、`scripts/ops/production-health-patrol.mjs`、`scripts/ops/pingora-current-release-audit.mjs`、`scripts/ops/pingora-cutover-status-snapshot.mjs`、`scripts/ops/pingora-cutover-evidence-bundle.mjs`、`scripts/ops/pingora-cutover-command-evidence.mjs`、`scripts/ops/pingora-cutover-evidence-verify.mjs`、`scripts/ops/pingora-cutover-evidence-audit.mjs`、`scripts/check-pingora-direct-preflight.mjs`、`scripts/check-pingora-direct-live.mjs`、`scripts/check-pingora-canary-access-log-parity.mjs`、`scripts/check-production-health-patrol-env.mjs`、`scripts/deploy/pingora-direct-enable.sh`、`scripts/deploy/pingora-direct-rollback.sh`、`deploy/systemd/**`、`deploy/env/**` 和 `deploy/pingora/**`。`deploy/systemd/genarrative-database-backup.service` 从 `/opt/genarrative/current/scripts/database-backup-to-oss.mjs` 执行冷备份,`deploy/systemd/genarrative-health-patrol.service` 从 `/opt/genarrative/current/scripts/ops/production-health-patrol.mjs` 执行巡检;`Genarrative-Api-Deploy` 会从上游 API 构建产物复制并执行 `build//scripts/deploy/production-api-deploy.sh`,同目录的 `maintenance-on.sh` / `maintenance-off.sh` 也必须来自同一 build 产物;部署脚本会先写入 `${RELEASE_ROOT}/.${VERSION}.staging.$$`,把 `release-manifest.json` 校验后复制为 current release 的 `release-manifest.api-server.json`,并把备份脚本、巡检脚本、Pingora 直连启用 / 回退 / 预检 / live smoke / canary access log 对账 / health patrol env 复核 / current release 自审 / 状态快照 / 证据包 / 命令证据 / 证据验真 / 证据根目录审计脚本,以及 `deploy/systemd`、`deploy/env`、`deploy/pingora` 支撑配置全部复制完成后,才用非合并语义提升为 `${RELEASE_ROOT}/${VERSION}` 并用固定替换语义切换 `current` 符号链接,不再在目标机器 checkout Git,也不再执行部署工作区根部脚本。Pingora 直连启用脚本必须能从 `/opt/genarrative/current` 独立执行 preflight 和 direct live smoke,并默认读取 current release 随包 `deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`,不依赖 Jenkins 工作区、源码 checkout 或 `/etc` 参考模板;`plan:pingora-direct-cutover` 必须能用同一组参数生成 current release 切换 / 回退 runbook。`production-api-deploy.sh` 对 release manifest、备份脚本、巡检脚本、env 示例目录和 Pingora 直连依赖都执行 fail-fast,且 `--release-root`、`--current-link`、`--api-env-file` 必须是绝对路径,`--version` 必须以数字或字母开头并只能包含数字、字母、点、下划线和短横线,禁止 `.` / `..` 点目录;发布产物缺少 manifest、manifest 未登记 `api-server`、缺少脚本 / 配置目录、同版本 release 目录已存在、current 路径不是符号链接、提升前 release 目录竞态出现或 staging 构建中失败时会保留维护模式并停止部署;失败会清理 staging 目录且不会留下正式 release 目录,不再从部署机工作区兜底补文件,也不把旧同名 release 目录和新文件混合。如果 API 发布后 current release 中缺少这些脚本或目录,应先检查 `Genarrative-Api-Build` 的 `archiveArtifacts` 和 `Genarrative-Api-Deploy` 的 `copyArtifacts` 过滤器是否仍包含 `build//release-manifest.json`、`build//scripts/deploy/production-api-deploy.sh`、`build//scripts/deploy/maintenance-on.sh`、`build//scripts/deploy/maintenance-off.sh`、`build//scripts/database-backup-to-oss.mjs`、`build//scripts/ops/production-health-patrol.mjs`、`build//scripts/ops/pingora-current-release-audit.mjs`、`build//scripts/ops/pingora-cutover-status-snapshot.mjs`、`build//scripts/ops/pingora-cutover-evidence-bundle.mjs`、`build//scripts/ops/pingora-cutover-command-evidence.mjs`、`build//scripts/ops/pingora-cutover-evidence-verify.mjs`、`build//scripts/ops/pingora-cutover-evidence-audit.mjs`、`build//scripts/check-pingora-direct-preflight.mjs`、`build//scripts/check-pingora-direct-live.mjs`、`build//scripts/check-pingora-canary-access-log-parity.mjs`、`build//scripts/check-production-health-patrol-env.mjs`、`build//scripts/deploy/pingora-direct-enable.sh`、`build//scripts/deploy/pingora-direct-rollback.sh`、`build//deploy/systemd/**`、`build//deploy/env/**` 与 `build//deploy/pingora/**`,不要只在部署机工作区手工补文件。本机用 `npm run check:production-api-release` 通过临时 `CARGO_TARGET_DIR` 和假 `api-server` / `pingora-gateway` release binary 验证 `build-production-release.sh --component api-server --skip-api-build` 会把这些文件打进 API release,并验证显式 `--include-pingora-gateway --skip-pingora-gateway-build` 时发布包包含 `pingora-gateway`、`pingora-gateway.sha256` 和 manifest 登记;`npm run check:pingora-production-release-build` 则用假 `api-server` 和真实 `cargo build -p pingora-gateway --release --target x86_64-unknown-linux-gnu` 验证显式 include 路径能构出可执行网关二进制、checksum 和 manifest 登记;再用 `npm run check:production-api-deploy` 通过临时 release、fake `systemctl` / `curl` 验证从发布产物内执行 `production-api-deploy.sh` 会把这些文件复制到 current release,并验证缺少 release manifest、manifest 未登记 `api-server`、缺少数据库备份脚本、健康巡检脚本、健康巡检 env 复核脚本、current release 自审脚本、状态快照脚本、证据包脚本、证据验真脚本、证据根目录审计脚本、canary access log 对账脚本、env 示例目录或 direct live smoke 脚本时都会失败且保持维护模式,还会验证相对 release root / current link / api env file、点目录或点开头 version 被拒绝、失败时不留下 staging / 正式 release 目录、同版本 release 目录已存在、current 路径不是符号链接或提升前 release 目录竞态出现时拒绝覆盖 / 合并。Pingora 影子网关不是默认 API 归档物;只有显式用 `npm run build:production-release -- --component api-server --include-pingora-gateway` 或在 `Genarrative-Api-Build` 勾选 `INCLUDE_PINGORA_GATEWAY` 时,发布包才包含 `pingora-gateway` / `pingora-gateway.sha256`,API deploy 会在两者同时存在且 manifest 登记 `pingora-gateway` 时校验并复制到 current release;此时 build 脚本和 Jenkins 会先检查 `cmake`、C 编译器和 C++ 编译器,避免进入 Cargo 后才因 `libz-ng-sys` 构建依赖缺失失败。发布包包含 Pingora 时,deploy 会在提升 release 前读取 `systemctl cat genarrative-pingora-gateway.service` 和其 `EnvironmentFile`,拒绝 direct-entry `CAP_NET_BIND_SERVICE`、拒绝非 `127.0.0.1:18081` 的 shadow listen、拒绝 `TLS_LISTEN` / `HTTP_REDIRECT_LISTEN`,确认仍是本机 shadow 高端口后才切换 current;切换后执行 `systemctl restart genarrative-pingora-gateway.service` 并复核 active,让 shadow / canary 机器加载新网关二进制。该自动拉起不会启用公网 `80/443` 直连入口;已经进入 direct-entry 状态的机器应走正式直连 runbook 或先回退到 shadow。 Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit.mjs`、直连切换状态快照脚本 `scripts/ops/pingora-cutover-status-snapshot.mjs`、证据包脚本 `scripts/ops/pingora-cutover-evidence-bundle.mjs`、命令证据脚本 `scripts/ops/pingora-cutover-command-evidence.mjs`、证据验真脚本 `scripts/ops/pingora-cutover-evidence-verify.mjs`、证据根目录审计脚本 `scripts/ops/pingora-cutover-evidence-audit.mjs` 和 canary access log 对账脚本 `scripts/check-pingora-canary-access-log-parity.mjs` 都属于 API release 的强制随包依赖;缺少任一脚本时 `check:production-api-release`、`check:production-api-deploy` 和生产运维护栏都必须失败,避免切换窗口只能靠 Jenkins 工作区或源码 checkout 临时补自审、证据或日志对账脚本。 diff --git a/scripts/check-production-api-deploy.mjs b/scripts/check-production-api-deploy.mjs index 2a6df0648..378a442a2 100644 --- a/scripts/check-production-api-deploy.mjs +++ b/scripts/check-production-api-deploy.mjs @@ -37,7 +37,9 @@ console.log('[check:production-api-deploy] OK'); function main() { assertDeployCopiesPingoraDirectReleaseDependencies(); assertDeployRestartsActivePingoraWhenArtifactIncluded(); - assertDeploySkipsInactivePingoraWhenArtifactIncluded(); + assertDeployStartsInactivePingoraWhenArtifactIncluded(); + assertDeployRejectsPingoraDirectEntryWhenArtifactIncluded(); + assertDeployRejectsPingoraPublicListenWhenArtifactIncluded(); assertDeployRejectsPingoraArtifactMissingManifestEntry(); assertMissingReleaseManifestFails(); assertReleaseManifestMissingApiArtifactFails(); @@ -286,22 +288,22 @@ function assertDeployRestartsActivePingoraWhenArtifactIncluded() { const commandsLog = readFileSync(fixture.commandsLog, 'utf8'); assertIncludes( commandsLog, - 'systemctl is-active --quiet genarrative-pingora-gateway.service', - '部署脚本看到 Pingora 产物后必须先检查 shadow service 是否 active。', + 'systemctl cat genarrative-pingora-gateway.service', + '部署脚本看到 Pingora 产物后必须先读取 systemd 最终配置。', ); assertIncludes( commandsLog, - 'systemctl try-restart genarrative-pingora-gateway.service', - 'Pingora shadow service 已 active 时必须随 current release 切换 try-restart。', + 'systemctl restart genarrative-pingora-gateway.service', + 'Pingora shadow service 已 active 时必须随 current release 切换 restart。', ); assertIncludes( result.stdout, - '发布包包含 Pingora,重启已运行的影子服务', + '发布包包含 Pingora,启动或重启 shadow 影子服务', 'Pingora shadow service 自动重启时必须输出明确提示。', ); } -function assertDeploySkipsInactivePingoraWhenArtifactIncluded() { +function assertDeployStartsInactivePingoraWhenArtifactIncluded() { const fixture = prepareFixture('with-inactive-pingora-artifact'); addPingoraGatewayArtifact(fixture); const result = runDeploy(fixture, { pingoraActive: false }); @@ -309,7 +311,7 @@ function assertDeploySkipsInactivePingoraWhenArtifactIncluded() { assertStatus( result, 0, - '包含 Pingora 但 shadow service inactive 时应部署成功。', + '包含 Pingora 且 shadow service inactive 但仍是安全 shadow 配置时应部署成功。', ); if (result.status !== 0) { return; @@ -318,20 +320,86 @@ function assertDeploySkipsInactivePingoraWhenArtifactIncluded() { const commandsLog = readFileSync(fixture.commandsLog, 'utf8'); assertIncludes( commandsLog, - 'systemctl is-active --quiet genarrative-pingora-gateway.service', - '部署脚本看到 Pingora 产物后必须检查 shadow service 是否 active。', + 'systemctl cat genarrative-pingora-gateway.service', + '部署脚本看到 Pingora 产物后必须先读取 systemd 最终配置。', + ); + assertIncludes( + commandsLog, + 'systemctl restart genarrative-pingora-gateway.service', + 'Pingora shadow service inactive 且 shadow 配置安全时必须随 current release 启动。', + ); + assertIncludes( + commandsLog, + 'systemctl is-active --quiet genarrative-pingora-gateway.service', + '启动或重启 Pingora shadow 后必须复核 active。', ); - if ( - commandsLog.includes( - 'systemctl try-restart genarrative-pingora-gateway.service', - ) - ) { - failures.push('Pingora shadow service inactive 时不能主动 try-restart。'); - } assertIncludes( result.stdout, - 'Pingora 影子服务未处于 active,跳过自动重启', - 'Pingora shadow service inactive 时必须明确说明不会主动拉起。', + '发布包包含 Pingora,启动或重启 shadow 影子服务', + 'Pingora shadow service 自动启动时必须输出明确提示。', + ); +} + +function assertDeployRejectsPingoraDirectEntryWhenArtifactIncluded() { + const fixture = prepareFixture('with-direct-entry-pingora-artifact'); + addPingoraGatewayArtifact(fixture); + const result = runDeploy(fixture, { pingoraDirectEntry: true }); + + if (result.status === 0) { + failures.push('包含 Pingora 但 systemd 已启用 direct-entry capability 时部署必须失败。'); + } + assertIncludes( + result.stderr, + 'Pingora systemd 已包含 CAP_NET_BIND_SERVICE', + 'direct-entry capability 存在时必须给出明确错误。', + ); + const commandsLog = readFileSync(fixture.commandsLog, 'utf8'); + if ( + commandsLog.includes( + 'systemctl restart genarrative-pingora-gateway.service', + ) + ) { + failures.push('direct-entry capability 存在时不能自动 restart Pingora。'); + } + if (!existsSync(fixture.maintenanceFile)) { + failures.push('direct-entry capability 导致部署失败时必须保持维护模式。'); + } + assertNoReleasePromoted( + fixture, + 'direct-entry capability 导致部署失败时不能提升正式 release。', + ); +} + +function assertDeployRejectsPingoraPublicListenWhenArtifactIncluded() { + const fixture = prepareFixture('with-public-listen-pingora-artifact'); + addPingoraGatewayArtifact(fixture); + writePingoraEnv(fixture, { + listen: '0.0.0.0:443', + }); + const result = runDeploy(fixture); + + if (result.status === 0) { + failures.push('包含 Pingora 但 env 已配置公网监听时部署必须失败。'); + } + assertIncludes( + result.stderr, + 'Pingora 自动启动只允许 shadow 监听 127.0.0.1:18081', + '公网监听 env 存在时必须给出明确错误。', + ); + const commandsLog = readFileSync(fixture.commandsLog, 'utf8'); + if ( + commandsLog.includes( + 'systemctl restart genarrative-pingora-gateway.service', + ) + ) { + failures.push('公网监听 env 存在时不能自动 restart Pingora。'); + } + if (!existsSync(fixture.maintenanceFile)) { + failures.push('公网监听 env 导致部署失败时必须保持维护模式。'); + } + assertNoReleasePromoted( + fixture, + '公网监听 env 导致部署失败时不能提升正式 release。', ); } @@ -1024,10 +1092,12 @@ function prepareFixture(name) { const releaseRoot = path.join(root, 'releases'); const currentLink = path.join(root, 'current'); const apiEnvFile = path.join(root, 'etc', 'api-server.env'); + const pingoraEnvFile = path.join(root, 'etc', 'pingora-gateway.env'); const maintenanceFile = path.join(root, 'maintenance', 'enabled'); const fakeBin = path.join(root, 'bin'); const commandsLog = path.join(root, 'commands.log'); const workerStateFile = path.join(root, 'worker-service-enabled'); + const pingoraStateFile = path.join(root, 'pingora-service-active'); const version = `20260614-${name}`; mkdirSync(sourceDir, { recursive: true }); @@ -1054,6 +1124,7 @@ function prepareFixture(name) { ].join('\n'), 'utf8', ); + writePingoraEnv({ pingoraEnvFile }); chmodExecutable(path.join(sourceDir, 'api-server')); writeSha256(sourceDir, 'api-server'); writeFileSync( @@ -1226,6 +1297,17 @@ function prepareFixture(name) { 'set -euo pipefail', `printf 'systemctl %s\\n' "$*" >> ${shellQuote(commandsLog)}`, 'worker_state_file="${FAKE_WORKER_STATE_FILE}"', + 'pingora_state_file="${FAKE_PINGORA_STATE_FILE}"', + 'if [[ "$1" == "cat" && "${2:-}" == "genarrative-pingora-gateway.service" ]]; then', + ' printf "[Service]\\n"', + ' printf "EnvironmentFile=%s\\n" "${FAKE_PINGORA_ENV_FILE}"', + ' printf "ExecStart=/opt/genarrative/current/pingora-gateway\\n"', + ' if [[ "${FAKE_PINGORA_DIRECT_ENTRY:-false}" == "true" ]]; then', + ' printf "AmbientCapabilities=CAP_NET_BIND_SERVICE\\n"', + ' printf "CapabilityBoundingSet=CAP_NET_BIND_SERVICE\\n"', + ' fi', + ' exit 0', + 'fi', 'if [[ "$1" == "list-units" ]]; then', ' pattern="${@: -1}"', ' if [[ "${pattern}" == "genarrative-external-generation-worker@*.service" && -f "${worker_state_file}" ]]; then', @@ -1237,9 +1319,16 @@ function prepareFixture(name) { ' printf "enabled\\n" > "${worker_state_file}"', ' exit 0', 'fi', - 'if [[ "$1 $2 ${3:-}" == "is-active --quiet genarrative-pingora-gateway.service" && "${FAKE_PINGORA_ACTIVE:-true}" == "false" ]]; then', + 'if [[ "$1 $2 ${3:-}" == "is-active --quiet genarrative-pingora-gateway.service" ]]; then', + ' if [[ "${FAKE_PINGORA_ACTIVE:-true}" == "true" || -f "${pingora_state_file}" ]]; then', + ' exit 0', + ' fi', ' exit 3', 'fi', + 'if [[ "$1" == "restart" && "${2:-}" == "genarrative-pingora-gateway.service" ]]; then', + ' printf "active\\n" > "${pingora_state_file}"', + ' exit 0', + 'fi', 'exit 0', '', ].join('\n'), @@ -1283,14 +1372,32 @@ function prepareFixture(name) { releaseRoot, currentLink, apiEnvFile, + pingoraEnvFile, maintenanceFile, fakeBin, commandsLog, workerStateFile, + pingoraStateFile, version, }; } +function writePingoraEnv(fixture, options = {}) { + const filePath = fixture.pingoraEnvFile; + const lines = [ + `GENARRATIVE_PINGORA_GATEWAY_LISTEN=${options.listen ?? '127.0.0.1:18081'}`, + ]; + if (options.tlsListen) { + lines.push(`GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=${options.tlsListen}`); + } + if (options.redirectListen) { + lines.push( + `GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=${options.redirectListen}`, + ); + } + writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); +} + function addPingoraGatewayArtifact(fixture, options = {}) { writeFileSync( path.join(fixture.sourceDir, 'pingora-gateway'), @@ -1351,6 +1458,10 @@ function runDeploy(fixture, options = {}) { PATH: `${fixture.fakeBin}:${process.env.PATH || ''}`, GENARRATIVE_MAINTENANCE_FILE: fixture.maintenanceFile, FAKE_PINGORA_ACTIVE: options.pingoraActive === false ? 'false' : 'true', + FAKE_PINGORA_DIRECT_ENTRY: + options.pingoraDirectEntry === true ? 'true' : 'false', + FAKE_PINGORA_ENV_FILE: fixture.pingoraEnvFile, + FAKE_PINGORA_STATE_FILE: fixture.pingoraStateFile, FAKE_CREATE_RELEASE_DURING_COPY: options.createReleaseDuringCopy === true ? 'true' : 'false', FAKE_RELEASE_ROOT: fixture.releaseRoot, @@ -1397,6 +1508,16 @@ function assertFileExists(filePath, reason) { } } +function assertNoReleasePromoted(fixture, reason) { + const releaseDir = path.join(fixture.releaseRoot, fixture.version); + if (existsSync(releaseDir)) { + failures.push(`${reason} 已存在: ${releaseDir}`); + } + if (existsSync(fixture.currentLink)) { + failures.push(`${reason} 不应切换 current: ${fixture.currentLink}`); + } +} + function readDirNames(directory) { return spawnSync('find', [directory, '-maxdepth', '1', '-mindepth', '1', '-printf', '%f\n'], { cwd: process.cwd(), diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 66734666a..f011adf78 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -5317,21 +5317,33 @@ const checks = [ }, { file: 'scripts/deploy/production-api-deploy.sh', - includes: 'systemctl is-active --quiet "${service_name}"', + includes: 'ensure_pingora_shadow_service', reason: - 'API deploy 看到 Pingora 影子网关产物时必须先确认 shadow service 已 active。', + 'API deploy 看到 Pingora 影子网关产物时必须先进入受控 shadow service 启动流程。', }, { file: 'scripts/deploy/production-api-deploy.sh', - includes: 'systemctl try-restart "${service_name}"', + includes: 'systemctl cat "${service_name}"', reason: - 'API deploy 看到 Pingora 影子网关产物且 shadow service 已 active 时必须随 current release 切换重启。', + 'API deploy 自动启动或重启 Pingora 前必须读取 systemd 最终配置。', }, { file: 'scripts/deploy/production-api-deploy.sh', - includes: 'Pingora 影子服务未处于 active,跳过自动重启', + includes: 'CAP_NET_BIND_SERVICE', reason: - 'API deploy 不能在 Pingora shadow service 未运行时主动拉起新公网入口候选服务。', + 'API deploy 自动启动或重启 Pingora 前必须拒绝疑似 direct-entry 低端口 capability。', + }, + { + file: 'scripts/deploy/production-api-deploy.sh', + includes: 'Pingora 自动启动只允许 shadow 监听 127.0.0.1:18081', + reason: + 'API deploy 自动启动 Pingora 时必须限制为本机 shadow 高端口,避免误拉起公网直连入口。', + }, + { + file: 'scripts/deploy/production-api-deploy.sh', + includes: 'systemctl restart "${service_name}"', + reason: + 'API deploy 看到 Pingora 影子网关产物且 shadow 配置安全时必须随 current release 启动或重启。', }, { file: 'scripts/check-production-api-deploy.mjs', @@ -5341,9 +5353,21 @@ const checks = [ }, { file: 'scripts/check-production-api-deploy.mjs', - includes: 'assertDeploySkipsInactivePingoraWhenArtifactIncluded', + includes: 'assertDeployStartsInactivePingoraWhenArtifactIncluded', reason: - 'API deploy 动态烟测必须覆盖 Pingora shadow service inactive 时不会被主动拉起。', + 'API deploy 动态烟测必须覆盖 Pingora shadow service inactive 但配置安全时会被主动拉起。', + }, + { + file: 'scripts/check-production-api-deploy.mjs', + includes: 'assertDeployRejectsPingoraDirectEntryWhenArtifactIncluded', + reason: + 'API deploy 动态烟测必须覆盖 direct-entry capability 下不会自动拉起 Pingora。', + }, + { + file: 'scripts/check-production-api-deploy.mjs', + includes: 'assertDeployRejectsPingoraPublicListenWhenArtifactIncluded', + reason: + 'API deploy 动态烟测必须覆盖公网监听 env 下不会自动拉起 Pingora。', }, { file: 'scripts/check-production-api-release.mjs', diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh index 5019e64c5..14742cdcd 100644 --- a/scripts/deploy/production-api-deploy.sh +++ b/scripts/deploy/production-api-deploy.sh @@ -11,7 +11,7 @@ usage() { 进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。 默认同时重启外部生成 worker controller 和已加载的 worker 实例;未启用 worker 单元时会自动跳过。 若传入 --database,会在重启前把 GENARRATIVE_SPACETIME_DATABASE 写入 api-server 环境文件,避免服务继续读取旧库。 - 若发布包包含 pingora-gateway,部署脚本会在 current 链接切换后仅对已 active 的 Pingora 影子服务执行 try-restart 并复核 active;未运行时不会主动拉起。 + 若发布包包含 pingora-gateway,部署脚本会在 current 链接切换后先复核 systemd/env 仍是本机高端口 shadow 配置,再启动或重启 Pingora 影子服务并复核 active。 失败时保留维护模式。 EOF } @@ -235,18 +235,101 @@ ensure_runtime_env_and_dirs() { fi } -restart_pingora_if_active() { +extract_pingora_env_files_from_unit() { local service_name="$1" + local unit_content - if ! systemctl is-active --quiet "${service_name}"; then - echo "[production-api-deploy] Pingora 影子服务未处于 active,跳过自动重启: ${service_name}" - return + if ! unit_content="$(systemctl cat "${service_name}")"; then + echo "[production-api-deploy] 无法读取 Pingora systemd 最终配置: ${service_name}" >&2 + exit 1 fi - echo "[production-api-deploy] 发布包包含 Pingora,重启已运行的影子服务: ${service_name}" - systemctl try-restart "${service_name}" + if printf "%s\n" "${unit_content}" | grep -Eq '^[[:space:]]*(AmbientCapabilities|CapabilityBoundingSet)=.*CAP_NET_BIND_SERVICE'; then + echo "[production-api-deploy] Pingora systemd 已包含 CAP_NET_BIND_SERVICE,疑似直连入口配置;API deploy 不会自动启动或重启直连服务: ${service_name}" >&2 + exit 1 + fi + + printf "%s\n" "${unit_content}" | while IFS= read -r raw_line; do + local line value token env_file + line="${raw_line#"${raw_line%%[![:space:]]*}"}" + [[ "${line}" == EnvironmentFile=* ]] || continue + value="${line#EnvironmentFile=}" + for token in ${value}; do + env_file="${token#-}" + env_file="${env_file%\"}" + env_file="${env_file#\"}" + env_file="${env_file%\'}" + env_file="${env_file#\'}" + [[ -n "${env_file}" ]] && printf "%s\n" "${env_file}" + done + done +} + +find_pingora_gateway_env_file() { + local service_name="$1" + local env_file listen + + while IFS= read -r env_file; do + if [[ "${env_file}" != /* ]]; then + echo "[production-api-deploy] Pingora EnvironmentFile 必须使用绝对路径: ${env_file}" >&2 + exit 1 + fi + listen="$(read_env_value "${env_file}" "GENARRATIVE_PINGORA_GATEWAY_LISTEN")" + if [[ -n "${listen}" ]]; then + printf "%s\n" "${env_file}" + return + fi + done < <(extract_pingora_env_files_from_unit "${service_name}") + + echo "[production-api-deploy] Pingora systemd 配置缺少包含 GENARRATIVE_PINGORA_GATEWAY_LISTEN 的 EnvironmentFile: ${service_name}" >&2 + exit 1 +} + +require_pingora_shadow_env() { + local env_file="$1" + local listen tls_listen redirect_listen + + listen="$(read_env_value "${env_file}" "GENARRATIVE_PINGORA_GATEWAY_LISTEN")" + tls_listen="$(read_env_value "${env_file}" "GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN")" + redirect_listen="$(read_env_value "${env_file}" "GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN")" + + if [[ "${listen}" != "127.0.0.1:18081" ]]; then + echo "[production-api-deploy] Pingora 自动启动只允许 shadow 监听 127.0.0.1:18081,当前 GENARRATIVE_PINGORA_GATEWAY_LISTEN=${listen:-}" >&2 + exit 1 + fi + if [[ -n "${tls_listen}" ]]; then + echo "[production-api-deploy] Pingora 自动启动不允许启用 TLS_LISTEN,当前 GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=${tls_listen}" >&2 + exit 1 + fi + if [[ -n "${redirect_listen}" ]]; then + echo "[production-api-deploy] Pingora 自动启动不允许启用 HTTP_REDIRECT_LISTEN,当前 GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=${redirect_listen}" >&2 + exit 1 + fi +} + +check_pingora_shadow_service_config() { + local service_name="$1" + local env_file + + env_file="$(find_pingora_gateway_env_file "${service_name}")" + require_pingora_shadow_env "${env_file}" + printf "%s\n" "${env_file}" +} + +ensure_pingora_shadow_service() { + local service_name="$1" + local env_file="${2:-}" + + if [[ -z "${env_file}" ]]; then + env_file="$(check_pingora_shadow_service_config "${service_name}")" + else + require_pingora_shadow_env "${env_file}" + fi + + echo "[production-api-deploy] 发布包包含 Pingora,启动或重启 shadow 影子服务: ${service_name} (${env_file})" + systemctl restart "${service_name}" if ! systemctl is-active --quiet "${service_name}"; then - echo "[production-api-deploy] Pingora 影子服务重启后不是 active: ${service_name}" >&2 + echo "[production-api-deploy] Pingora shadow 影子服务启动或重启后不是 active: ${service_name}" >&2 exit 1 fi } @@ -604,7 +687,7 @@ if [[ -f "${SOURCE_DIR}/pingora-gateway" ]]; then cp "${SOURCE_DIR}/pingora-gateway" "${RELEASE_CONTENT_DIR}/pingora-gateway" cp "${SOURCE_DIR}/pingora-gateway.sha256" "${RELEASE_CONTENT_DIR}/pingora-gateway.sha256" chmod +x "${RELEASE_CONTENT_DIR}/pingora-gateway" - echo "[production-api-deploy] 已复制 Pingora 影子网关;current 链接切换后将复核并重启已运行的 ${PINGORA_SERVICE_NAME}" + echo "[production-api-deploy] 已复制 Pingora 影子网关;current 链接切换后将复核 shadow 配置并启动或重启 ${PINGORA_SERVICE_NAME}" fi BACKUP_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" @@ -786,6 +869,8 @@ cp -R "${ENV_DEPLOY_DIR_SOURCE}" "${RELEASE_CONTENT_DIR}/deploy/env" cp "${SOURCE_DIR}/release-manifest.json" "${RELEASE_CONTENT_DIR}/release-manifest.api-server.json" +PINGORA_SHADOW_ENV_FILE="" + if [[ -n "${DATABASE}" ]]; then echo "[production-api-deploy] 写入 api-server SpacetimeDB database: ${DATABASE} -> ${API_ENV_FILE}" write_env_value "${API_ENV_FILE}" "GENARRATIVE_SPACETIME_DATABASE" "${DATABASE}" @@ -798,6 +883,10 @@ fi ensure_runtime_env_and_dirs "${API_ENV_FILE}" +if [[ "${PINGORA_INCLUDED}" -eq 1 ]]; then + PINGORA_SHADOW_ENV_FILE="$(check_pingora_shadow_service_config "${PINGORA_SERVICE_NAME}")" +fi + mkdir -p "$(dirname "${CURRENT_LINK}")" if [[ -e "${RELEASE_DIR}" ]]; then echo "[production-api-deploy] 目标 release 在发布过程中出现,拒绝合并 staging: ${RELEASE_DIR}" >&2 @@ -808,7 +897,7 @@ STAGING_RELEASE_DIR="" ln -sfnT "${RELEASE_DIR}" "${CURRENT_LINK}" if [[ "${PINGORA_INCLUDED}" -eq 1 ]]; then - restart_pingora_if_active "${PINGORA_SERVICE_NAME}" + ensure_pingora_shadow_service "${PINGORA_SERVICE_NAME}" "${PINGORA_SHADOW_ENV_FILE}" fi echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}"