diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index a17740b0..9c3e0525 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -619,6 +619,9 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" - Application storage mount: `${STORAGE_HOST_DIR:-./data/storage}`. - Formal deploy inputs: absolute `POSTGRES_DATA_DIR`, absolute `STORAGE_HOST_DIR`, `DEPLOY_DATA_MODE=existing|initialize`. +- Timer install inputs: `COMPOSE_PROJECT_NAME`, absolute + `POSTGRES_DATA_DIR`, absolute `STORAGE_HOST_DIR`, full 40–64 hex + `BUILD_SHA` and its exact 12-hex `VITE_BUILD_SHA` prefix. - DB has no host `ports`; server remains loopback-only. ### 3. Contracts @@ -639,6 +642,16 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" 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. +- The timer installer must validate the same non-root, non-overlapping absolute + data-path shape as formal deploy, require an existing `PG_VERSION` and + storage directory, and write both paths plus both build identifiers into the + systemd service `Environment=` entries. Project name alone is insufficient: + a later timer invocation would otherwise interpolate Compose defaults and + operate against a non-active data pair. +- Timer installation may enable/start the timer unit, but verification must not + manually start the daily service because that path can send real alerts. + Verify the unit environment against active container mounts, then run + Compose `config --quiet` and `exec -T server true` with the same values. - 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 @@ -651,6 +664,13 @@ 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. +- Timer install omits either data path/build identifier, receives nested paths, + a PGDATA without `PG_VERSION`, a missing storage directory, an invalid full + SHA or a 12-character value that is not its prefix -> fail before writing the + systemd unit. +- A path-segment regex can match an empty string at the leading `/` -> contract + failure; use explicit root, trailing slash, `//`, `/./` and `/../` checks so + valid absolute paths remain installable. - 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 @@ -664,17 +684,26 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" - 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. +- Timer contract assertions cover all four explicit inputs, path isolation, + PGDATA marker, SHA/prefix validation and the four systemd `Environment=` + entries. On the deployment host, assert timer enabled/active, daily service + inactive, exact active-environment equality and non-mutating Compose resolve. ### 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. +- Good: the timer unit contains the current project, active mount sources and + deployed full/short SHA pair; Compose config and a non-mutating server exec + resolve under those exact values while the daily service stays inactive. - 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. +- Bad: reinstall the timer with only `COMPOSE_PROJECT_NAME`, allowing systemd + Compose interpolation to fall back to repository-relative data directories. ### 7. Wrong vs Correct @@ -697,6 +726,22 @@ DB_TOOL_MODE=docker DB_CONTAINER="${DB_CONTAINERS[0]}" \ STORAGE_DIR_HOST= ./scripts/backup-all.sh ``` +For timer installation, keep the active deployment identity explicit: + +```bash +# Wrong: project identity alone still permits relative data-path fallback. +COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT_NAME" \ + bash deploy/trialrun/install-warch3-ops-timer.sh install + +# Correct: use the verified active mounts and deployed SHA pair. +COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT_NAME" \ +POSTGRES_DATA_DIR= \ +STORAGE_HOST_DIR= \ +BUILD_SHA= \ +VITE_BUILD_SHA= \ + bash deploy/trialrun/install-warch3-ops-timer.sh install +``` + ## Scenario: Paired Backup And Shadow-Only Restore ### 1. Scope / Trigger 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 c3e3823a..508b8424 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md @@ -131,4 +131,4 @@ GET /auth/wecom/login?mode=onboard 2. 本地先修复 Compose 可配置回环端口及部署脚本动态端口探测,通过全部门禁并提交、发布批准提交。 3. 停机后把活动 PGDATA 和附件复制到新的显式绝对目录,复制前目标必须为空;保留旧目录原位且不再挂载写入,直到观察期结束。复制仅在旧 PostgreSQL 完全停止后执行,并保留所有权、权限、链接和扩展属性。 4. 用新 Compose 重建 db/init/server,应用已提交 migrations;旧容器和旧目录在验收前保留,不清理。 -5. 公网构建标识、数据计数、角色、企微入口和关键只读检查通过后更新运维 timer;失败则停止新容器并恢复旧目录/容器和原 Caddy 目标。 +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 6a679e02..80a73b7a 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md @@ -20,13 +20,13 @@ - [ ] 复用既有 HR 同步与角色语义写入 `employee + finance`、`employee + ceo`,复核会话失效、审计和 maker-checker,不部署、不迁移。 - [x] 增加默认关闭的一次性企微敏感资料授权入口,保持普通登录静默;实现 `user_ticket -> getuserdetail`、active/字段/唯一性门禁和仅 `employee` 自助开通。 - [x] 补齐 OAuth 路由、服务、配置契约和单元测试,证明票据/原始敏感响应不进入日志、Cookie、数据库或审计。 -- [ ] 修复部署脚本的现场端口解析,确定唯一部署目录,并在活动 DB/附件挂载、环境摘要和旧镜像回滚点全部确认后才执行获批切换。 +- [x] 修复部署脚本的现场端口解析,确定唯一部署目录,并在活动 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;不因服务可访问而忽略来源漂移。 +- [x] 部署后验证唯一 Compose 来源、构建标识、迁移状态、Caddy 公网 `/ready` 和前端 marker;不因服务可访问而忽略来源漂移。 - [ ] 用户在企微应用详情选择手机、邮箱、企业邮箱敏感字段;部署开关开启后,两名目标分别通过一次性链接本人确认授权。 - [ ] 两人自助开通成功后写入获批 `finance` / `ceo` 职责,复核不同身份、maker-checker、会话撤销、审计和付款零写入。 @@ -71,6 +71,15 @@ - 已从活动 DB 容器与活动 server 附件挂载生成正确批次并复制到 ARM 备份机;DB catalog、manifest、源/目标产物 SHA-256、附件 tar 与 0 个普通文件计数均通过,且未伪造旧实例 build SHA。ARM 只承担异机备份与恢复演练职责,不运行 OA 服务。 - 已在 ARM 备份机用不发布端口的一次性 PostgreSQL 16 容器恢复正确批次:58 项成功迁移、12 张关键表计数和 0 个附件普通文件均与冻结基线一致,包含镜像拉取的实测 RTO 为 33 秒。演练后容器、临时附件目录和本轮新增镜像均已删除,未运行 OA server;应用 `/ready` 留待生产部署环境验证。 +## 生产部署与 timer 收口证据(2026-07-27) + +- 已在获批停机窗口把生产切换到唯一 Compose project;活动 DB 与附件从停机冻结副本进入新的独立目录,旧容器保持停止,旧数据目录原位保留作回滚点。 +- 生产已应用 61 项 migration;12 张关键表计数与冻结基线一致,活动附件普通文件数仍为 0。server 仅回环发布,DB 不发布宿主端口。 +- 容器内与公网 `/ready`、前端 marker 均返回批准提交的同一 12 位构建标识;runtime smoke、external-deps smoke 和公网 bundle/marker 检查通过。 +- 运维 timer 安装器已要求显式绑定 Compose project、活动 PGDATA、活动附件目录、完整后端 SHA 和 12 位前端 SHA。生产 timer 已重新安装并保持 enabled/active,daily service 未被手工触发;unit 环境与活动挂载/构建标识一致,同环境 Compose config 与 server exec 解析通过。 +- timer 首次收口时发现从 deploy 复制的旧路径正则会误拒绝所有绝对路径;安装在写 unit 前安全停止。现已与 deploy 的根目录、尾随斜杠、双斜杠及点路径段门禁保持一致,并由环境契约阻止旧正则回退。 +- 本轮没有启动真实告警任务,没有修改员工、角色、付款、企微后台或企微开通开关;职责分离与两人本人授权继续保持未完成。 + ## 回滚点 - 成对入口失败:保留四个底层脚本,不写完整 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 e846161f..7870d6e7 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md @@ -62,7 +62,7 @@ - [ ] 获批写入后,两名活跃员工分别具有 `employee + finance` 与 `employee + ceo`,企微绑定不同,角色审计存在且未产生付款业务写入。 - [x] 普通企微登录仍生成 `snsapi_base`,一次性开通入口生成 `snsapi_privateinfo`;缺少开关、`user_ticket`、成员授权字段、active 状态或唯一性时均拒绝创建(本地 synthetic 验证)。 - [ ] 一次性授权通过 `getuserdetail` 获取成员同意的手机和邮箱,自动创建或补齐 OA 员工与企微绑定;新建账号角色严格等于 `employee`,日志和审计不含票据或完整敏感响应。 -- [ ] 部署目录、Compose、环境文件、运维 timer 和活动容器来源统一;活动 DB/附件连续,远端 migrations 与批准提交一致,公网 `/ready` 和前端 marker 返回同一短构建标识。 +- [x] 部署目录、Compose、环境文件、运维 timer 和活动容器来源统一;活动 DB/附件连续,远端 migrations 与批准提交一致,公网 `/ready` 和前端 marker 返回同一短构建标识。 - [x] 本地 Compose、部署、timer 和备份合同完成加固并覆盖负向回退;未把本地通过误报为生产切换完成。 - [x] 部署入口拒绝隐式/相对数据目录及缺少数据模式,成对 manifest 可记录显式 build SHA 或活动镜像 digest,并有 Node/Python 等价测试。 - [ ] 两名目标成员分别完成一次本人授权后,账号唯一、企微绑定不同,再由管理员写入 `employee + finance` 与 `employee + ceo`;maker-checker、会话撤销和角色审计通过。 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 d5f91a29..9ee2fab6 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 @@ -111,3 +111,12 @@ - 已使用正确异机批次在不发布端口的一次性 PostgreSQL 16 容器中完成真实备份格式影子恢复;58 项成功迁移、12 张关键表计数与冻结基线逐项一致,附件归档安全检查和解包后的 0 个普通文件也一致,包含镜像拉取的实测 RTO 为 33 秒。 - 恢复演练后,一次性数据库容器、临时附件目录和本轮新增 PostgreSQL 镜像均已删除;ARM 继续只承担备份与恢复演练职责,没有运行 OA server 或开放 OA 端口。 - 当前证据证明 DB+附件异机恢复点可用,但不替代部署环境的应用 `/ready`、构建标识、Caddy 和前端 marker 验收。生产服务未停止,数据库、附件、容器、Caddy、timer、角色和企微均未修改。 + +## 2026-07-27 获批生产切换与运维收口 + +- 已完成获批生产切换:新 Compose project 成为唯一活动来源,旧项目容器保持停止;旧数据目录和旧容器均保留,正式活动数据使用停机后复制的新目录。 +- 生产共 61 项 migration 成功,12 张关键表计数与冻结基线一致,活动附件普通文件数为 0;server 只绑定回环地址,DB 未发布宿主端口。 +- 容器内与公网 `/ready`、前端 marker 均观察到批准提交的同一 12 位构建标识;runtime、外部依赖和公网资源标识检查通过。 +- 运维 timer 已重新安装并显式绑定当前 Compose project、活动 DB/附件目录和前后端构建标识;timer 为 enabled/active,daily service 保持 inactive,未手工触发真实告警。使用同一组脱敏环境事实执行 Compose config 与 server exec 均通过。 +- timer 安装前的路径与构建门禁先在本地通过;一次旧正则误拒绝绝对路径后,安装在 unit 写入前停止,随后修复并增加防回退契约。全程未输出或写入 Secret、完整宿主路径和环境原文。 +- 部署与恢复基线现已完成;真实职责分配、两名成员本人敏感资料授权和企微开通仍是独立未完成门禁,因此整体仍不得宣称付款 maker-checker 或企微自助开通已可用。 diff --git a/deploy/trialrun/install-warch3-ops-timer.sh b/deploy/trialrun/install-warch3-ops-timer.sh index de6897ae..f104146e 100644 --- a/deploy/trialrun/install-warch3-ops-timer.sh +++ b/deploy/trialrun/install-warch3-ops-timer.sh @@ -5,6 +5,10 @@ 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}" +POSTGRES_DATA_DIR="${POSTGRES_DATA_DIR:-}" +STORAGE_HOST_DIR="${STORAGE_HOST_DIR:-}" +BUILD_SHA="${BUILD_SHA:-}" +VITE_BUILD_SHA="${VITE_BUILD_SHA:-}" SERVICE_NAME="${SERVICE_NAME:-hyb-oa-warch3-ops}" RUN_AT="${RUN_AT:-07:30}" MODE="${1:-install}" @@ -19,6 +23,20 @@ need_root() { 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 +} + uninstall_timer() { need_root systemctl disable --now "${SERVICE_NAME}.timer" >/dev/null 2>&1 || true @@ -42,6 +60,32 @@ install_timer() { echo "[ERROR] COMPOSE_PROJECT_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 [[ ! -f "$POSTGRES_DATA_DIR/PG_VERSION" ]]; then + echo "[ERROR] POSTGRES_DATA_DIR 必须是包含 PG_VERSION 的现存 PGDATA" >&2 + exit 1 + fi + if [[ ! -d "$STORAGE_HOST_DIR" ]]; then + echo "[ERROR] STORAGE_HOST_DIR 必须是现存目录" >&2 + exit 1 + fi + if [[ ! "$BUILD_SHA" =~ ^[0-9a-fA-F]{40,64}$ ]]; then + echo "[ERROR] BUILD_SHA 必须是 40-64 位十六进制完整提交标识" >&2 + exit 1 + fi + BUILD_SHA="${BUILD_SHA,,}" + VITE_BUILD_SHA="${VITE_BUILD_SHA,,}" + if [[ ! "$VITE_BUILD_SHA" =~ ^[0-9a-f]{12}$ || "${BUILD_SHA:0:12}" != "$VITE_BUILD_SHA" ]]; then + echo "[ERROR] VITE_BUILD_SHA 必须是 BUILD_SHA 的 12 位十六进制前缀" >&2 + exit 1 + fi local compose_exec if docker compose version >/dev/null 2>&1; then @@ -66,6 +110,10 @@ 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=POSTGRES_DATA_DIR=${POSTGRES_DATA_DIR} +Environment=STORAGE_HOST_DIR=${STORAGE_HOST_DIR} +Environment=BUILD_SHA=${BUILD_SHA} +Environment=VITE_BUILD_SHA=${VITE_BUILD_SHA} Environment=LIVE_SMOKE_BASE_URL=https://oa.hyinda.com Environment=WARCH3_SEND_ALERT=1 Environment=WARCH3_OPS_ALERT_SEND=1 @@ -99,7 +147,7 @@ case "$MODE" in uninstall_timer ;; --help|-h) - echo "Usage: REMOTE_DIR=/opt/hyb-oa-trialrun bash deploy/trialrun/install-warch3-ops-timer.sh [install|uninstall]" + echo "Usage: REMOTE_DIR=/opt/hyb-oa-trialrun COMPOSE_PROJECT_NAME= POSTGRES_DATA_DIR= STORAGE_HOST_DIR= BUILD_SHA= VITE_BUILD_SHA=<12-char-prefix> bash deploy/trialrun/install-warch3-ops-timer.sh [install|uninstall]" ;; *) echo "[ERROR] unsupported mode: $MODE" >&2 diff --git a/deploy/trialrun/validate-env-contract.js b/deploy/trialrun/validate-env-contract.js index 7fd9da41..bc78ae7e 100755 --- a/deploy/trialrun/validate-env-contract.js +++ b/deploy/trialrun/validate-env-contract.js @@ -241,6 +241,37 @@ for (const [name, content] of [ if (!content.includes('-p "$COMPOSE_PROJECT_NAME"')) { errors.push(`${name} 必须向 Compose 显式传入独立 project name`); } + if (!content.includes('"$value" == *"//"*') + || !content.includes('"/$value/" == *"/../"*') + || !content.includes('"/$value/" == *"/./"*')) { + errors.push(`${name} 缺少绝对数据路径的空段与点路径段门禁`); + } + if (content.includes('[[:space:]]*)($|/)')) { + errors.push(`${name} 不得以可匹配空字符串的路径段正则拒绝全部绝对路径`); + } +} + +for (const requiredFragment of [ + 'POSTGRES_DATA_DIR="${POSTGRES_DATA_DIR:-}"', + 'STORAGE_HOST_DIR="${STORAGE_HOST_DIR:-}"', + 'BUILD_SHA="${BUILD_SHA:-}"', + 'VITE_BUILD_SHA="${VITE_BUILD_SHA:-}"', + 'validate_data_path POSTGRES_DATA_DIR "$POSTGRES_DATA_DIR"', + 'validate_data_path STORAGE_HOST_DIR "$STORAGE_HOST_DIR"', + '"$POSTGRES_DATA_DIR" == "$STORAGE_HOST_DIR"/*', + '"$STORAGE_HOST_DIR" == "$POSTGRES_DATA_DIR"/*', + '"$POSTGRES_DATA_DIR/PG_VERSION"', + '"$BUILD_SHA" =~ ^[0-9a-fA-F]{40,64}$', + '"$VITE_BUILD_SHA" =~ ^[0-9a-f]{12}$', + '"${BUILD_SHA:0:12}" != "$VITE_BUILD_SHA"', + 'Environment=POSTGRES_DATA_DIR=${POSTGRES_DATA_DIR}', + 'Environment=STORAGE_HOST_DIR=${STORAGE_HOST_DIR}', + 'Environment=BUILD_SHA=${BUILD_SHA}', + 'Environment=VITE_BUILD_SHA=${VITE_BUILD_SHA}', +]) { + if (!timerScriptContent.includes(requiredFragment)) { + errors.push(`install-warch3-ops-timer.sh 缺少活动部署环境绑定: ${requiredFragment}`); + } } for (const requiredFragment of [