From 23342035ee47029f57ee4d8f642022a5ff8a6b12 Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 27 Jul 2026 06:13:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=9B=BA=E9=83=A8=E7=BD=B2=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E4=B8=8E=E5=A4=87=E4=BB=BD=E6=9D=A5=E6=BA=90=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=EF=BC=8807-26-internal-ops-recovery-baseline=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/deployment-trialrun.md | 83 ++++++++++++++++++- .../design.md | 9 +- .../implement.md | 13 ++- .../prd.md | 8 +- .../research/live-read-only-baseline.md | 12 +-- deploy/trialrun/.env.example | 4 + deploy/trialrun/checklist.md | 22 +++-- deploy/trialrun/deploy.sh | 18 ++-- deploy/trialrun/docker-compose.yml | 16 +--- deploy/trialrun/install-warch3-ops-timer.sh | 8 +- deploy/trialrun/runbook.md | 65 ++++++++------- deploy/trialrun/validate-env-contract.js | 58 ++++++++++++- scripts/backup-db.sh | 7 +- scripts/backup-storage.sh | 10 +-- scripts/qa/run-backup-restore-drill.sh | 44 ++++++++++ scripts/restore-db.sh | 7 +- 16 files changed, 304 insertions(+), 80 deletions(-) diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index 8c2ae8d1..83e5683e 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -592,6 +592,80 @@ SERVER_HOST_PORT="${SERVER_HOST_PORT##*:}" curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" ``` +## Scenario: Compose Project And Data-Path Isolation + +### 1. Scope / Trigger + +- Trigger: changing Compose identity, host data mounts, database publication, + deployment entrypoints or operational timers. +- Purpose: ensure one approved Compose project owns one verified DB/storage pair + without fixed global container names or accidental shared PGDATA. + +### 2. Signatures + +- Every deploy/timer Compose command uses `-p "$COMPOSE_PROJECT_NAME"`. +- PostgreSQL mount: `${POSTGRES_DATA_DIR:-./data/postgres}`. +- Application storage mount: `${STORAGE_HOST_DIR:-./data/storage}`. +- DB has no host `ports`; server remains loopback-only. + +### 3. Contracts + +- Compose files must not set `container_name`. Operational commands resolve + services through the approved project, never a historical global name. +- A project-name change and its DB/storage path selection are one atomic, + separately approved downtime operation. Never start two PostgreSQL instances + against one PGDATA. +- Relative defaults preserve local trialrun compatibility, but production must + verify the resolved host paths against the active container mounts before + any start, backup or migration. +- Remote readiness may use host curl, but build-SHA JSON validation runs with + Node inside the server container; the Docker host does not need Node. + +### 4. Validation & Error Matrix + +- DB `ports` appears -> environment contract fails. +- Any fixed `container_name` appears -> environment contract fails. +- Required data mount loses its parameter -> environment contract fails. +- Deploy/timer omits `-p` -> environment contract fails. +- New project resolves the same PGDATA while the old DB runs -> stop before + Compose start; do not use service health as permission to continue. + +### 5. Tests Required + +- `node deploy/trialrun/validate-env-contract.js`. +- The isolated backup/restore drill mutates Compose with a DB port and fixed + name and proves the contract rejects it. +- `bash -n` covers deploy, timer, backup and restore scripts. + +### 6. Good/Base/Bad Cases + +- Good: one approved project resolves one stopped-and-migrated PGDATA and the + matching active storage path; deploy, timer and backup all use that identity. +- Base: local trialrun uses the documented relative defaults under one project. +- Bad: start a renamed project before the old DB stops, or let it create a new + empty directory and accept a green health check as proof of data continuity. + +### 7. Wrong vs Correct + +#### Wrong + +```bash +docker-compose up -d db +DB_CONTAINER=hyb-oa-trialrun-db ./scripts/backup-all.sh +``` + +#### Correct + +```bash +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml \ + --env-file deploy/trialrun/.env up -d db +mapfile -t DB_CONTAINERS < <(docker-compose -p "$COMPOSE_PROJECT_NAME" \ + -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env ps -q db) +test "${#DB_CONTAINERS[@]}" -eq 1 +DB_TOOL_MODE=docker DB_CONTAINER="${DB_CONTAINERS[0]}" \ + STORAGE_DIR_HOST= ./scripts/backup-all.sh +``` + ## Scenario: Paired Backup And Shadow-Only Restore ### 1. Scope / Trigger @@ -603,7 +677,7 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" ### 2. Signatures -- Paired backup: `DB_TOOL_MODE=docker DB_CONTAINER= ./scripts/backup-all.sh`. +- Paired backup: `DB_TOOL_MODE=docker DB_CONTAINER= STORAGE_DIR_HOST= ./scripts/backup-all.sh`. - Optional safe batch identifier: `BACKUP_RUN_ID=`. - Manifest writer: host `node` is preferred; host `python3` is an equivalent fallback for Docker-only runtime machines. @@ -627,6 +701,10 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" the running database. Confirm that the selected PostgreSQL container's database and user match at least one active application `DATABASE_URL`, then pass the verified runtime values without logging their contents. +- Docker backup/restore has no default container name. Resolve the unique DB + container with `compose -p ... ps -q db` and fail when the + result is empty or ambiguous. Storage backup likewise requires an explicit + `STORAGE_DIR_HOST`; it must never infer `STORAGE_DIR` or a repository default. - The DB dump must pass `pg_restore --list` and the storage archive must pass `tar -tzf` before the manifest may say `complete`. - DB restore queries `current_database()` and compares it exactly with @@ -704,6 +782,7 @@ source deploy/trialrun/.env ./scripts/qa/run-backup-restore-drill.sh # Before an approved live backup, compare the selected DB container identity # with an active app connection using redacted boolean diagnostics. -DB_TOOL_MODE=docker DB_CONTAINER= ./scripts/backup-all.sh +DB_TOOL_MODE=docker DB_CONTAINER= \ + STORAGE_DIR_HOST= ./scripts/backup-all.sh # Real restore remains an approved incident operation after shadow evidence. ``` diff --git a/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md b/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md index a86279ae..07212e7d 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md @@ -112,14 +112,19 @@ GET /auth/wecom/login?mode=onboard - 现场是混合部署,不能把“统一目录”处理成普通 rsync:必须以当前活动 PostgreSQL 数据和附件挂载为唯一数据事实源。 - 在停机切换前固定记录活动容器、数据挂载、环境文件摘要、端口、Caddy 路由和回滚镜像;不得打印环境变量内容。 -- 当前部署脚本固定探测宿主端口,而现场通过另一宿主端口提供 OA;脚本必须从已验证的 Compose/env 配置解析实际端口,不能硬编码。 +- 现场通过非默认宿主端口提供 OA;部署脚本必须从已验证的 Compose/env 配置解析实际端口,不能回退到硬编码(本地已实现,待获批部署)。 - 两套目录的 Compose 容器名和环境文件不同。切换前必须先选择唯一目录并证明其 DB/附件目标就是当前活动数据;不得让 Compose 创建第二套空数据库后误判部署成功。 - 统一后应由同一目录负责 source sync、Compose、环境文件和运维 timer,并让 `/ready`、前端 marker 和容器环境同时暴露同一短构建标识。 - 任一迁移状态、数据挂载、端口或公网构建标识不匹配时,立即切回旧容器和 Caddy 目标;不执行数据库 reset 或数据目录覆盖。 +- Compose 不再固定 `container_name`;deploy 与 timer 通过 `-p` 显式选择同一项目,PostgreSQL 与附件宿主路径分别由 `POSTGRES_DATA_DIR`、`STORAGE_HOST_DIR` 参数化。 +- 项目名隔离不是独立部署步骤。项目名、容器身份和数据目录必须在同一次停机切换中启用;旧实例未停止时不得让新 PostgreSQL 挂载同一 PGDATA。 +- DB 不发布宿主端口;应用仅通过 Compose 内部网络访问 `db`。环境契约必须拒绝 DB `ports`、固定容器名或未参数化数据目录回退。 +- Docker 备份/恢复必须显式传入由 `compose ... ps -q db` 得到的已核验容器 ID;附件备份必须显式传入活动挂载源,两个脚本都不猜默认值。 +- 远端 `/ready` 构建标识在 server 容器内用 Node 校验,宿主机只负责 curl 和 Compose,不要求安装 Node。 ### 生产统一顺序 -1. 使用已完成的成对异机备份作为灾难回滚点,并记录活动数据挂载、环境摘要、容器镜像和 Caddy 端口。 +1. 另行获批后,从活动 DB 和活动附件挂载源重新生成成对异机备份作为灾难回滚点;旧批次因混入非活动附件目录只保留审计,并记录活动数据挂载、环境摘要、容器镜像和 Caddy 端口。 2. 本地先修复 Compose 可配置回环端口及部署脚本动态端口探测,通过全部门禁并提交、发布批准提交。 3. 停机后把非活动的既定部署目录改名保留,将活动数据目录和环境文件迁入既定目录;同一文件系统优先原子改名,不复制运行中的 PostgreSQL 文件。 4. 用新 Compose 重建 db/init/server,应用已提交 migrations;旧容器和旧目录在验收前保留,不清理。 diff --git a/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md b/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md index 6fc7c372..4729df0f 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md @@ -10,7 +10,8 @@ - [x] 更新 runbook/checklist 和部署规约,区分普通备份、影子演练和真实事故恢复。 - [x] 运行任务测试计划和 Trellis 治理门禁;按实际结果记录本地 RTO,不伪造 `/ready` 或异机证据。 - [x] 在单独授权后完成真实运行来源、职责分离、异机副本和 `/ready` 的只读核查,并如实记录 `NO_GO` 缺口。 -- [x] 经明确授权完成提交发布和真实 DB+附件+manifest 成对异机备份,两端完整性复验通过。 +- [x] 经明确授权生成并复制过 DB+附件+manifest 批次;后续发现附件来自非活动目录,已将该批次降级为无效恢复证据并保留审计。 +- [ ] 经后续明确授权,从活动 DB 与活动 server 附件挂载源重新生成正确批次并完成异机复验。 - [ ] 经后续明确授权完成真实职责分配、部署/迁移和真实格式影子恢复后复验。 - [x] 修复企微 HR 双 Secret 分工:通讯录 token 只列 userid,自建应用 token 读取详情、部门和 HR 信息。 - [x] 补充单元测试并运行后端语法、聚焦测试、Prisma 与 Trellis 门禁。 @@ -19,6 +20,8 @@ - [x] 增加默认关闭的一次性企微敏感资料授权入口,保持普通登录静默;实现 `user_ticket -> getuserdetail`、active/字段/唯一性门禁和仅 `employee` 自助开通。 - [x] 补齐 OAuth 路由、服务、配置契约和单元测试,证明票据/原始敏感响应不进入日志、Cookie、数据库或审计。 - [ ] 修复部署脚本的现场端口解析,确定唯一部署目录,并在活动 DB/附件挂载、环境摘要和旧镜像回滚点全部确认后才执行获批切换。 +- [x] 完成本地 Compose/备份加固:移除固定容器名和 DB 宿主端口,参数化数据目录,deploy/timer 显式 project,备份来源缺失硬失败,远端构建标识改由容器内 Node 核验。 +- [x] 增加隐式附件源、隐式 DB 容器、DB 端口与固定容器名回退的负向契约演练;未部署或启用新 Compose。 - [ ] 部署后验证唯一 Compose 来源、构建标识、迁移状态、Caddy 公网 `/ready` 和前端 marker;不因服务可访问而忽略来源漂移。 - [ ] 用户在企微应用详情选择手机、邮箱、企业邮箱敏感字段;部署开关开启后,两名目标分别通过一次性链接本人确认授权。 - [ ] 两人自助开通成功后写入获批 `finance` / `ceo` 职责,复核不同身份、maker-checker、会话撤销、审计和付款零写入。 @@ -46,6 +49,7 @@ - `cd server && npm test -- --run tests/unit/routes/wecomAuth.test.js tests/unit/wecomHrMaster.service.test.js` - `cd server && npm test -- --run tests/unit/routes/wecomAuth.test.js tests/unit/wecomHrMaster.service.test.js tests/unit/wecomOAuthOnboarding.service.test.js` - `bash -n deploy/trialrun/deploy.sh` +- `bash -n deploy/trialrun/install-warch3-ops-timer.sh` ## 本轮本地验证证据(2026-07-26) @@ -53,6 +57,13 @@ - `prisma validate`、四个企微改动文件 `node --check`、部署环境契约和部署脚本语法均通过。 - 本地实现没有开启 `WECOM_OAUTH_ONBOARDING_ENABLED`,没有部署、迁移、写真实员工/角色或修改企微后台权限。 +## 本地部署加固证据(2026-07-27) + +- Shell 语法、部署环境契约、Compose 示例配置解析、Trellis task/governance、敏感扫描、文档注册、流程合同、Prisma 和 `git diff --check` 通过。 +- 合成备份恢复在 Node 路径和移除 Node 的 Python 3 回退路径均通过,最近两次本地参考 RTO 均为 6 秒;负向覆盖隐式附件源、隐式 DB 容器、DB 宿主端口、固定容器名和 deploy 缺失 project name。 +- 本阶段授权仅覆盖本地文件、测试、规约和提交,不包含生产备份、停机、迁移、部署、timer 安装或企微写入。 +- 已确认旧异机批次混用了活动 DB 与非活动附件目录,保留审计但不再作为活动实例成对恢复证据;正确批次须另行批准重做。 + ## 回滚点 - 成对入口失败:保留四个底层脚本,不写完整 manifest。 diff --git a/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md b/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md index f4d68783..0ee67638 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md @@ -43,6 +43,7 @@ - 自助开通按企微 userid、手机号和邮箱做唯一性检查;新账号只创建 `employee` 角色,随机不可知密码并要求改密,不自动授予财务、CEO、管理员或审批角色。 - 普通 `snsapi_base` 登录、已绑定账号登录和现有绑定流程保持兼容,不因资料授权功能改成每次手工确认。 - 生产统一以仓库既定部署目录和 Compose 契约为目标,但活动 PostgreSQL 与附件数据是唯一数据事实源;停机切换不得复制运行中的 PostgreSQL 文件、启动第二套空库或覆盖环境变量。 +- Compose 项目名、容器身份与 DB/附件目录必须在同一次获批停机窗口切换;DB 不发布宿主端口,备份不得猜测容器名或附件目录。 - 部署健康检查必须从 Compose 解析实际宿主端口,并校验后端 `/ready`、前端 marker 与批准提交一致;应用已提交的 Prisma migrations 但禁止 reset 或改写历史。 ## Acceptance Criteria @@ -60,6 +61,7 @@ - [x] 普通企微登录仍生成 `snsapi_base`,一次性开通入口生成 `snsapi_privateinfo`;缺少开关、`user_ticket`、成员授权字段、active 状态或唯一性时均拒绝创建(本地 synthetic 验证)。 - [ ] 一次性授权通过 `getuserdetail` 获取成员同意的手机和邮箱,自动创建或补齐 OA 员工与企微绑定;新建账号角色严格等于 `employee`,日志和审计不含票据或完整敏感响应。 - [ ] 部署目录、Compose、环境文件、运维 timer 和活动容器来源统一;活动 DB/附件连续,远端 migrations 与批准提交一致,公网 `/ready` 和前端 marker 返回同一短构建标识。 +- [x] 本地 Compose、部署、timer 和备份合同完成加固并覆盖负向回退;未把本地通过误报为生产切换完成。 - [ ] 两名目标成员分别完成一次本人授权后,账号唯一、企微绑定不同,再由管理员写入 `employee + finance` 与 `employee + ceo`;maker-checker、会话撤销和角色审计通过。 ## Out of Scope @@ -84,7 +86,7 @@ - `cd server && npm test -- --run tests/unit/wecomHrMaster.service.test.js` - `cd server && npm run prisma:validate` - `cd server && npm test -- --run tests/unit/routes/wecomAuth.test.js tests/unit/wecomHrMaster.service.test.js` -- `bash -n deploy/trialrun/deploy.sh` +- `bash -n deploy/trialrun/deploy.sh deploy/trialrun/install-warch3-ops-timer.sh` - `node deploy/trialrun/validate-env-contract.js` - 获批部署窗口内执行迁移状态、Compose 数据挂载、`/ready`、前端 marker、Caddy 公网路由和回滚容器只读复验。 @@ -105,11 +107,11 @@ - 获批只读核查结果见 `research/live-read-only-baseline.md`,结论为 `NO_GO_FOR_FORMAL_INTERNAL_USE`。 - 公开 HTTPS、TLS、HSTS 和 `/ready` 当前可用,但运行环境没有后端/前端构建标识,且远端迁移少于本地当前基线。 - 真实角色只有一个活跃账号,财务和付款复核候选均为 0,不满足 maker-checker。 -- 只读核查时的历史 DB/附件备份约 55 天未更新、无 manifest,且与运行数据位于同一设备;获批后已生成新的 DB+附件+manifest 完整批次并复制到独立主机,两端校验一致。 +- 只读核查时的历史 DB/附件备份约 55 天未更新、无 manifest,且与运行数据位于同一设备;后续异机批次虽通过传输校验,但复核发现混用了非活动附件目录,已降级为无效恢复证据,须另行批准后从活动 DB/附件重新生成。 - 当前代码已在获批后以普通快进方式发布到 upstream,来源门禁为 `PASS`;尚未执行角色修改、部署、迁移或真实格式影子恢复,因此最后一项验收继续保持未勾选。 - 当前 OA 是宿主机 Caddy、容器化 server/frontend、独立 PostgreSQL 容器和宿主附件挂载组成的混合部署;公网 OA 路由确实进入 server 容器。 - 服务器存在两套无 Git 元数据的部署目录:运行容器来源与日常运维任务指向不同目录,Compose 和环境文件也不一致;运行代码与本机当前代码不一致且缺少构建标识。 -- 现场端口与当前部署脚本的固定健康检查端口不一致;未统一活动数据目录、容器名、端口和环境文件前,不得直接运行现有部署脚本。 +- 现场端口与历史部署脚本的固定健康检查端口不一致;本地脚本现已动态解析,但未统一活动数据目录、项目名、端口和环境文件前仍不得部署。 - 企微结构化探测证明 6 名成员的批量详情接口均成功并返回部门,但手机号、邮箱和企业邮箱可读数量均为 0;HR 接口均成功但没有 `staff_info`。官方文档确认这不代表字段不可获取:2022-06-20 后新建自建应用应由管理员在应用详情选择敏感字段,再由成员通过 `snsapi_privateinfo` 手工授权,并用 `user_ticket` 调 `/cgi-bin/auth/getuserdetail` 获取本人手机号、邮箱或企业邮箱。 - 当前 OA OAuth 只使用 `snsapi_base`,且没有调用 `/cgi-bin/auth/getuserdetail`,因此现有登录链路不能采集敏感字段。两名目标各唯一匹配且身份不同,但批量 dry-run 均缺 `phone`、`email`,真实写入已按门禁停止。 - 本轮已在本地实现默认关闭的一次性 `onboard` 入口;普通登录继续静默。全量后端门禁与安全补修后的企微聚焦回归结果记录在 `implement.md`。该证据不代表功能已部署,也不代表企微后台敏感字段已由管理员选择。 diff --git a/.trellis/tasks/07-26-internal-ops-recovery-baseline/research/live-read-only-baseline.md b/.trellis/tasks/07-26-internal-ops-recovery-baseline/research/live-read-only-baseline.md index e36be90b..7d89ca8a 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/research/live-read-only-baseline.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/research/live-read-only-baseline.md @@ -63,30 +63,30 @@ 服务当前可访问不等于运行与恢复底线通过。进入下一步前必须按顺序完成: 1. 已将当前获批代码以普通快进方式发布到 upstream,来源门禁复验为 `PASS`。 -2. 已在获批后用新成对备份合同生成 DB + 附件 + manifest,并复制到独立主机;两端 manifest 和产物校验通过。 +2. 重新从活动 DB 与活动 server 附件挂载源生成 DB + 附件 + manifest,并复制到独立主机;此前批次混用了非活动附件目录,不能作为活动实例的成对恢复证据。 3. 明确至少一名财务处理人和另一名 `manager`/`ceo` 付款复核人;系统管理职责单独分配,应急 `admin` 独立托管。 4. 通过获批部署任务升级代码和 3 项迁移,验证 `BUILD_SHA`、前端 marker、`/ready`、权限矩阵和回滚点。 5. 在独立影子目标恢复同一批次 DB/附件,验证应用 `/ready` 和关键附件引用,记录真实 RPO/RTO。 发布和远程备份已分别获得授权并完成;真实角色写入、部署和影子恢复仍须继续取得明确授权,不能由既有批准推导。 -## 后续获批写入:成对异机备份 +## 后续获批写入:已降级的异机备份批次 - 执行日期:2026-07-26。 - 授权范围:生成真实 DB、附件和 manifest 成对备份,并复制到用户指定的另一台主机。 - 源机与目标机设备指纹不同;未记录主机名、地址、SSH 名称、账号或完整目录。 -- 完整批次:`hyb-oa-backup-20260726T133923Z-live`,包含 `database.dump`、`storage.tar.gz`、`manifest.json` 三个预期文件,总大小约 9.6 MiB。 -- 源端完整性检查通过;目标端重新计算 DB/附件 SHA-256 并与 manifest 核对通过;两端 manifest 哈希一致。 +- 批次 `hyb-oa-backup-20260726T133923Z-live` 的三份产物及传输校验本身完整,但后续拓扑复核证明其来源是“活动 DB + 非活动附件目录”。该批次保留作审计,不再计为活动实例的成对恢复点。 +- 该批次的附件归档含 54 个文件,而活动 server 实际附件挂载为空,活动库附件类记录也均为 0;两端哈希一致只能证明传输一致,不能证明源选择正确。 - 目标批次目录权限为 `700`;传输失败产生的单文件残缺副本已精确删除,本机临时暂存数据已安全清理。 - 运行主机没有宿主 Node.js,因此为成对备份入口增加 Python 3 manifest 等价回退;无 Node 路径已通过完整合成恢复演练,本地参考 RTO 仍为 6 秒。 - 运行中的 PostgreSQL 容器参数与部署目录环境文件存在漂移;本次只在运行容器参数与活动服务数据库连接相互匹配后执行备份,未修改环境文件。 -- 本次未执行恢复、部署、迁移、角色修改、企微写入或业务写入;真实格式影子恢复和恢复后 `/ready` 仍无证据。 +- 必须在另行批准后,显式提供经挂载核验的 `STORAGE_DIR_HOST` 和 Compose 解析出的 DB 容器 ID,重新生成并复制正确批次。本次未执行恢复、部署、迁移、角色修改、企微写入或业务写入;真实格式影子恢复和恢复后 `/ready` 仍无证据。 ## 后续获批调查:部署拓扑与企微字段可用性 - 当前 OA 公网路由由宿主反向代理进入 server 容器;后端和前端构建位于同一应用镜像,数据库位于独立容器,附件由宿主目录挂载。 - 运行容器来源目录与日常运维任务目录不同;两套目录都没有 Git 元数据,源文件相同但 Compose 与环境文件摘要不同。运行代码与本机当前代码不一致,`/ready` 无构建标识。 -- 仓库部署脚本的固定宿主健康检查端口与现场实际端口不一致。直接同步并执行现有 Compose 可能产生不同容器名或第二套数据目标,故未执行部署、重启、迁移或目录改写。 +- 核查时仓库部署脚本的固定宿主健康检查端口与现场实际端口不一致;本地现已改为 Compose 动态解析,并移除固定容器名、参数化数据目录,但新项目名和数据路径尚未在获批停机窗口启用,故仍未执行部署、重启、迁移或目录改写。 - 使用修正后的双 Secret 读取链路做结构化探测:成员详情 6/6 成功、部门 6/6 可读,手机号/邮箱/企业邮箱均 0/6 可读;HR 详情 6/6 返回成功但 `staff_info` 为 0/6。该结果只证明批量读取链路拿不到敏感字段。 - 企微官方文档复核:2022-06-20 后新建的自建应用不再通过 `/user/get` 返回手机、邮箱和企业邮箱;管理员需在应用详情选择敏感字段,成员使用 `snsapi_privateinfo` 手工授权,`/cgi-bin/auth/getuserinfo` 才返回 30 分钟有效的 `user_ticket`,随后 `/cgi-bin/auth/getuserdetail` 可返回成员同意的手机号、邮箱和企业邮箱。 - 当前 OA `server/src/routes/wecomAuth.js` 固定使用 `snsapi_base`,只调用 `getuserinfo` 获取 userid,不调用 `getuserdetail`;因此现状是项目尚未接入官方敏感信息授权链路,而不是企微没有该能力。 diff --git a/deploy/trialrun/.env.example b/deploy/trialrun/.env.example index 1f74318a..5f64fb72 100644 --- a/deploy/trialrun/.env.example +++ b/deploy/trialrun/.env.example @@ -98,6 +98,9 @@ WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE= IM_NOTIFIER_ENABLED=true # ===== 数据库(Compose 内部连接) ===== +# Compose 项目名由 deploy/timer 命令通过 -p 显式传入,不写入本文件。 +# 数据目录默认相对 deploy/trialrun/;生产切换时必须与项目名在同一停机窗口确认和启用。 +POSTGRES_DATA_DIR=./data/postgres POSTGRES_DB= POSTGRES_USER= POSTGRES_PASSWORD= @@ -105,6 +108,7 @@ DATABASE_URL= # SHADOW_DATABASE_URL= # ===== 存储与上传 ===== +STORAGE_HOST_DIR=./data/storage STORAGE_DIR=/data/storage STORAGE_DRIVER=local S3_ENDPOINT=http://minio:9000 diff --git a/deploy/trialrun/checklist.md b/deploy/trialrun/checklist.md index 238417e3..69927554 100644 --- a/deploy/trialrun/checklist.md +++ b/deploy/trialrun/checklist.md @@ -25,6 +25,9 @@ - [ ] 已确认 OA 前端静态资源由 server 容器内 `/app/client/dist` 提供,Caddy 只反代到 `127.0.0.1:` ## A. 发布前(Pre-flight) +- [ ] 已冻结唯一 `COMPOSE_PROJECT_NAME`,所有 deploy、timer、runbook 命令均显式使用 `-p`,未依赖目录名或固定容器名 +- [ ] 已核对 `POSTGRES_DATA_DIR` 与 `STORAGE_HOST_DIR` 指向活动数据;项目名与数据目录只在同一次获批停机窗口切换 +- [ ] 已证明没有两个 PostgreSQL 实例同时挂载同一 PGDATA;任何不一致均停止发布 - [ ] 确认当前版本已打 tag:`git tag -a trialrun-YYYYMMDD-NN -m "trialrun release"` - [ ] 本地门禁:`./scripts/check-minimal.sh` - [ ] 部署契约校验:`node deploy/trialrun/validate-env-contract.js` @@ -33,7 +36,9 @@ - [ ] 全量回归(建议):`cd server && RUN_AUDIT_REGRESSION=1 RUN_OCR_REGRESSION=1 ./scripts/regression-all.sh` - [ ] 备份(DB + 文件存储): - [ ] `set -a && source deploy/trialrun/.env && set +a` - - [ ] `DB_TOOL_MODE=docker DB_CONTAINER=hyb-oa-trialrun-db ./scripts/backup-all.sh` + - [ ] `DB_CONTAINER="$(docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env ps -q db)"` 返回唯一非空容器 ID + - [ ] `DB_TOOL_MODE=docker DB_CONTAINER="$DB_CONTAINER" STORAGE_DIR_HOST= ./scripts/backup-all.sh` + - [ ] `DB_CONTAINER` 或 `STORAGE_DIR_HOST` 缺失时备份硬失败,没有回退到历史容器名或仓库默认附件目录 - [ ] 同一批次包含 `database.dump`、`storage.tar.gz`、`manifest.json`,两份 SHA-256 校验通过 - [ ] 完整批次已复制到获批异机/异盘,并记录保留周期 @@ -44,12 +49,13 @@ - [ ] 标准发布通过 `bash deploy/trialrun/deploy.sh` 执行;脚本自动注入完整后端 SHA 与 12 位前端 SHA,并完成构建、init 与 smoke - [ ] 若为首次初始化或受控应急而直接使用 Compose,已显式设置 `BUILD_SHA=` 与 `VITE_BUILD_SHA=`;未使用 `unknown` - [ ] 探活: - - [ ] 已用 `docker-compose ... port server 3000` 解析实际宿主端口,并对该端口执行 `/ready`,未依赖当前 shell 中可能缺失的 `APP_HOST_PORT` + - [ ] 已用 `docker-compose -p "$COMPOSE_PROJECT_NAME" ... port server 3000` 解析实际宿主端口,并对该端口执行 `/ready`,未依赖当前 shell 中可能缺失的 `APP_HOST_PORT` + - [ ] `/ready` 的 JSON 在 server 容器内用 Node 核对 build SHA;宿主机无需 Node.js - [ ] Runtime smoke: - - [ ] `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js` + - [ ] `docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js` - [ ] 若 runtime smoke 报 `announcements_v1` 或 `global_search_v1` 为 `force_off`,已记录为发布阻塞,不忽略通过 - [ ] external deps smoke: - - [ ] `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-external-deps.js` + - [ ] `docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-external-deps.js` - [ ] 若 external deps smoke 通过,已确认通过前提不是“企微关闭 + OCR mock”误通过 - [ ] 若启用腾讯 OCR,已脱敏核查 `OCR_PROVIDER=tencent`、`TENCENT_SECRET_ID` 长度、`TENCENT_SECRET_KEY` 长度和 `TENCENT_OCR_REGION` - [ ] 若本次包含前端体验修复,已在容器内确认 `/app/client/dist` 含关键 test id 或公网 asset hash 已更新 @@ -61,7 +67,7 @@ - [ ] 若本地已配置 `LIVE_SMOKE_*`,确认 `bash deploy/trialrun/deploy.sh` 输出中包含 live smoke 结果 - [ ] 已确认 release gate / live smoke 结果为通过;关键链路不再作为每次发布的手工必做项 - [ ] 查看 server 日志(无异常错误风暴): - - [ ] `docker logs --tail=200 hyb-oa-trialrun-server` + - [ ] `docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env logs --tail=200 server` - [ ] 已确认 smoke 账号为独立账号(`smoke-admin` / `smoke-employee`),且不参与真实业务 - [ ] 已确认 live smoke 仅检查 `/ready`、登录、Portal、项目、公告、个人设置和企微配置状态 - [ ] 若涉及票据/OCR,已执行真实 OCR 辅助复核或记录跳过原因:`PLAYWRIGHT_MANUAL=1 PLAYWRIGHT_OCR_LIVE=1 ... npx playwright test tests/e2e/manual/live-invoice-ocr.spec.ts` @@ -71,13 +77,13 @@ > 原则:如果涉及迁移,最快回滚通常是 “恢复备份 + 回到上个 tag”。 - [ ] **EXTERNAL_GATED**:若回滚目标仍包含旧认证挑战逻辑,人工确认边缘继续关闭 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify`;脚本不会自动改边缘,未确认不得回滚开放,直到安全版本恢复 -- [ ] 停止 server:`docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env stop server` +- [ ] 停止 server:`docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env stop server` - [ ] 恢复 DB/存储属于获批事故操作;当前脚本仅允许 `RESTORE_SCOPE=shadow`,不得绕过后直接指向真实目标 - [ ] 已先在独立数据库和空附件目录完成同批次影子恢复,数据库名精确确认且 manifest/SHA-256 匹配 - [ ] 回到上个版本:`git checkout ` - [ ] 重启并探活: - - [ ] `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server` - - [ ] 已重新用 `docker-compose ... port server 3000` 解析实际宿主端口并执行 `/ready` + - [ ] `docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server` + - [ ] 已重新用 `docker-compose -p "$COMPOSE_PROJECT_NAME" ... port server 3000` 解析实际宿主端口并执行 `/ready` - [ ] 若仅为 OCR 应急降级,已把远端 `.env` 的 `OCR_PROVIDER` 临时切回 `mock`,重启 server,并在 Issue/发布记录中写明原因、影响范围和恢复时间 diff --git a/deploy/trialrun/deploy.sh b/deploy/trialrun/deploy.sh index a8d82f9c..9cadfb07 100755 --- a/deploy/trialrun/deploy.sh +++ b/deploy/trialrun/deploy.sh @@ -8,6 +8,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" DEPLOY_SERVER="${DEPLOY_SERVER:-}" REMOTE_DIR="${REMOTE_DIR:-/opt/hyb-oa-trialrun}" REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-${REMOTE_DIR}/deploy/trialrun/.env}" +COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-hyb-oa-trialrun}" ENABLE_WORKER="${ENABLE_WORKER:-0}" ENABLE_MINIO="${ENABLE_MINIO:-0}" SYNC_ENV="${SYNC_ENV:-0}" @@ -47,6 +48,11 @@ if [[ -z "$DEPLOY_SERVER" ]]; then exit 1 fi +if [[ ! "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then + echo "[ERROR] COMPOSE_PROJECT_NAME 只能包含小写字母、数字、下划线和连字符" >&2 + exit 1 +fi + if [[ ! -f "${ROOT_DIR}/${PROVENANCE_SCRIPT}" ]]; then echo "[ERROR] 缺少 ${PROVENANCE_SCRIPT}" >&2 exit 1 @@ -137,7 +143,7 @@ fi echo ">>> 6. 在服务器执行部署与 smoke" ssh "${DEPLOY_SERVER}" \ - "REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' ENABLE_WORKER='${ENABLE_WORKER}' ENABLE_MINIO='${ENABLE_MINIO}' BUILD_SHA='${SOURCE_COMMIT}' VITE_BUILD_SHA='${SOURCE_SHORT_COMMIT}' bash -s" <<'REMOTE' + "REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' COMPOSE_PROJECT_NAME='${COMPOSE_PROJECT_NAME}' ENABLE_WORKER='${ENABLE_WORKER}' ENABLE_MINIO='${ENABLE_MINIO}' BUILD_SHA='${SOURCE_COMMIT}' VITE_BUILD_SHA='${SOURCE_SHORT_COMMIT}' bash -s" <<'REMOTE' set -euo pipefail cd "$REMOTE_DIR" mkdir -p deploy/trialrun/data/postgres deploy/trialrun/data/storage deploy/trialrun/data/minio @@ -148,9 +154,9 @@ if [[ ! -f "$REMOTE_ENV_FILE" ]]; then fi if docker compose version >/dev/null 2>&1; then - compose_cmd=(docker compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE") + compose_cmd=(docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE") elif command -v docker-compose >/dev/null 2>&1; then - compose_cmd=(docker-compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE") + compose_cmd=(docker-compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE") else echo "[ERROR] 远端既没有 docker compose,也没有 docker-compose" >&2 exit 1 @@ -202,8 +208,10 @@ fi ready_ok=0 for i in $(seq 1 60); do if ready_payload="$(curl -fsS "http://127.0.0.1:${server_host_port}/ready" 2>/dev/null)"; then - if READY_PAYLOAD="$ready_payload" EXPECTED_BUILD_SHA="${BUILD_SHA:0:12}" node -e ' - const payload = JSON.parse(process.env.READY_PAYLOAD || "{}"); + if printf '%s' "$ready_payload" | "${compose_cmd[@]}" exec -T \ + -e EXPECTED_BUILD_SHA="${BUILD_SHA:0:12}" server node -e ' + const fs = require("node:fs"); + const payload = JSON.parse(fs.readFileSync(0, "utf8") || "{}"); if (payload?.data?.buildSha !== process.env.EXPECTED_BUILD_SHA) process.exit(1); '; then ready_ok=1 diff --git a/deploy/trialrun/docker-compose.yml b/deploy/trialrun/docker-compose.yml index 22af7510..6bde25d5 100644 --- a/deploy/trialrun/docker-compose.yml +++ b/deploy/trialrun/docker-compose.yml @@ -3,16 +3,13 @@ version: "3.8" services: db: image: postgres:16-alpine - container_name: hyb-oa-trialrun-db restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - ports: - - "5432:5432" volumes: - - ./data/postgres:/var/lib/postgresql/data + - ${POSTGRES_DATA_DIR:-./data/postgres}:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s @@ -21,7 +18,6 @@ services: minio: image: minio/minio:RELEASE.2025-01-20T14-49-07Z - container_name: hyb-oa-trialrun-minio restart: unless-stopped profiles: ["minio"] environment: @@ -36,7 +32,6 @@ services: minio_init: image: minio/mc:RELEASE.2025-08-13T08-35-41Z - container_name: hyb-oa-trialrun-minio-init profiles: ["minio"] depends_on: - minio @@ -58,7 +53,6 @@ services: args: BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit} VITE_BUILD_SHA: ${VITE_BUILD_SHA:?set VITE_BUILD_SHA to the matching short commit} - container_name: hyb-oa-trialrun-server restart: unless-stopped depends_on: db: @@ -71,7 +65,7 @@ services: ports: - "127.0.0.1:${APP_HOST_PORT:-3000}:3000" volumes: - - ./data/storage:${STORAGE_DIR} + - ${STORAGE_HOST_DIR:-./data/storage}:${STORAGE_DIR} worker: build: @@ -80,7 +74,6 @@ services: args: BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit} VITE_BUILD_SHA: ${VITE_BUILD_SHA:?set VITE_BUILD_SHA to the matching short commit} - container_name: hyb-oa-trialrun-worker restart: unless-stopped profiles: ["worker"] depends_on: @@ -95,7 +88,7 @@ services: SERVE_CLIENT_DIST: "false" JOBS_ENABLED: "true" volumes: - - ./data/storage:${STORAGE_DIR} + - ${STORAGE_HOST_DIR:-./data/storage}:${STORAGE_DIR} init: build: @@ -104,7 +97,6 @@ services: args: BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit} VITE_BUILD_SHA: ${VITE_BUILD_SHA:?set VITE_BUILD_SHA to the matching short commit} - container_name: hyb-oa-trialrun-init depends_on: db: condition: service_healthy @@ -114,7 +106,7 @@ services: NODE_ENV: production BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit} volumes: - - ./data/storage:${STORAGE_DIR} + - ${STORAGE_HOST_DIR:-./data/storage}:${STORAGE_DIR} entrypoint: ["sh", "-c"] command: > cd /app/server diff --git a/deploy/trialrun/install-warch3-ops-timer.sh b/deploy/trialrun/install-warch3-ops-timer.sh index 1fd3af0e..de6897ae 100644 --- a/deploy/trialrun/install-warch3-ops-timer.sh +++ b/deploy/trialrun/install-warch3-ops-timer.sh @@ -4,6 +4,7 @@ set -euo pipefail REMOTE_DIR="${REMOTE_DIR:-/opt/hyb-oa-trialrun}" REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-${REMOTE_DIR}/deploy/trialrun/.env}" COMPOSE_FILE="${COMPOSE_FILE:-deploy/trialrun/docker-compose.yml}" +COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-hyb-oa-trialrun}" SERVICE_NAME="${SERVICE_NAME:-hyb-oa-warch3-ops}" RUN_AT="${RUN_AT:-07:30}" MODE="${1:-install}" @@ -37,6 +38,10 @@ install_timer() { echo "[ERROR] 远端 env 不存在: $REMOTE_ENV_FILE" >&2 exit 1 fi + if [[ ! "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then + echo "[ERROR] COMPOSE_PROJECT_NAME 格式无效" >&2 + exit 1 + fi local compose_exec if docker compose version >/dev/null 2>&1; then @@ -60,10 +65,11 @@ WorkingDirectory=${REMOTE_DIR} Environment=REMOTE_DIR=${REMOTE_DIR} Environment=REMOTE_ENV_FILE=${REMOTE_ENV_FILE} Environment=COMPOSE_FILE=${COMPOSE_FILE} +Environment=COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME} Environment=LIVE_SMOKE_BASE_URL=https://oa.hyinda.com Environment=WARCH3_SEND_ALERT=1 Environment=WARCH3_OPS_ALERT_SEND=1 -ExecStart=/bin/bash -lc 'cd "$REMOTE_DIR" && ${compose_exec} -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" exec -T server sh -lc "cd /app && LIVE_SMOKE_BASE_URL=https://oa.hyinda.com WARCH3_SEND_ALERT=1 WARCH3_OPS_ALERT_SEND=1 bash scripts/qa/run-warch3-ops-daily.sh"' +ExecStart=/bin/bash -lc 'cd "$REMOTE_DIR" && ${compose_exec} -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" exec -T server sh -lc "cd /app && LIVE_SMOKE_BASE_URL=https://oa.hyinda.com WARCH3_SEND_ALERT=1 WARCH3_OPS_ALERT_SEND=1 bash scripts/qa/run-warch3-ops-daily.sh"' SERVICE cat >"$TIMER_FILE" < additive migration -> 新应用切换”。部署脚本不会自动修改边缘配置;未人工确认四端点已关闭不得发布或回滚开放。仅在安全版本及 smoke 全部通过后人工恢复;若回滚到旧认证代码,四端点必须继续关闭。 @@ -65,10 +67,11 @@ cd /path/to/hyb-oa node deploy/trialrun/validate-env-contract.js export BUILD_SHA="" export VITE_BUILD_SHA="" -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d db -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env build init -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server +export COMPOSE_PROJECT_NAME="" +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d db +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env build init +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server ``` 以上直接 Compose 命令仅用于首次初始化或受控应急;正式发布优先使用 `deploy/trialrun/deploy.sh`,由脚本从 clean、已进入 upstream 的提交自动注入两个构建标识。缺少任一标识时 Compose 必须拒绝解析。 @@ -76,7 +79,7 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/ 可选:启用 MinIO(对象存储,F21): ```bash cd /path/to/hyb-oa -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env --profile minio up -d minio minio_init +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env --profile minio up -d minio minio_init ``` 可选:启用 Worker 进程(推荐在试运行/多进程模式使用): @@ -84,13 +87,13 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/ cd /path/to/hyb-oa # 1) 在 deploy/trialrun/.env 中设置:JOBS_ENABLED="false" # 2) 启用 worker profile(会额外启动 worker 容器) -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env --profile worker up -d --build server worker +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env --profile worker up -d --build server worker ``` ### 1.3 初始化(迁移 + 配置 seed + 试运行管理员) ```bash cd /path/to/hyb-oa -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init ``` 说明: @@ -103,12 +106,12 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/ ### 1.4 验收 ```bash -SERVER_HOST_PORT="$(docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env port server 3000)" +SERVER_HOST_PORT="$(docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env port server 3000)" SERVER_HOST_PORT="${SERVER_HOST_PORT##*:}" curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/health" curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/metrics" | head -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js ``` 说明: @@ -122,7 +125,7 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/ - 2026-05-05 公网复核显示:`OCR_PROVIDER=tencent` 可用,真实发票上传后进入 `available`,provider 为 `tencent`;后续发布不能再把 OCR 回退到 mock 而不记录原因。 浏览器访问: -- `docker-compose ... port server 3000` 返回的宿主回环端口(单域托管前端)。 +- `docker-compose -p "$COMPOSE_PROJECT_NAME" ... port server 3000` 返回的宿主回环端口(单域托管前端)。 ### 1.4.1 腾讯 OCR 试运行核查 远端 `.env` 是 OCR 配置事实源,禁止提交或复制密钥明文。只做以下脱敏检查: @@ -147,8 +150,8 @@ printf 'TENCENT_OCR_REGION=%s\n' "$TENCENT_OCR_REGION" 发布后 smoke: ```bash -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-external-deps.js -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-external-deps.js +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js ``` 真实 OCR 辅助复核(本地执行,不提交发票文件): @@ -164,7 +167,7 @@ npx playwright test tests/e2e/manual/live-invoice-ocr.spec.ts --workers=1 --repo 回滚到 mock 只允许作为应急降级: 1. 在远端 `.env` 将 `OCR_PROVIDER=tencent` 改为 `OCR_PROVIDER=mock`。 -2. 执行 `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d server`。 +2. 执行 `docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d server`。 3. 在 Issue/发布记录中写明降级原因、预计恢复时间和影响范围。 ## 1.5 备份与恢复演练(正式启用前必须完成) @@ -177,12 +180,17 @@ set -a source deploy/trialrun/.env set +a +mapfile -t DB_CONTAINERS < <(docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env ps -q db) +test "${#DB_CONTAINERS[@]}" -eq 1 +DB_CONTAINER="${DB_CONTAINERS[0]}" + DB_TOOL_MODE=docker \ -DB_CONTAINER=hyb-oa-trialrun-db \ +DB_CONTAINER="$DB_CONTAINER" \ +STORAGE_DIR_HOST="" \ ./scripts/backup-all.sh ``` -`backup-all.sh` 可使用宿主 Node.js 或 Python 3 生成同结构 manifest;Docker-only 主机没有 Node.js 时会自动使用 Python 3,两者都没有则失败且不发布完整批次。真实运行环境执行前,不得只相信可能漂移的 `.env`:先脱敏确认所选 PostgreSQL 容器的数据库/用户与至少一个活动应用连接一致,再把已验证的运行参数传给脚本;不在日志中输出参数值。 +`backup-all.sh` 可使用宿主 Node.js 或 Python 3 生成同结构 manifest;Docker-only 主机没有 Node.js 时会自动使用 Python 3,两者都没有则失败且不发布完整批次。`DB_CONTAINER` 和 `STORAGE_DIR_HOST` 均无隐式默认值:前者必须由已核验 Compose 项目的 `ps -q db` 解析,后者必须与活动 server 的附件挂载源一致。真实运行环境执行前,不得只相信可能漂移的 `.env`:先脱敏确认所选 PostgreSQL 容器的数据库/用户与至少一个活动应用连接一致,再把已验证的运行参数传给脚本;不在日志中输出参数值。 输出默认在 `deploy/trialrun/backups/hyb-oa-backup-/`,其中包含: @@ -267,12 +275,13 @@ RESTORE_SCOPE=shadow \ 步骤: 1) 先做备份(见 E14 脚本) -2) 从本地仓库执行 `DEPLOY_SERVER= REMOTE_DIR= bash deploy/trialrun/deploy.sh`(推荐);脚本只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。 +2) 从本地仓库执行 `DEPLOY_SERVER= REMOTE_DIR= COMPOSE_PROJECT_NAME= bash deploy/trialrun/deploy.sh`(推荐);脚本只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。 + - 在现网目录统一任务中,新的 `COMPOSE_PROJECT_NAME`、`POSTGRES_DATA_DIR`、`STORAGE_HOST_DIR` 必须与活动数据目录迁移在同一停机窗口启用;本地提交本身不授权切换。 - `artifacts/` 和 `output/` 是运行期证据/截图目录,不作为源码发布输入,也不由 `rsync --delete` 清理远端历史证据。 3) 构建并重启: ```bash -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init ``` ### 2.2 前端部署一致性排障 @@ -283,13 +292,13 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/ ```bash # 1) 确认 Caddy 指向 Compose 实际发布的宿主回环端口 sudo sed -n '/oa.hyinda.com/,/}/p' /etc/caddy/Caddyfile -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env port server 3000 +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env port server 3000 # 2) 比对公网首页引用的 asset hash curl -fsS https://oa.hyinda.com/ | grep -o '/assets/[^" ]*' | head -# 3) 在容器内查构建产物是否包含关键 test id -docker exec hyb-oa-trialrun-server sh -lc \ +# 3) 在当前 Compose 项目的 server 容器内查构建产物是否包含关键 test id +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server sh -lc \ 'for pat in invoice-pool-mobile-list reimbursements-mobile-list mobile-capture-file multiple:!0; do printf "%s " "$pat"; grep -Rao "$pat" /app/client/dist/assets /app/client/dist/index.html 2>/dev/null | wc -l; done' ``` @@ -297,9 +306,9 @@ docker exec hyb-oa-trialrun-server sh -lc \ ```bash cd /opt/hyb-oa-trialrun -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm -T init -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server ``` ### 3.2 需要 DB 回滚(推荐做法) 1) 停止服务: ```bash -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env stop server +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env stop server ``` 2) 按 E14 恢复 DB + 文件存储备份 3) 启动服务并验收 `/ready` 与登录 @@ -332,7 +341,7 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/ ## 4. 日志与排障 - server 容器日志: ```bash -docker logs -f hyb-oa-trialrun-server +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env logs -f server ``` - `/ready` 失败通常为 DB 不可达或连接串错误。 @@ -453,7 +462,7 @@ npm run test:e2e -- tests/e2e/manual/live-invoice-ocr-batch.spec.ts - `OPENAPI_DOCS_ENABLED=false` 3. 重新执行: ```bash -docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server +docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server ``` 4. 验证浏览器和企业微信 WebView 都能正常登录、刷新 token 与退出登录。 diff --git a/deploy/trialrun/validate-env-contract.js b/deploy/trialrun/validate-env-contract.js index f00faf4e..6e859588 100755 --- a/deploy/trialrun/validate-env-contract.js +++ b/deploy/trialrun/validate-env-contract.js @@ -4,10 +4,21 @@ const path = require('node:path'); const rootDir = path.resolve(__dirname, '..', '..'); const configPath = path.join(rootDir, 'server/src/config.js'); -const envExamplePath = path.join(rootDir, 'deploy/trialrun/.env.example'); -const composePath = path.join(rootDir, 'deploy/trialrun/docker-compose.yml'); +const envExamplePath = path.resolve( + process.env.ENV_CONTRACT_ENV_EXAMPLE_PATH || path.join(rootDir, 'deploy/trialrun/.env.example'), +); +const composePath = path.resolve( + process.env.ENV_CONTRACT_COMPOSE_PATH || path.join(rootDir, 'deploy/trialrun/docker-compose.yml'), +); const runbookPath = path.join(rootDir, 'deploy/trialrun/runbook.md'); const checklistPath = path.join(rootDir, 'deploy/trialrun/checklist.md'); +const deployScriptPath = path.resolve( + process.env.ENV_CONTRACT_DEPLOY_SCRIPT_PATH || path.join(rootDir, 'deploy/trialrun/deploy.sh'), +); +const timerScriptPath = path.resolve( + process.env.ENV_CONTRACT_TIMER_SCRIPT_PATH + || path.join(rootDir, 'deploy/trialrun/install-warch3-ops-timer.sh'), +); const nginxPaths = [ path.join(rootDir, 'deploy/trialrun/nginx-hyb-oa.conf'), path.join(rootDir, 'deploy/trialrun/nginx.conf.example'), @@ -130,6 +141,8 @@ const envExampleContent = readFile(envExamplePath); const composeContent = readFile(composePath); const runbookContent = readFile(runbookPath); const checklistContent = readFile(checklistPath); +const deployScriptContent = readFile(deployScriptPath); +const timerScriptContent = readFile(timerScriptPath); const nginxContents = nginxPaths.map((filePath) => ({ filePath, content: readFile(filePath), @@ -150,6 +163,15 @@ for (const key of deploymentInjectedEnvKeys) { } } +for (const key of ['APP_HOST_PORT', 'POSTGRES_DATA_DIR', 'STORAGE_HOST_DIR']) { + if (!envExampleKeys.has(key)) { + errors.push(`.env.example 缺少 Compose 宿主契约键 ${key}`); + } +} +if (envExampleKeys.has('COMPOSE_PROJECT_NAME')) { + errors.push('.env.example 不应持久化 COMPOSE_PROJECT_NAME,调用方必须通过 -p 显式选择项目'); +} + for (const serviceName of requiredServices) { const block = extractServiceBlock(composeContent, serviceName); if (!block) { @@ -189,6 +211,38 @@ if (!serverBlock || !/^\s{6}-\s*["']127\.0\.0\.1:\$\{APP_HOST_PORT:-3000\}:3000[ errors.push('服务 server 必须通过 APP_HOST_PORT 将容器 3000 端口只绑定到宿主回环地址'); } +const dbBlock = extractServiceBlock(composeContent, 'db'); +if (!dbBlock) { + errors.push('docker-compose.yml 缺少服务 db'); +} else { + if (/^\s{4}ports:\s*$/m.test(dbBlock)) { + errors.push('服务 db 不得向宿主发布端口'); + } + if (!/\$\{POSTGRES_DATA_DIR:-\.\/data\/postgres\}:\/var\/lib\/postgresql\/data/.test(dbBlock)) { + errors.push('服务 db 必须通过 POSTGRES_DATA_DIR 参数化宿主数据目录'); + } +} + +if (/^\s+container_name:\s*/m.test(composeContent)) { + errors.push('docker-compose.yml 不得固定 container_name,容器身份必须由 Compose project 隔离'); +} + +for (const serviceName of ['server', 'worker', 'init']) { + const block = extractServiceBlock(composeContent, serviceName); + if (block && !/\$\{STORAGE_HOST_DIR:-\.\/data\/storage\}:\$\{STORAGE_DIR\}/.test(block)) { + errors.push(`服务 ${serviceName} 必须通过 STORAGE_HOST_DIR 参数化宿主附件目录`); + } +} + +for (const [name, content] of [ + ['deploy.sh', deployScriptContent], + ['install-warch3-ops-timer.sh', timerScriptContent], +]) { + if (!content.includes('-p "$COMPOSE_PROJECT_NAME"')) { + errors.push(`${name} 必须向 Compose 显式传入独立 project name`); + } +} + for (const { filePath, content } of nginxContents) { const relativePath = path.relative(rootDir, filePath); if (content.includes('$proxy_add_x_forwarded_for')) { diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh index 337cd409..2161f8cd 100755 --- a/scripts/backup-db.sh +++ b/scripts/backup-db.sh @@ -56,11 +56,16 @@ if ! command -v docker >/dev/null 2>&1; then exit 1 fi -DB_CONTAINER="${DB_CONTAINER:-hyb-oa-trialrun-db}" +DB_CONTAINER="${DB_CONTAINER:-}" POSTGRES_DB="${POSTGRES_DB:-}" POSTGRES_USER="${POSTGRES_USER:-}" POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" +if [[ -z "$DB_CONTAINER" ]]; then + echo "[ERR] Using docker fallback requires explicit DB_CONTAINER" >&2 + exit 1 +fi + if [[ -z "$POSTGRES_DB" || -z "$POSTGRES_USER" || -z "$POSTGRES_PASSWORD" ]]; then echo "[ERR] Using docker fallback requires POSTGRES_DB/POSTGRES_USER/POSTGRES_PASSWORD" >&2 exit 1 diff --git a/scripts/backup-storage.sh b/scripts/backup-storage.sh index 2bcbc5b7..44aa1457 100755 --- a/scripts/backup-storage.sh +++ b/scripts/backup-storage.sh @@ -16,14 +16,8 @@ fi STORAGE_DIR_HOST="${STORAGE_DIR_HOST:-}" if [[ -z "$STORAGE_DIR_HOST" ]]; then - if [[ -n "${STORAGE_DIR:-}" && -d "${STORAGE_DIR:-}" ]]; then - STORAGE_DIR_HOST="$STORAGE_DIR" - elif [[ -d "$ROOT_DIR/deploy/trialrun/data/storage" ]]; then - STORAGE_DIR_HOST="$ROOT_DIR/deploy/trialrun/data/storage" - else - echo "[ERR] storage dir not found; set STORAGE_DIR_HOST" >&2 - exit 1 - fi + echo "[ERR] STORAGE_DIR_HOST is required; implicit storage paths are forbidden" >&2 + exit 1 fi if [[ ! -d "$STORAGE_DIR_HOST" ]]; then diff --git a/scripts/qa/run-backup-restore-drill.sh b/scripts/qa/run-backup-restore-drill.sh index 73ef758c..4a0fd4d9 100755 --- a/scripts/qa/run-backup-restore-drill.sh +++ b/scripts/qa/run-backup-restore-drill.sh @@ -41,6 +41,50 @@ mkdir -p "$SOURCE_STORAGE/nested" "$NONEMPTY_STORAGE" "$BACKUP_ROOT" printf 'synthetic attachment for restore drill\n' >"$SOURCE_STORAGE/nested/marker.txt" printf 'must not be overwritten\n' >"$NONEMPTY_STORAGE/existing.txt" +if env -u STORAGE_DIR_HOST -u STORAGE_DIR \ + OUT_FILE="$DRILL_DIR/implicit-storage.tar.gz" \ + "$ROOT_DIR/scripts/backup-storage.sh" >/dev/null 2>&1; then + echo "[ERR] implicit storage backup source was accepted" >&2 + exit 1 +fi + +if env -u DB_CONTAINER DB_TOOL_MODE=docker DATABASE_URL=postgresql://invalid \ + POSTGRES_DB=invalid POSTGRES_USER=invalid POSTGRES_PASSWORD=invalid \ + OUT_FILE="$DRILL_DIR/implicit-db.dump" \ + "$ROOT_DIR/scripts/backup-db.sh" >/dev/null 2>&1; then + echo "[ERR] implicit DB container was accepted" >&2 + exit 1 +fi + +if command -v node >/dev/null 2>&1; then + MUTATED_PORT_COMPOSE="$DRILL_DIR/docker-compose-db-port.yml" + sed '/ db:/a\ ports:\n - "5432:5432"' \ + "$ROOT_DIR/deploy/trialrun/docker-compose.yml" >"$MUTATED_PORT_COMPOSE" + if ENV_CONTRACT_COMPOSE_PATH="$MUTATED_PORT_COMPOSE" \ + node "$ROOT_DIR/deploy/trialrun/validate-env-contract.js" >/dev/null 2>&1; then + echo "[ERR] unsafe DB port publication passed the contract" >&2 + exit 1 + fi + + MUTATED_NAME_COMPOSE="$DRILL_DIR/docker-compose-fixed-name.yml" + sed '/ db:/a\ container_name: forbidden-fixed-name' \ + "$ROOT_DIR/deploy/trialrun/docker-compose.yml" >"$MUTATED_NAME_COMPOSE" + if ENV_CONTRACT_COMPOSE_PATH="$MUTATED_NAME_COMPOSE" \ + node "$ROOT_DIR/deploy/trialrun/validate-env-contract.js" >/dev/null 2>&1; then + echo "[ERR] fixed Compose container name passed the contract" >&2 + exit 1 + fi + + MUTATED_DEPLOY="$DRILL_DIR/deploy-without-project.sh" + sed 's/-p "$COMPOSE_PROJECT_NAME" //g' \ + "$ROOT_DIR/deploy/trialrun/deploy.sh" >"$MUTATED_DEPLOY" + if ENV_CONTRACT_DEPLOY_SCRIPT_PATH="$MUTATED_DEPLOY" \ + node "$ROOT_DIR/deploy/trialrun/validate-env-contract.js" >/dev/null 2>&1; then + echo "[ERR] deployment without an explicit Compose project passed the contract" >&2 + exit 1 + fi +fi + docker run -d --rm --name "$CONTAINER_NAME" \ -e "POSTGRES_USER=$POSTGRES_USER" \ -e "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" \ diff --git a/scripts/restore-db.sh b/scripts/restore-db.sh index 3369ac30..dffee242 100755 --- a/scripts/restore-db.sh +++ b/scripts/restore-db.sh @@ -83,11 +83,16 @@ if ! command -v docker >/dev/null 2>&1; then exit 1 fi -DB_CONTAINER="${DB_CONTAINER:-hyb-oa-trialrun-db}" +DB_CONTAINER="${DB_CONTAINER:-}" POSTGRES_DB="${POSTGRES_DB:-}" POSTGRES_USER="${POSTGRES_USER:-}" POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" +if [[ -z "$DB_CONTAINER" ]]; then + echo "[ERR] Using docker fallback requires explicit DB_CONTAINER" >&2 + exit 1 +fi + if [[ -z "$POSTGRES_DB" || -z "$POSTGRES_USER" || -z "$POSTGRES_PASSWORD" ]]; then echo "[ERR] Using docker fallback requires POSTGRES_DB/POSTGRES_USER/POSTGRES_PASSWORD" >&2 exit 1