diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index 83e5683e..3889c4e8 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -7,8 +7,7 @@ operations require explicit approval and evidence. ## Rules -- Use `SYNC_ENV=0 bash deploy/trialrun/deploy.sh` only when deployment is - approved; do not overwrite remote `.env`. +- Use `SYNC_ENV=0 POSTGRES_DATA_DIR= STORAGE_HOST_DIR= DEPLOY_DATA_MODE= bash deploy/trialrun/deploy.sh` only when deployment is approved; do not overwrite remote `.env`. - When `server/src/config.js` reads a new environment key, add the same key to `deploy/trialrun/.env.example` before deployment; `deploy/trialrun/deploy.sh` runs `node deploy/trialrun/validate-env-contract.js` at step 0 and must fail @@ -31,7 +30,8 @@ operations require explicit approval and evidence. ### 2. Signatures - Command: `node deploy/trialrun/validate-env-contract.js` -- Deployment entry: `SYNC_ENV=0 bash deploy/trialrun/deploy.sh` +- Deployment entry requires `SYNC_ENV=0`, absolute `POSTGRES_DATA_DIR`, absolute + `STORAGE_HOST_DIR` and `DEPLOY_DATA_MODE=existing|initialize`. - Contract sources: - `server/src/config.js` - `deploy/trialrun/.env.example` @@ -47,6 +47,10 @@ operations require explicit approval and evidence. allowlist in `validate-env-contract.js`. - `SYNC_ENV=0` is the default public deploy mode; it does not overwrite remote `.env`. +- Relative data-path defaults are allowed only for direct local Compose. + `deploy.sh` requires explicit absolute host paths and a data mode; `existing` + verifies the PGDATA directory contains `PG_VERSION`, while `initialize` is + the only mode allowed to create missing directories. ### 4. Validation & Error Matrix @@ -54,6 +58,10 @@ operations require explicit approval and evidence. - Service omits `env_file: ./.env` -> contract exits non-zero. - Unauthorized compose override -> contract exits non-zero. - Remote `.env` missing -> deploy step 4 exits before migration. +- Missing/relative/unsafe/overlapping data paths or missing data mode -> deploy + exits locally before provenance, synchronization or SSH. +- `existing` target missing `PG_VERSION` or storage directory -> remote deploy + exits before Compose starts DB. ### 5. Good/Base/Bad Cases @@ -83,7 +91,9 @@ SYNC_ENV=0 bash deploy/trialrun/deploy.sh ```bash # Add the key to deploy/trialrun/.env.example first. node deploy/trialrun/validate-env-contract.js -SYNC_ENV=0 bash deploy/trialrun/deploy.sh +SYNC_ENV=0 POSTGRES_DATA_DIR=/srv/hyb-oa/pgdata \ + STORAGE_HOST_DIR=/srv/hyb-oa/storage DEPLOY_DATA_MODE=existing \ + bash deploy/trialrun/deploy.sh ``` ## Scenario: Public Smoke Navigation Labels @@ -279,7 +289,8 @@ const REQUIRED_ADMIN_NAV_KEYS = ["systemSettings"]; ### 2. Signatures - Provenance gate: `node deploy/trialrun/check-source-provenance.mjs`. -- Standard release: `SYNC_ENV=0 bash deploy/trialrun/deploy.sh`. +- Standard release additionally supplies the explicit absolute data-path and + data-mode contract required by `deploy.sh`. - Build/runtime inputs: `BUILD_SHA=` and `VITE_BUILD_SHA=<12-char-commit>`. - Runtime outputs: `/health.data.buildSha`, `/ready.data.buildSha` and the @@ -606,6 +617,8 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" - 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}`. +- Formal deploy inputs: absolute `POSTGRES_DATA_DIR`, absolute + `STORAGE_HOST_DIR`, `DEPLOY_DATA_MODE=existing|initialize`. - DB has no host `ports`; server remains loopback-only. ### 3. Contracts @@ -615,11 +628,22 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" - 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. +- Freeze the old project name from `docker compose ls`, not from a directory + guess. Record immutable image IDs/digests, successful migration count, fixed + business-table counts, resolved mounts and Caddy target before any write. - 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. + use explicit absolute command inputs and verify them against active container + mounts before any start, backup or migration. +- `existing` is the normal migration mode and refuses a missing PGDATA marker + or storage directory. `initialize` is only for an approved empty first start; + only that mode may create data directories. - 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. +- For an existing cutover, stop the old PostgreSQL completely, copy PGDATA and + storage into pre-verified empty absolute targets while preserving ownership, + permissions, links and extended attributes, and retain the old directories + untouched and unmounted for the observation period. Do not rename away the + only pre-migration rollback data and do not copy a running PGDATA. ### 4. Validation & Error Matrix @@ -627,8 +651,12 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" - Any fixed `container_name` appears -> environment contract fails. - Required data mount loses its parameter -> environment contract fails. - Deploy/timer omits `-p` -> environment contract fails. +- Formal deploy omits paths/mode, uses a relative/broad/overlapping path, or an + `existing` PGDATA lacks `PG_VERSION` -> fail before DB start. - New project resolves the same PGDATA while the old DB runs -> stop before Compose start; do not use service health as permission to continue. +- Old project name was inferred, immutable image ID or baseline counts are + missing, or target copy is non-empty -> stop the cutover. ### 5. Tests Required @@ -641,9 +669,12 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" - 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. +- Base: direct local Compose uses the documented relative defaults under one + project; those defaults are never accepted by formal `deploy.sh`. - 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. +- Bad: move the only PGDATA into the new path, migrate it in place, then assume + the old container name alone is a rollback point. ### 7. Wrong vs Correct @@ -677,7 +708,7 @@ DB_TOOL_MODE=docker DB_CONTAINER="${DB_CONTAINERS[0]}" \ ### 2. Signatures -- Paired backup: `DB_TOOL_MODE=docker DB_CONTAINER= STORAGE_DIR_HOST= ./scripts/backup-all.sh`. +- Paired backup: `DB_TOOL_MODE=docker DB_CONTAINER= STORAGE_DIR_HOST= BUILD_SHA= SOURCE_IMAGE_DIGEST= ./scripts/backup-all.sh`; at least one source identity is required. - Optional safe batch identifier: `BACKUP_RUN_ID=`. - Manifest writer: host `node` is preferred; host `python3` is an equivalent fallback for Docker-only runtime machines. @@ -693,7 +724,11 @@ DB_TOOL_MODE=docker DB_CONTAINER="${DB_CONTAINERS[0]}" \ - A complete recovery point is one batch directory containing `database.dump`, `storage.tar.gz` and a final `manifest.json`. The manifest contains only safe - relative names, byte sizes, SHA-256 hashes, batch time and optional build SHA. + relative names, byte sizes, SHA-256 hashes, batch time, actual build SHA when + known and actual source image digest when supplied. +- `BUILD_SHA` supplied by the caller takes precedence over local Git fallback. + On a non-Git runtime host, at least `SOURCE_IMAGE_DIGEST=sha256:<64-hex>` is + mandatory. Never label a legacy backup with the commit planned for deployment. - Manifest generation uses exclusive creation and preserves the same schema under Node and Python 3; if neither runtime exists, fail before publishing the final batch directory. @@ -718,6 +753,10 @@ DB_TOOL_MODE=docker DB_CONTAINER="${DB_CONTAINERS[0]}" \ people, rollback point and post-restore `/ready`/permission checks. - Copy the whole completed batch to approved off-host/off-disk storage; a batch left only on the application host is not accepted recovery evidence. +- A real-format shadow restore compares successful migration count and a fixed + set of business-table counts with the pre-backup baseline, compares extracted + regular-file count with the active storage source, and runs `/ready` using a + schema-compatible image. `/ready` alone is not recovery evidence. ### 4. Validation & Error Matrix @@ -735,9 +774,14 @@ DB_TOOL_MODE=docker DB_CONTAINER="${DB_CONTAINERS[0]}" \ only when the explicit target container and required DB variables exist. - Neither Node nor Python 3 is available -> fail manifest generation and remove the temporary batch; do not publish a partial recovery point. +- Invalid build SHA/image digest, or neither identity available on a non-Git + host -> fail before DB or storage backup starts. - Deployment `.env`, PostgreSQL container and active application connection do not identify the same database/user -> stop the live backup; never guess credentials or expose values in diagnostics. +- Shadow migration count, any fixed table count, attachment regular-file count + or compatible-image `/ready` differs from baseline -> reject the recovery + point and do not enter production cutover. ### 5. Good/Base/Bad Cases @@ -745,6 +789,8 @@ DB_TOOL_MODE=docker DB_CONTAINER="${DB_CONTAINERS[0]}" \ host, and a disposable shadow drill restores the DB marker and attachment hash. - Good: a Docker-only host uses Python 3 to write the same manifest contract after the active application connection is matched to the database container. +- Good: a legacy container without `/ready.data.buildSha` records its inspected + `sha256:` image ID and leaves `buildSha` null rather than inventing provenance. - Base: backup succeeds locally but off-host copy and deployed `/ready` evidence remain explicitly pending; do not claim formal recovery readiness. - Bad: source a stale deployment `.env` and treat a failed dump as a prompt to @@ -759,7 +805,7 @@ DB_TOOL_MODE=docker DB_CONTAINER="${DB_CONTAINERS[0]}" \ - Negative assertions for wrong DB confirmation, corrupt DB/tar, non-empty target and dangerous tar path. - Validate manifest relative paths, absence of connection secrets and matching - SHA-256 values. + SHA-256 values, explicit build-SHA precedence and image-digest preservation. - Run the synthetic drill with Node available and with a `PATH` that excludes Node but includes Python 3; both paths must complete the same round trip. - Real-instance restore and `/ready` remain separately approved evidence and @@ -783,6 +829,7 @@ source deploy/trialrun/.env # 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= \ - STORAGE_DIR_HOST= ./scripts/backup-all.sh + STORAGE_DIR_HOST= \ + SOURCE_IMAGE_DIGEST= ./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 07212e7d..c3e3823a 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md @@ -21,7 +21,7 @@ isolated drill - 一个批次只使用一个 `run_id`,由上层入口传递明确的 `OUT_FILE` 给现有底层脚本。 - 先把产物写入批次临时目录;DB 和附件均成功并通过完整性检查后,再写 manifest 并把批次标记为完整。 -- manifest 使用稳定、脱敏的键:`schemaVersion`、`runId`、`createdAt`、`buildSha`(如可安全取得)、两份相对文件名、字节数和 SHA-256。 +- manifest 使用稳定、脱敏的键:`schemaVersion`、`runId`、`createdAt`、`buildSha`(真实可知时)、`sourceImageDigest`(实测可知时)、两份相对文件名、字节数和 SHA-256;两类来源标识至少有一项。 - 不把连接串、容器密码、存储源路径、真实业务文件清单或主机绝对路径写入 manifest。 - 本任务不负责异机传输实现;runbook 要求操作者把完整批次复制到异机/异盘,并记录保留周期。 @@ -121,11 +121,14 @@ GET /auth/wecom/login?mode=onboard - DB 不发布宿主端口;应用仅通过 Compose 内部网络访问 `db`。环境契约必须拒绝 DB `ports`、固定容器名或未参数化数据目录回退。 - Docker 备份/恢复必须显式传入由 `compose ... ps -q db` 得到的已核验容器 ID;附件备份必须显式传入活动挂载源,两个脚本都不猜默认值。 - 远端 `/ready` 构建标识在 server 容器内用 Node 校验,宿主机只负责 curl 和 Compose,不要求安装 Node。 +- `deploy.sh` 不解析或 source 远端 `.env` 来猜宿主路径;调用者必须在发布命令中显式传入受限字符集的绝对 `POSTGRES_DATA_DIR`、`STORAGE_HOST_DIR` 和数据模式。`existing` 要求目录已存在且 PGDATA 含 `PG_VERSION`;`initialize` 才允许创建空目标。 +- 本地直接 Compose 可继续使用示例相对默认值,但正式发布入口拒绝相对路径,从而避免项目名变化时 Docker 以 root 静默创建第二套空目录。 +- manifest 的 `buildSha` 优先使用调用者显式值,否则仅在本地 Git 工作树存在时推导;另以可选 `sourceImageDigest` 记录活动镜像不可变标识。旧实例 build SHA 不可知时只记录实测 digest,不填写目标提交。 ### 生产统一顺序 1. 另行获批后,从活动 DB 和活动附件挂载源重新生成成对异机备份作为灾难回滚点;旧批次因混入非活动附件目录只保留审计,并记录活动数据挂载、环境摘要、容器镜像和 Caddy 端口。 2. 本地先修复 Compose 可配置回环端口及部署脚本动态端口探测,通过全部门禁并提交、发布批准提交。 -3. 停机后把非活动的既定部署目录改名保留,将活动数据目录和环境文件迁入既定目录;同一文件系统优先原子改名,不复制运行中的 PostgreSQL 文件。 +3. 停机后把活动 PGDATA 和附件复制到新的显式绝对目录,复制前目标必须为空;保留旧目录原位且不再挂载写入,直到观察期结束。复制仅在旧 PostgreSQL 完全停止后执行,并保留所有权、权限、链接和扩展属性。 4. 用新 Compose 重建 db/init/server,应用已提交 migrations;旧容器和旧目录在验收前保留,不清理。 5. 公网构建标识、数据计数、角色、企微入口和关键只读检查通过后更新运维 timer;失败则停止新容器并恢复旧目录/容器和原 Caddy 目标。 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 4729df0f..bc9baca8 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md @@ -22,6 +22,9 @@ - [ ] 修复部署脚本的现场端口解析,确定唯一部署目录,并在活动 DB/附件挂载、环境摘要和旧镜像回滚点全部确认后才执行获批切换。 - [x] 完成本地 Compose/备份加固:移除固定容器名和 DB 宿主端口,参数化数据目录,deploy/timer 显式 project,备份来源缺失硬失败,远端构建标识改由容器内 Node 核验。 - [x] 增加隐式附件源、隐式 DB 容器、DB 端口与固定容器名回退的负向契约演练;未部署或启用新 Compose。 +- [x] 收紧正式 deploy 数据路径:显式绝对路径、`existing|initialize` 模式、既有 PGDATA 标记和禁止 Docker 静默建空目录。 +- [x] 让成对 manifest 尊重显式 `BUILD_SHA` 并支持活动镜像 digest;补 Node/Python 等价与非法来源标识负向测试。 +- [x] 把旧 Compose project 实测、镜像 digest、58 项迁移与关键表计数、影子逐表/附件计数验收、停机后复制并保留旧目录写入 runbook/checklist/spec。 - [ ] 部署后验证唯一 Compose 来源、构建标识、迁移状态、Caddy 公网 `/ready` 和前端 marker;不因服务可访问而忽略来源漂移。 - [ ] 用户在企微应用详情选择手机、邮箱、企业邮箱敏感字段;部署开关开启后,两名目标分别通过一次性链接本人确认授权。 - [ ] 两人自助开通成功后写入获批 `finance` / `ceo` 职责,复核不同身份、maker-checker、会话撤销、审计和付款零写入。 @@ -63,6 +66,7 @@ - 合成备份恢复在 Node 路径和移除 Node 的 Python 3 回退路径均通过,最近两次本地参考 RTO 均为 6 秒;负向覆盖隐式附件源、隐式 DB 容器、DB 宿主端口、固定容器名和 deploy 缺失 project name。 - 本阶段授权仅覆盖本地文件、测试、规约和提交,不包含生产备份、停机、迁移、部署、timer 安装或企微写入。 - 已确认旧异机批次混用了活动 DB 与非活动附件目录,保留审计但不再作为活动实例成对恢复证据;正确批次须另行批准重做。 +- 复核补修后,正式 deploy 的相对/重叠路径负向门禁、显式来源标识、Node 路径以及无 Node/无 Git 的 Python 3 + digest-only 路径均通过;最近两次合成 RTO 均为 7 秒。 ## 回滚点 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 0ee67638..e846161f 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md @@ -44,6 +44,8 @@ - 普通 `snsapi_base` 登录、已绑定账号登录和现有绑定流程保持兼容,不因资料授权功能改成每次手工确认。 - 生产统一以仓库既定部署目录和 Compose 契约为目标,但活动 PostgreSQL 与附件数据是唯一数据事实源;停机切换不得复制运行中的 PostgreSQL 文件、启动第二套空库或覆盖环境变量。 - Compose 项目名、容器身份与 DB/附件目录必须在同一次获批停机窗口切换;DB 不发布宿主端口,备份不得猜测容器名或附件目录。 +- 正式 `deploy.sh` 必须由调用者显式提供绝对 `POSTGRES_DATA_DIR`、`STORAGE_HOST_DIR` 和 `DEPLOY_DATA_MODE=existing|initialize`;既有实例默认只接受包含 `PG_VERSION` 的现存 PGDATA,不得让 Docker 静默创建空目录。 +- 成对备份必须尊重调用者显式提供的真实来源标识。当前旧实例没有 build SHA 时记录活动镜像不可变 digest,不得用待部署提交冒充备份来源。 - 部署健康检查必须从 Compose 解析实际宿主端口,并校验后端 `/ready`、前端 marker 与批准提交一致;应用已提交的 Prisma migrations 但禁止 reset 或改写历史。 ## Acceptance Criteria @@ -62,6 +64,7 @@ - [ ] 一次性授权通过 `getuserdetail` 获取成员同意的手机和邮箱,自动创建或补齐 OA 员工与企微绑定;新建账号角色严格等于 `employee`,日志和审计不含票据或完整敏感响应。 - [ ] 部署目录、Compose、环境文件、运维 timer 和活动容器来源统一;活动 DB/附件连续,远端 migrations 与批准提交一致,公网 `/ready` 和前端 marker 返回同一短构建标识。 - [x] 本地 Compose、部署、timer 和备份合同完成加固并覆盖负向回退;未把本地通过误报为生产切换完成。 +- [x] 部署入口拒绝隐式/相对数据目录及缺少数据模式,成对 manifest 可记录显式 build SHA 或活动镜像 digest,并有 Node/Python 等价测试。 - [ ] 两名目标成员分别完成一次本人授权后,账号唯一、企微绑定不同,再由管理员写入 `employee + finance` 与 `employee + ceo`;maker-checker、会话撤销和角色审计通过。 ## Out of Scope 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 7d89ca8a..e725a1c9 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 @@ -91,3 +91,11 @@ - 企微官方文档复核: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`;因此现状是项目尚未接入官方敏感信息授权链路,而不是企微没有该能力。 - 两名目标成员的脱敏结果为匹配计数 `[1,1]`、身份不同、均 active、数据库匹配 `[0,0]`、计划动作均为 create;两人均缺 `phone` 与 `email`,因此没有员工、角色、会话、付款或其他业务写入。 + +## 2026-07-27 复核后的执行授权边界 + +- 外部复核确认提交 `2334203` 的 Compose、端口、容器名、备份来源硬失败和负向契约方向正确,同时发现正式 deploy 仍会创建默认目录、相对默认值缺少生产护栏、非 Git 主机 manifest 无来源标识三项本地缺口。 +- 已批准在补齐上述缺口后执行:发布批准提交、生产只读冻结、从活动 DB/附件重做异机成对备份、在隔离影子目标恢复并验收。该授权不包含停机、生产数据目录复制、迁移、新 Compose 启动、Caddy/timer 修改、企微写入或角色写入。 +- 冻结证据必须包含实测旧 Compose project、不可变镜像 ID/digest、成功 migration 数和固定关键表计数;影子恢复必须逐表比对计数、比对附件普通文件数并运行 schema 兼容镜像 `/ready`。 +- 当前旧实例没有 build SHA,重做备份不得写入待部署提交。manifest 记录活动容器的实测 `sha256:` 镜像 ID;新版本部署后的备份才能记录其真实 build SHA。 +- 后续停机预案采用“旧 PostgreSQL 完全停止后复制到空新目录,旧目录原位保留且不再写入”,不把原子改名后的唯一数据副本当作回滚点。 diff --git a/deploy/trialrun/.env.example b/deploy/trialrun/.env.example index 5f64fb72..0af11ad7 100644 --- a/deploy/trialrun/.env.example +++ b/deploy/trialrun/.env.example @@ -99,7 +99,7 @@ IM_NOTIFIER_ENABLED=true # ===== 数据库(Compose 内部连接) ===== # Compose 项目名由 deploy/timer 命令通过 -p 显式传入,不写入本文件。 -# 数据目录默认相对 deploy/trialrun/;生产切换时必须与项目名在同一停机窗口确认和启用。 +# 以下相对目录仅供本地直接 Compose;正式 deploy.sh 必须另行显式传入绝对路径和 DEPLOY_DATA_MODE。 POSTGRES_DATA_DIR=./data/postgres POSTGRES_DB= POSTGRES_USER= diff --git a/deploy/trialrun/checklist.md b/deploy/trialrun/checklist.md index 69927554..c1354b65 100644 --- a/deploy/trialrun/checklist.md +++ b/deploy/trialrun/checklist.md @@ -16,7 +16,7 @@ - [ ] 已将 OA `Caddyfile` 站点块配置到 `/etc/caddy/Caddyfile` - [ ] 已确认 Caddy 反代到 `.env` 中 `APP_HOST_PORT` 对应的宿主回环端口,并用 `header_up X-Forwarded-For {remote_host}` 覆盖客户端 XFF - [ ] 已执行 `systemctl reload caddy` -- [ ] 已显式提供 `DEPLOY_SERVER`、`REMOTE_DIR`,并从本地执行 `bash scripts/qa/run-release-gate.sh all` 或至少执行 `bash deploy/trialrun/deploy.sh` +- [ ] 已显式提供 `DEPLOY_SERVER`、`REMOTE_DIR`、绝对 `POSTGRES_DATA_DIR`、绝对 `STORAGE_HOST_DIR` 和 `DEPLOY_DATA_MODE`,并从本地执行发布入口 - [ ] 已通过 `https:///ready` 或首页登录完成最小探活 - [ ] 首次上线先执行系统初始化(migration + seed),不要直接导入旧库整库备份 - [ ] 若要导数据,仅准备最小主数据:部门、员工、角色、在用资产、活跃项目、合同元数据 @@ -26,8 +26,12 @@ ## A. 发布前(Pre-flight) - [ ] 已冻结唯一 `COMPOSE_PROJECT_NAME`,所有 deploy、timer、runbook 命令均显式使用 `-p`,未依赖目录名或固定容器名 +- [ ] 已用 `docker compose ls` 实测旧 Compose project;停止旧栈命令使用旧 project,新栈使用批准的新 project - [ ] 已核对 `POSTGRES_DATA_DIR` 与 `STORAGE_HOST_DIR` 指向活动数据;项目名与数据目录只在同一次获批停机窗口切换 +- [ ] 正式 deploy 使用绝对数据路径;既有实例使用 `DEPLOY_DATA_MODE=existing`,PGDATA 已存在且含 `PG_VERSION`,附件目录已存在 - [ ] 已证明没有两个 PostgreSQL 实例同时挂载同一 PGDATA;任何不一致均停止发布 +- [ ] 已记录活动 server/db 容器、不可变镜像 ID(及可用 repo digest)、Caddy 目标、挂载源、环境摘要,不记录 env 正文 +- [ ] 已冻结成功 migration 数量(当前预期 58)和关键业务表逐表计数基线 - [ ] 确认当前版本已打 tag:`git tag -a trialrun-YYYYMMDD-NN -m "trialrun release"` - [ ] 本地门禁:`./scripts/check-minimal.sh` - [ ] 部署契约校验:`node deploy/trialrun/validate-env-contract.js` @@ -36,17 +40,21 @@ - [ ] 全量回归(建议):`cd server && RUN_AUDIT_REGRESSION=1 RUN_OCR_REGRESSION=1 ./scripts/regression-all.sh` - [ ] 备份(DB + 文件存储): - [ ] `set -a && source deploy/trialrun/.env && set +a` - - [ ] `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` + - [ ] `mapfile` 读取 `docker-compose -p "$COMPOSE_PROJECT_NAME" ... ps -q db` 后,容器数量严格等于 1 + - [ ] `DB_TOOL_MODE=docker DB_CONTAINER="$DB_CONTAINER" STORAGE_DIR_HOST= SOURCE_IMAGE_DIGEST= ./scripts/backup-all.sh` - [ ] `DB_CONTAINER` 或 `STORAGE_DIR_HOST` 缺失时备份硬失败,没有回退到历史容器名或仓库默认附件目录 + - [ ] manifest 至少含真实 `buildSha` 或活动 `sourceImageDigest`;旧实例无 build SHA 时未填写待部署提交 - [ ] 同一批次包含 `database.dump`、`storage.tar.gz`、`manifest.json`,两份 SHA-256 校验通过 - [ ] 完整批次已复制到获批异机/异盘,并记录保留周期 + - [ ] 同批次已在隔离影子目标恢复:migration 数、关键业务表逐表计数、附件普通文件数均与冻结基线一致,兼容镜像 `/ready` 通过 +- [ ] 停机复制方案已冻结:旧 PostgreSQL 完全停止后复制到空新目录并保留所有权/权限/链接/扩展属性;旧目录原位保留且观察期内不再写入 ## B. 发布(Deploy) - [ ] 本地工作树 clean,目标 commit 已进入 upstream;部署日志中的 commit 与 `/ready.data.buildSha` 一致 - [ ] **EXTERNAL_GATED**:操作人已在边缘暂停 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify`;部署脚本不会自动修改边缘配置,未人工确认四端点关闭不得发布,配置预检、迁移、新应用和 smoke 全部通过后才人工恢复 - [ ] 标准路径已成功刷新 upstream;若因受控离线恢复使用 unpublished override,日志包含批准原因码和 `UNPUBLISHED-OVERRIDE` 标识 - [ ] 标准发布通过 `bash deploy/trialrun/deploy.sh` 执行;脚本自动注入完整后端 SHA 与 12 位前端 SHA,并完成构建、init 与 smoke +- [ ] 发布命令显式携带批准的新 project、两个绝对数据路径和 `DEPLOY_DATA_MODE=existing`;未依赖 `.env.example` 相对默认值 - [ ] 若为首次初始化或受控应急而直接使用 Compose,已显式设置 `BUILD_SHA=` 与 `VITE_BUILD_SHA=`;未使用 `unknown` - [ ] 探活: - [ ] 已用 `docker-compose -p "$COMPOSE_PROJECT_NAME" ... port server 3000` 解析实际宿主端口,并对该端口执行 `/ready`,未依赖当前 shell 中可能缺失的 `APP_HOST_PORT` diff --git a/deploy/trialrun/deploy.sh b/deploy/trialrun/deploy.sh index 9cadfb07..d8291cff 100755 --- a/deploy/trialrun/deploy.sh +++ b/deploy/trialrun/deploy.sh @@ -9,6 +9,9 @@ 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}" +POSTGRES_DATA_DIR="${POSTGRES_DATA_DIR:-}" +STORAGE_HOST_DIR="${STORAGE_HOST_DIR:-}" +DEPLOY_DATA_MODE="${DEPLOY_DATA_MODE:-}" ENABLE_WORKER="${ENABLE_WORKER:-0}" ENABLE_MINIO="${ENABLE_MINIO:-0}" SYNC_ENV="${SYNC_ENV:-0}" @@ -53,6 +56,33 @@ if [[ ! "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then exit 1 fi +validate_data_path() { + local name="$1" + local value="$2" + if [[ -z "$value" || "$value" != /* || ! "$value" =~ ^/[A-Za-z0-9._/-]+$ ]]; then + echo "[ERROR] $name 必须显式提供受限字符集的绝对路径" >&2 + exit 1 + fi + if [[ "$value" == "/" || "$value" == */ || "$value" == *"//"* \ + || "/$value/" == *"/../"* || "/$value/" == *"/./"* ]]; then + echo "[ERROR] $name 不得是根目录、尾随斜杠或包含空、.、.. 路径段" >&2 + exit 1 + fi +} + +validate_data_path POSTGRES_DATA_DIR "$POSTGRES_DATA_DIR" +validate_data_path STORAGE_HOST_DIR "$STORAGE_HOST_DIR" +if [[ "$POSTGRES_DATA_DIR" == "$STORAGE_HOST_DIR" \ + || "$POSTGRES_DATA_DIR" == "$STORAGE_HOST_DIR"/* \ + || "$STORAGE_HOST_DIR" == "$POSTGRES_DATA_DIR"/* ]]; then + echo "[ERROR] PostgreSQL 与附件宿主目录必须相互独立" >&2 + exit 1 +fi +if [[ "$DEPLOY_DATA_MODE" != "existing" && "$DEPLOY_DATA_MODE" != "initialize" ]]; then + echo "[ERROR] DEPLOY_DATA_MODE 必须显式设为 existing 或 initialize" >&2 + exit 1 +fi + if [[ ! -f "${ROOT_DIR}/${PROVENANCE_SCRIPT}" ]]; then echo "[ERROR] 缺少 ${PROVENANCE_SCRIPT}" >&2 exit 1 @@ -143,10 +173,25 @@ fi echo ">>> 6. 在服务器执行部署与 smoke" ssh "${DEPLOY_SERVER}" \ - "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' + "REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' COMPOSE_PROJECT_NAME='${COMPOSE_PROJECT_NAME}' POSTGRES_DATA_DIR='${POSTGRES_DATA_DIR}' STORAGE_HOST_DIR='${STORAGE_HOST_DIR}' DEPLOY_DATA_MODE='${DEPLOY_DATA_MODE}' 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 + +if [[ "$DEPLOY_DATA_MODE" == "initialize" ]]; then + mkdir -p "$POSTGRES_DATA_DIR" "$STORAGE_HOST_DIR" +else + if [[ ! -d "$POSTGRES_DATA_DIR" || ! -f "$POSTGRES_DATA_DIR/PG_VERSION" ]]; then + echo "[ERROR] existing 模式要求 POSTGRES_DATA_DIR 已存在且包含 PG_VERSION" >&2 + exit 1 + fi + if [[ ! -d "$STORAGE_HOST_DIR" ]]; then + echo "[ERROR] existing 模式要求 STORAGE_HOST_DIR 已存在" >&2 + exit 1 + fi +fi +if [[ "$ENABLE_MINIO" == "1" ]]; then + mkdir -p deploy/trialrun/data/minio +fi if [[ ! -f "$REMOTE_ENV_FILE" ]]; then echo "[ERROR] 远端缺少 $REMOTE_ENV_FILE,请先手工创建或使用 SYNC_ENV=1" >&2 diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md index 3195db4c..847fc815 100644 --- a/deploy/trialrun/runbook.md +++ b/deploy/trialrun/runbook.md @@ -12,7 +12,7 @@ 1. 服务器以 root 执行 `bash deploy/trialrun/setup-server.sh` 2. 手工创建 `deploy/trialrun/.env` 3. 手工更新 OA 的 `Caddyfile` 站点块并确认 TLS 正常 - 4. 本地通过受控配置显式提供 `DEPLOY_SERVER`、`REMOTE_DIR`,再执行 `bash scripts/qa/run-release-gate.sh all`(或至少执行 `bash deploy/trialrun/deploy.sh`) + 4. 本地通过受控配置显式提供 `DEPLOY_SERVER`、`REMOTE_DIR`、绝对数据目录和 `DEPLOY_DATA_MODE`,再执行 `bash scripts/qa/run-release-gate.sh all`(或至少执行 `bash deploy/trialrun/deploy.sh`) ## 0B. 历史部署基线(基础设施信息已脱敏) - SSH 目标、部署账号和机器地址只保存在受控运维配置中,通过 `DEPLOY_SERVER` 显式传入。 @@ -47,7 +47,8 @@ cp deploy/trialrun/.env.example deploy/trialrun/.env - `BUILD_SHA` / `VITE_BUILD_SHA` 由发布入口按已批准提交临时注入,不写入长期 `.env`;前者供后端运行时核对,后者只允许 12 位短 SHA 进入公网 bundle。 - 推荐使用无引号值,例如 `COOKIE_SECURE=true`、`PORT=3000`。 - `PORT=3000` 是容器内端口;`APP_HOST_PORT` 是宿主回环端口。发布脚本通过 Compose 动态解析后者,Caddy 必须指向相同值。 -- `POSTGRES_DATA_DIR` 和 `STORAGE_HOST_DIR` 是宿主数据事实源;默认相对 `deploy/trialrun/`。生产环境更换项目名或任一数据目录只能在同一次获批停机窗口完成,禁止两个 PostgreSQL 实例同时挂载同一 PGDATA。 +- `POSTGRES_DATA_DIR` 和 `STORAGE_HOST_DIR` 是宿主数据事实源。示例相对值仅供本地直接 Compose;正式 `deploy.sh` 要求在命令环境中显式提供两个绝对路径和 `DEPLOY_DATA_MODE=existing|initialize`。 +- `existing` 要求 PGDATA 已存在且包含 `PG_VERSION`,附件目录也必须已存在;`initialize` 才会创建目录。生产环境更换项目名或任一数据目录只能在同一次获批停机窗口完成,禁止两个 PostgreSQL 实例同时挂载同一 PGDATA。 - 历史上已经写成带引号的 `.env` 当前仍兼容,但建议后续逐步改成无引号。 - 正式部署前可先执行 `node deploy/trialrun/validate-env-contract.js` 做静态契约检查。 - **EXTERNAL_GATED**:涉及认证安全迁移时,发布或回滚前必须由操作人先在边缘暂停 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify` 四个 challenge 端点,再执行“新镜像配置预检 -> additive migration -> 新应用切换”。部署脚本不会自动修改边缘配置;未人工确认四端点已关闭不得发布或回滚开放。仅在安全版本及 smoke 全部通过后人工恢复;若回滚到旧认证代码,四端点必须继续关闭。 @@ -183,15 +184,19 @@ 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]}" +SOURCE_IMAGE_DIGEST="$(docker inspect --format '{{.Image}}' )" DB_TOOL_MODE=docker \ DB_CONTAINER="$DB_CONTAINER" \ STORAGE_DIR_HOST="" \ +SOURCE_IMAGE_DIGEST="$SOURCE_IMAGE_DIGEST" \ ./scripts/backup-all.sh ``` `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 容器的数据库/用户与至少一个活动应用连接一致,再把已验证的运行参数传给脚本;不在日志中输出参数值。 +manifest 必须至少记录一个真实来源标识:活动 `/ready` 有构建标识时显式传 `BUILD_SHA`;没有时,从活动 server 容器 `docker inspect --format '{{.Image}}'` 取得 `sha256:` 镜像 ID并传 `SOURCE_IMAGE_DIGEST`。不得用待部署提交填充旧实例的 `buildSha`。 + 输出默认在 `deploy/trialrun/backups/hyb-oa-backup-/`,其中包含: - `database.dump` @@ -233,11 +238,24 @@ RESTORE_SCOPE=shadow \ `restore-db.sh` 和 `restore-storage.sh` 默认只接受 `shadow`。影子库名必须包含独立的 `shadow`、`drill` 或 `restore_test` 标记;数据库确认不匹配、库名不安全、附件目录非空、目标为默认试运行附件目录或归档路径危险时均在写入前退出。 -### 1.5.3 真实事故恢复 +真实格式影子恢复的验收不仅是 `/ready`:恢复后的 `_prisma_migrations` 成功项必须等于冻结基线(当前预期 58),预先列出的关键业务表计数必须逐表一致,附件解包后的普通文件数必须与活动源目录一致。任一项不一致即判定批次不可作为切换回滚点。 + +### 1.5.3 生产切换前冻结记录 + +执行正确备份和影子恢复前,先用只读命令记录: + +- `docker compose ls` 实测的旧 Compose project 名;不得按目录名推断,后续停止旧栈必须使用该实测值。 +- 活动 server/db 容器 ID、活动镜像 ID(`sha256:`)及可用的 repo digest;回滚至少保留不可变镜像 ID。 +- 活动 DB/附件真实宿主挂载源、Caddy 目标端口和环境文件摘要,不输出环境变量正文。 +- 成功 migration 数量和固定关键表计数基线。至少覆盖组织人员、角色、采购、报销、发票池、支付批次、合同、项目和附件关联表。 + +活动数据复制策略在停机窗口前冻结:旧 PostgreSQL 完全停止后,把 PGDATA 和附件复制到预先确认的空绝对目录,并保留所有权、权限、链接和扩展属性;旧目录留在原位且观察期内不再挂载写入。不得移动或改名后只留下单份已迁移数据,也不得复制运行中的 PGDATA。 + +### 1.5.4 真实事故恢复 真实库/附件恢复不是普通脚本路径。必须另建或更新获批 Trellis task,记录停机窗口、恢复批次、实际目标、两名负责人、当前备份、回滚点和验证清单。当前恢复脚本不会接受 `production` 模式,不得通过修改环境变量或脚本临时绕过。恢复后必须验证 `/ready`、运行 commit、核心权限和 DB/附件关联完整性。 -### 1.5.4 小团队上线前演练记录 +### 1.5.5 小团队上线前演练记录 上线前至少记录一次演练结果: - 演练日期: @@ -275,8 +293,9 @@ RESTORE_SCOPE=shadow \ 步骤: 1) 先做备份(见 E14 脚本) -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` 必须与活动数据目录迁移在同一停机窗口启用;本地提交本身不授权切换。 +2) 从本地仓库执行 `DEPLOY_SERVER= REMOTE_DIR= COMPOSE_PROJECT_NAME= POSTGRES_DATA_DIR= STORAGE_HOST_DIR= DEPLOY_DATA_MODE=existing bash deploy/trialrun/deploy.sh`(推荐);脚本只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。 + - 在现网目录统一任务中,新的 `COMPOSE_PROJECT_NAME`、`POSTGRES_DATA_DIR`、`STORAGE_HOST_DIR` 必须与活动数据目录复制在同一停机窗口启用;本地提交本身不授权切换。 + - 旧栈停止命令必须使用冻结阶段由 `docker compose ls` 实测的旧 project;不得拿新 project 名去停止旧容器。 - `artifacts/` 和 `output/` 是运行期证据/截图目录,不作为源码发布输入,也不由 `rsync --delete` 清理远端历史证据。 3) 构建并重启: ```bash diff --git a/deploy/trialrun/validate-env-contract.js b/deploy/trialrun/validate-env-contract.js index 6e859588..7fd9da41 100755 --- a/deploy/trialrun/validate-env-contract.js +++ b/deploy/trialrun/validate-env-contract.js @@ -243,6 +243,18 @@ for (const [name, content] of [ } } +for (const requiredFragment of [ + 'validate_data_path POSTGRES_DATA_DIR "$POSTGRES_DATA_DIR"', + 'validate_data_path STORAGE_HOST_DIR "$STORAGE_HOST_DIR"', + 'DEPLOY_DATA_MODE" != "existing"', + 'DEPLOY_DATA_MODE" != "initialize"', + '"$POSTGRES_DATA_DIR/PG_VERSION"', +]) { + if (!deployScriptContent.includes(requiredFragment)) { + errors.push(`deploy.sh 缺少正式部署数据目录护栏: ${requiredFragment}`); + } +} + for (const { filePath, content } of nginxContents) { const relativePath = path.relative(rootDir, filePath); if (content.includes('$proxy_add_x_forwarded_for')) { diff --git a/docs/trialrun/OPS7运维交接Runbook.md b/docs/trialrun/OPS7运维交接Runbook.md index 4c8b8df2..ac5f6502 100644 --- a/docs/trialrun/OPS7运维交接Runbook.md +++ b/docs/trialrun/OPS7运维交接Runbook.md @@ -34,13 +34,23 @@ LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readine 标准部署命令: ```bash +DEPLOY_SERVER= \ +REMOTE_DIR= \ +COMPOSE_PROJECT_NAME= \ +POSTGRES_DATA_DIR= \ +STORAGE_HOST_DIR= \ +DEPLOY_DATA_MODE=existing \ bash deploy/trialrun/deploy.sh ``` 默认不覆盖远端 `.env`。只有确认本地 `deploy/trialrun/.env` 可直接上服务器时,才允许在单独确认后使用: ```bash -SYNC_ENV=1 bash deploy/trialrun/deploy.sh +SYNC_ENV=1 \ +POSTGRES_DATA_DIR= \ +STORAGE_HOST_DIR= \ +DEPLOY_DATA_MODE=existing \ +bash deploy/trialrun/deploy.sh ``` 部署完成后必须记录: diff --git a/scripts/backup-all.sh b/scripts/backup-all.sh index b3f45c66..0b71c917 100755 --- a/scripts/backup-all.sh +++ b/scripts/backup-all.sh @@ -4,12 +4,32 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BACKUP_DIR="${BACKUP_DIR:-$ROOT_DIR/deploy/trialrun/backups}" BACKUP_RUN_ID="${BACKUP_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)}" +BUILD_SHA="${BUILD_SHA:-}" +SOURCE_IMAGE_DIGEST="${SOURCE_IMAGE_DIGEST:-}" if [[ ! "$BACKUP_RUN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$ ]]; then echo "[ERR] BACKUP_RUN_ID must use 1-80 safe characters" >&2 exit 1 fi +if [[ -z "$BUILD_SHA" ]]; then + BUILD_SHA="$(git -C "$ROOT_DIR" rev-parse --short=12 HEAD 2>/dev/null || true)" +fi +if [[ -n "$BUILD_SHA" && ! "$BUILD_SHA" =~ ^[0-9a-fA-F]{7,64}$ ]]; then + echo "[ERR] BUILD_SHA must be a 7-64 character hexadecimal commit id" >&2 + exit 1 +fi +if [[ -n "$SOURCE_IMAGE_DIGEST" && ! "$SOURCE_IMAGE_DIGEST" =~ ^sha256:[0-9a-fA-F]{64}$ ]]; then + echo "[ERR] SOURCE_IMAGE_DIGEST must use sha256:<64 hexadecimal characters>" >&2 + exit 1 +fi +if [[ -z "$BUILD_SHA" && -z "$SOURCE_IMAGE_DIGEST" ]]; then + echo "[ERR] BUILD_SHA or SOURCE_IMAGE_DIGEST is required to identify the backup source" >&2 + exit 1 +fi +BUILD_SHA="${BUILD_SHA,,}" +SOURCE_IMAGE_DIGEST="${SOURCE_IMAGE_DIGEST,,}" + mkdir -p "$BACKUP_DIR" FINAL_DIR="$BACKUP_DIR/hyb-oa-backup-$BACKUP_RUN_ID" if [[ -e "$FINAL_DIR" ]]; then @@ -64,23 +84,23 @@ DB_SHA256="$(sha256sum "$DB_FILE" | awk '{print $1}')" STORAGE_SHA256="$(sha256sum "$STORAGE_FILE" | awk '{print $1}')" DB_BYTES="$(wc -c <"$DB_FILE" | tr -d ' ')" STORAGE_BYTES="$(wc -c <"$STORAGE_FILE" | tr -d ' ')" -BUILD_SHA="$(git -C "$ROOT_DIR" rev-parse --short=12 HEAD 2>/dev/null || true)" CREATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" MANIFEST_ARGS=( - "$TEMP_DIR/$MANIFEST_NAME" "$BACKUP_RUN_ID" "$CREATED_AT" "$BUILD_SHA" + "$TEMP_DIR/$MANIFEST_NAME" "$BACKUP_RUN_ID" "$CREATED_AT" "$BUILD_SHA" "$SOURCE_IMAGE_DIGEST" "$DB_NAME" "$DB_BYTES" "$DB_SHA256" "$STORAGE_NAME" "$STORAGE_BYTES" "$STORAGE_SHA256" ) if command -v node >/dev/null 2>&1; then node - "${MANIFEST_ARGS[@]}" <<'NODE' const fs = require("node:fs"); -const [manifestPath, runId, createdAt, buildSha, dbFile, dbBytes, dbSha256, storageFile, storageBytes, storageSha256] = process.argv.slice(2); +const [manifestPath, runId, createdAt, buildSha, sourceImageDigest, dbFile, dbBytes, dbSha256, storageFile, storageBytes, storageSha256] = process.argv.slice(2); const manifest = { schemaVersion: 1, runId, createdAt, buildSha: buildSha || null, + sourceImageDigest: sourceImageDigest || null, status: "complete", artifacts: { database: { file: dbFile, bytes: Number(dbBytes), sha256: dbSha256 }, @@ -99,6 +119,7 @@ import sys run_id, created_at, build_sha, + source_image_digest, db_file, db_bytes, db_sha256, @@ -112,6 +133,7 @@ manifest = { "runId": run_id, "createdAt": created_at, "buildSha": build_sha or None, + "sourceImageDigest": source_image_digest or None, "status": "complete", "artifacts": { "database": {"file": db_file, "bytes": int(db_bytes), "sha256": db_sha256}, diff --git a/scripts/qa/run-backup-restore-drill.sh b/scripts/qa/run-backup-restore-drill.sh index 4a0fd4d9..7d9dd2bf 100755 --- a/scripts/qa/run-backup-restore-drill.sh +++ b/scripts/qa/run-backup-restore-drill.sh @@ -41,6 +41,13 @@ 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 BUILD_SHA=not-a-commit SOURCE_IMAGE_DIGEST= \ + BACKUP_DIR="$BACKUP_ROOT" BACKUP_RUN_ID=invalid-source \ + "$ROOT_DIR/scripts/backup-all.sh" >/dev/null 2>&1; then + echo "[ERR] invalid backup source identity was accepted" >&2 + exit 1 +fi + 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 @@ -57,6 +64,28 @@ if env -u DB_CONTAINER DB_TOOL_MODE=docker DATABASE_URL=postgresql://invalid \ fi if command -v node >/dev/null 2>&1; then + if deploy_error="$(DEPLOY_SERVER=unused POSTGRES_DATA_DIR=./relative \ + STORAGE_HOST_DIR=/srv/hyb-oa/storage DEPLOY_DATA_MODE=existing \ + "$ROOT_DIR/deploy/trialrun/deploy.sh" 2>&1)"; then + echo "[ERR] formal deploy accepted a relative PostgreSQL data path" >&2 + exit 1 + fi + if [[ "$deploy_error" != *"POSTGRES_DATA_DIR"* ]]; then + echo "[ERR] relative deploy path did not fail at the data-path guard" >&2 + exit 1 + fi + + if deploy_error="$(DEPLOY_SERVER=unused POSTGRES_DATA_DIR=/srv/hyb-oa/data \ + STORAGE_HOST_DIR=/srv/hyb-oa/data DEPLOY_DATA_MODE=existing \ + "$ROOT_DIR/deploy/trialrun/deploy.sh" 2>&1)"; then + echo "[ERR] formal deploy accepted overlapping data paths" >&2 + exit 1 + fi + if [[ "$deploy_error" != *"必须相互独立"* ]]; then + echo "[ERR] overlapping deploy paths did not fail at the isolation guard" >&2 + exit 1 + fi + 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" @@ -103,6 +132,12 @@ HOST_PORT="$(docker port "$CONTAINER_NAME" 5432/tcp | awk -F: 'NR==1 {print $NF} DATABASE_URL="postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@127.0.0.1:$HOST_PORT/$POSTGRES_DB" export DATABASE_URL STORAGE_DIR_HOST="$SOURCE_STORAGE" BACKUP_DIR="$BACKUP_ROOT" BACKUP_RUN_ID="$DRILL_RUN_ID" export DB_TOOL_MODE="docker" DB_CONTAINER="$CONTAINER_NAME" POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB +if [[ "${DRILL_SOURCE_ID_MODE:-both}" == "digest_only" ]]; then + export BUILD_SHA="" +else + export BUILD_SHA="abcdef1234567890abcdef1234567890abcdef12" +fi +export SOURCE_IMAGE_DIGEST="sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -qc 'CREATE TABLE restore_drill_marker (value text NOT NULL)' psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -qc "INSERT INTO restore_drill_marker(value) VALUES ('synthetic-round-trip')" @@ -113,14 +148,17 @@ DB_BACKUP="$BATCH_DIR/database.dump" STORAGE_BACKUP="$BATCH_DIR/storage.tar.gz" if command -v node >/dev/null 2>&1; then - node - "$BATCH_DIR/manifest.json" "$DB_BACKUP" "$STORAGE_BACKUP" <<'NODE' + node - "$BATCH_DIR/manifest.json" "$DB_BACKUP" "$STORAGE_BACKUP" "$BUILD_SHA" "$SOURCE_IMAGE_DIGEST" <<'NODE' const crypto = require("node:crypto"); const fs = require("node:fs"); const path = require("node:path"); -const [manifestPath, dbPath, storagePath] = process.argv.slice(2); +const [manifestPath, dbPath, storagePath, expectedBuildSha, expectedImageDigest] = process.argv.slice(2); const manifestText = fs.readFileSync(manifestPath, "utf8"); const manifest = JSON.parse(manifestText); if (manifest.status !== "complete" || manifest.schemaVersion !== 1) throw new Error("manifest contract mismatch"); +if (manifest.buildSha !== (expectedBuildSha || null) || manifest.sourceImageDigest !== expectedImageDigest) { + throw new Error("manifest source identity mismatch"); +} if (path.isAbsolute(manifest.artifacts.database.file) || path.isAbsolute(manifest.artifacts.storage.file)) { throw new Error("manifest contains absolute artifact path"); } @@ -130,19 +168,21 @@ if (sha256(dbPath) !== manifest.artifacts.database.sha256) throw new Error("data if (sha256(storagePath) !== manifest.artifacts.storage.sha256) throw new Error("storage checksum mismatch"); NODE else - python3 - "$BATCH_DIR/manifest.json" "$DB_BACKUP" "$STORAGE_BACKUP" <<'PYTHON' + python3 - "$BATCH_DIR/manifest.json" "$DB_BACKUP" "$STORAGE_BACKUP" "$BUILD_SHA" "$SOURCE_IMAGE_DIGEST" <<'PYTHON' import hashlib import json import os import re import sys -manifest_path, db_path, storage_path = sys.argv[1:] +manifest_path, db_path, storage_path, expected_build_sha, expected_image_digest = sys.argv[1:] with open(manifest_path, encoding="utf-8") as manifest_file: manifest_text = manifest_file.read() manifest = json.loads(manifest_text) if manifest.get("status") != "complete" or manifest.get("schemaVersion") != 1: raise RuntimeError("manifest contract mismatch") +if manifest.get("buildSha") != (expected_build_sha or None) or manifest.get("sourceImageDigest") != expected_image_digest: + raise RuntimeError("manifest source identity mismatch") for artifact in (manifest["artifacts"]["database"], manifest["artifacts"]["storage"]): if os.path.isabs(artifact["file"]): raise RuntimeError("manifest contains absolute artifact path") diff --git a/scripts/qa/run-release-gate.sh b/scripts/qa/run-release-gate.sh index 62073ff6..e7d7579c 100755 --- a/scripts/qa/run-release-gate.sh +++ b/scripts/qa/run-release-gate.sh @@ -29,6 +29,7 @@ Usage: bash scripts/qa/run-release-gate.sh