diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index 080684f9..317456a5 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -7,7 +7,7 @@ operations require explicit approval and evidence. ## Rules -- 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`. +- Existing installations use `DEPLOY_SERVER= bash deploy/trialrun/deploy-existing.sh` only when deployment is approved; the wrapper reads the frozen allowlisted contract and keeps `SYNC_ENV=0`. First initialization or controlled recovery without that unit uses `deploy.sh` with explicit absolute data paths and `DEPLOY_DATA_MODE`; neither path overwrites remote `.env` by default. - 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 @@ -1282,3 +1282,63 @@ bash scripts/qa/run-automatic-backup-contract.sh \ bash deploy/trialrun/install-offhost-app-health-monitor.sh install ``` + +## Scenario: Existing Deployment Preflight And Cache Stability + +### 1. Scope / Trigger + +- Trigger: deploying a new published commit onto an existing single-host trialrun installation. +- Purpose: prevent a wrong repository-root interpretation from reaching `rsync --delete`, and prevent per-commit build identity from invalidating stable dependency layers. + +### 2. Signatures + +- Existing deployment entry: `DEPLOY_SERVER= bash deploy/trialrun/deploy-existing.sh`. +- Remote preflight: `deploy/trialrun/preflight-remote.sh` executed over SSH before rsync. +- Canonical parameter source: allowlisted `Environment=` values from `hyb-oa-automatic-backup-daily.service`. +- Stable image stages: `base`, `client_deps`, `client_build`, `server_deps`, `server_build`. + +### 3. Contracts + +- Never derive `REMOTE_DIR` from Compose `com.docker.compose.project.working_dir`; it may name the Compose-file directory rather than the repository root. +- The existing-deployment wrapper reads only `REMOTE_DIR`, `REMOTE_ENV_FILE`, `COMPOSE_FILE`, `COMPOSE_PROJECT_NAME`, `POSTGRES_DATA_DIR` and `STORAGE_HOST_DIR`; it does not echo their values. +- Before `mkdir` or rsync, existing mode requires repository markers, `PG_VERSION`, storage directory and a regular non-symlink env file when `SYNC_ENV=0`. +- `apk add` and both lockfile `npm ci` steps precede `BUILD_SHA` / `VITE_BUILD_SHA`. Build identity may invalidate application output, never stable dependency installation. +- Seed and smoke output reports readiness, counts and booleans; it does not print full login identifiers, contact values, WeCom identifiers, redirect URI or generated credentials. + +### 4. Validation & Error Matrix + +- Config directory passed as `REMOTE_DIR` -> preflight fails before rsync. +- Remote env missing or symlinked with `SYNC_ENV=0` -> preflight fails before rsync. +- Existing PGDATA missing `PG_VERSION` or storage missing -> preflight fails before rsync. +- Canonical systemd unit missing an allowlisted value -> wrapper fails without calling `deploy.sh`. +- Lockfile unchanged but a new SHA reruns dependency installation -> Dockerfile contract test fails. + +### 5. Good/Base/Bad Cases + +- Good: wrapper reads the frozen unit contract, preflight passes, Git archive syncs and unchanged dependency layers are cached. +- Base: first initialization uses reviewed explicit parameters and `DEPLOY_DATA_MODE=initialize`; it does not pretend an existing unit is available. +- Bad: inspect a running container's `working_dir`, append another `deploy/trialrun`, then discover the mistake only after rsync. + +### 6. Tests Required + +- `bash -n deploy/trialrun/deploy.sh deploy/trialrun/deploy-existing.sh deploy/trialrun/preflight-remote.sh`. +- `node deploy/trialrun/validate-env-contract.js`. +- `cd server && npm test -- tests/unit/scripts/deployment-safety-contract.test.js`. +- Synthetic fixtures must prove wrong root, missing/symlink env and invalid data fail; static ordering must prove preflight precedes rsync and dependency stages precede build args. + +### 7. Wrong vs Correct + +#### Wrong + +```bash +# working_dir may be the Compose config directory, not the repository root. +REMOTE_DIR="$(docker inspect --format '')" \ + bash deploy/trialrun/deploy.sh +``` + +#### Correct + +```bash +DEPLOY_SERVER= \ + bash deploy/trialrun/deploy-existing.sh +``` diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md index 51a80ac8..51190496 100644 --- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md @@ -298,3 +298,29 @@ processingProgress.stages[] = { - `displayFormat.ts` 新增可接受 `string | number | null | undefined` 的人民币格式化函数;PC/移动详情删除各自金额 formatter,统一复用。列表页本轮不做横向重构,避免扩大已批准范围。 - 移动材料区继续承接发票添加;固定底栏删除同名次级按钮,只渲染提交主动作。固定栏的 safe-area、loading、禁用和提交校验保持不变。 - 回退只需还原前端提交;不涉及 API、数据库、权限、workflow、生产配置或真实数据。 + +## 14. 生产部署防重复事故合同 + +### 14.1 根因与边界 + +- 正确发布提交 `5dac336` 前,首次尝试把 Compose `working_dir` 误当仓库根目录。该标签实际指向 `deploy/trialrun` 配置目录;现有 `deploy.sh` 又到 rsync 之后才检查远端 `.env`,因此错误目标先被同步,随后才因重复的 env 路径停止。旧容器未重建,但远端 `.env` 需从仍运行容器的项目允许键原子恢复。 +- 发布慢的主因不是 Vite;现场 Vite 生产构建约 11 秒,而 server `npm ci` 约 8 分钟。Dockerfile 在依赖层之前注入每次变化的 build args,使 `apk add` 与两套依赖层随提交 SHA 一起失效。 +- 当前加固只改部署脚本、Dockerfile、脱敏输出、测试和既有 runbook/spec;不修改数据库、业务状态、生产 `.env` 值或真实报销数据。加固完成后需另行批准才再次部署。 + +### 14.2 同步前只读 preflight + +- `DEPLOY_DATA_MODE=existing` 时,`deploy.sh` 在创建目录或 rsync 前通过 SSH 验证:`REMOTE_DIR` 已存在且同时含 `server/package.json` 与 `deploy/trialrun/docker-compose.yml`;PGDATA 含 `PG_VERSION`;附件目录存在。 +- `SYNC_ENV=0` 时,同一 preflight 必须确认 `REMOTE_ENV_FILE` 是普通文件;若位于 `REMOTE_DIR` 内,继续使用当前精确相对路径保护。任何验证失败均在 rsync 前退出。 +- 已有生产部署的参数只从受控 automatic-backup systemd unit 的 allowlist 环境读取,并交叉核对活动 Compose project 与挂载;禁止从 `com.docker.compose.project.working_dir` 猜仓库根目录。首次部署继续使用人工审核的显式参数。 + +### 14.3 Docker 缓存与构建身份 + +- `base` 只安装稳定系统依赖;client/server dependency stages 只由各自 lockfile(以及 Prisma generate 所需 schema)决定。 +- `VITE_BUILD_SHA` 只在 `client_build` 执行 Vite 前注入;`BUILD_SHA` 只在 `server_build`/最终运行层注入。改变 SHA 必须重建应用产物,但不得重跑基础包和 `npm ci`。 +- 静态合同测试断言 Dockerfile 中 `apk add`、client `npm ci`、server `npm ci` 均早于对应 build arg;既有 deploy 来源、`/ready` 和公网短 SHA 检查继续作为运行时证明。 + +### 14.4 日志与回退 + +- seed/smoke 只输出 readiness、计数和布尔状态,不输出完整邮箱、电话、企业微信 corp/agent 标识、redirect URI 或一次性密码。需要首次凭据时必须通过受控配置渠道提供,不从部署日志交付。 +- preflight 失败不需要回滚,因为尚未同步。镜像构建或 smoke 失败时沿用旧容器/既有发布回退合同,不通过重建 `.env` 作为常规路径。 +- 回归至少覆盖错误仓库根、缺失远端 env、无效 existing 数据目录都不会调用 rsync;正确合同可进入 rsync;Docker 缓存顺序与日志禁敏片段均有静态断言。 diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md index 7f6d1792..e66d1aa0 100644 --- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md @@ -16,6 +16,15 @@ - [x] 运行 R5 双设备、flow coverage/本地候选资产解析、前端 typecheck/build 与 Trellis 治理门禁;记录约 18 个其他孤儿 spec 为后续治理,不在本轮扩范围。 - [x] 修正上一轮证据中的错误表述:页面实际仍有四个入口,只有旧采集器因 key 漂移看见两个;本轮未推送、未部署前不宣称线上已修复。 +## 0.3 生产部署流程加固(本轮部署后) + +- [x] 如实记录 `5dac336` 发布的首次错误根目录尝试、`.env` 原子恢复、第二次成功发布、独立构建身份和备份链验证;真实业务数据与审批状态不变。 +- [x] 把远端仓库根、`SYNC_ENV=0` env、existing PGDATA/storage 验证前移到 rsync 之前;错误合同必须在任何远端同步前停止。 +- [x] 重排 Dockerfile,使基础包与两套 lockfile 依赖层不受 build SHA 变化影响,同时保留前端短 SHA和后端完整 SHA。 +- [x] 收敛 deploy seed/external/runtime smoke 的账号、联系方式和外部配置标识输出,只保留 readiness、计数和状态。 +- [x] 增加部署顺序、错误停止点、Docker 缓存顺序和日志最小化回归;更新 deployment spec 与 runbook。 +- [x] 运行聚焦单测、环境/来源合同、shell 语法、敏感扫描、Trellis/process/docs/diff 门禁;单独提交本地加固,不在未获再次批准时部署。 + ## 1. 生产写入前门禁 - [x] 只读复核活动 server 构建标识、Compose 项目、4 个 systemd service 当前 `BUILD_SHA`、timer enabled/active、最近源端备份与 ARM 新鲜度;输出仅限状态和计数。 @@ -365,3 +374,8 @@ - 候选门禁修复:`run-local-candidate.sh list` 首轮暴露隔离夹具漏建普通 `bash child-script`,修正为按命令行同时发现 `bash` 与 `bash -n` 子脚本;focused 首轮随后揭示 Wave3 合同页在 Radix 菜单仍打开时等待背景“筛选”按钮至 300 秒超时,改为先完成页面操作、最后验证“更多/导出 CSV”。最终本地候选 50 项、10 个核心 witness 全通过,0 failed、0 skipped、0 unexpected,状态为 `FOCUSED_BROWSER_PASS`。 - 门禁治理复验:QA Node 测试 52/52 通过;flow coverage 将 acceptance spec 从 7 项增至 8 项,8/8 有效且 0 blocking gap;前端最终 `typecheck` 与生产 build 明确 `exit=0`,PWA 产物生成完成。 - UX 治理:task validate、Trellis governance、process contract、敏感扫描与 `git diff --check` 通过;文档审计 0 error、1 条既有未注册文档汇总 warning。稳定的 PC 单层来源返回、共享人民币展示及 acceptance spec 必须接入真实门禁的合同已写入 `frontend-react-vite-design` spec。本步骤未部署、未推送、未修改生产配置或真实业务数据。 +- `5dac336` 生产发布事故与恢复:首次尝试把 Compose 配置目录误当仓库根目录,rsync 已把源码同步到错误嵌套位置,随后因重复拼接的 `.env` 路径不存在而停止;旧容器未重建且持续服务,但远端 `.env` 被错误同步删除。恢复只从仍运行的 server 容器按 `.env.example` 允许键提取,163/163 键可恢复、关键键缺失 0,原子写回后权限为 `0600`;未输出值或原始环境。第二次使用正确根目录发布成功并清理错误嵌套代码树,不能表述为一次成功。 +- `5dac336fbf851a4196ee6607e5705ee840dce92f` 生产成功证据:63 个 migration 无待执行项;`/ready`、external deps、runtime smoke 和公网资源扫描通过;公网、活动容器与 4 个运维 service 构建标识匹配,4 个 timer enabled/active,automatic backup daily 与 watchdog 均为 `success/0`。本次未改真实报销、审批或付款数据。 +- 部署加固实现:新增既有部署包装入口,只从 automatic-backup systemd unit 读取 6 个 allowlist 参数且不回显值;远端仓库根、普通非 symlink `.env`、existing PGDATA 与 storage 在 `mkdir`/rsync 前检查。Dockerfile 把 `apk add`、client/server `npm ci` 移到 build SHA 之前;seed/smoke 不再打印完整登录标识、联系方式、企业微信标识、redirect URI 或生成凭据。 +- Docker 双 SHA 实测:第一次冷构建 `server_build` 通过,基础包约 124 秒、client `npm ci` 约 66 秒、server `npm ci` 约 85 秒;仅把前后端构建 SHA 改为另一组值后,三层均明确显示 `CACHED`,第二次约 78 秒,主要剩余前端 typecheck/Vite/PWA 构建与薄的镜像导出。第二个镜像内完整 `BUILD_SHA` 与前端 12 位标识均命中预期。 +- 加固验证:部署安全聚焦测试 7/7;server 全量 172 个文件、1136 项通过,退出码 0;Prisma validate、3 个 shell 语法、4 个 JS 语法、环境合同、task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 均通过。文档审计为 0 error、1 条既有汇总 warning(22 个历史未登记文件)。本步骤只完成本地流程加固,尚未提交、推送或部署。 diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md index c640797c..ff0909d9 100644 --- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md @@ -59,6 +59,8 @@ - 移动草稿详情的“添加发票”只保留在“报销材料”区;底部固定动作栏只保留“提交审批”。不删除发票上传/选择能力,不改变提交前检查、材料权限或审批状态机。 - 旧 R5 真机反馈用例必须跟随当前移动首页信息架构:从常用动作宫格动态采集实际入口,但对经理测试角色固定断言“新建报销、上传票据、请假记录、采购”四项完整集合,并继续验证触控尺寸、单行标签、SVG 图标和无重叠;不得把动态采集写成“采到什么就接受什么”的重言测试。 - R5 不得继续作为无人执行的孤立 spec。将其登记到现有本地候选门禁的 acceptance spec 清单,使 `run-local-candidate.sh` 每次候选回归都执行;其余未被任何门禁引用的历史 E2E 仅记录为后续测试资产治理范围,本轮不批量接入、跳过或删除。 +- 本轮生产发布暴露出既有流程仍依赖人工解释 Compose `working_dir`,且远端 `.env`/数据目录检查发生在 rsync 之后;标准发布必须在任何远端同步前只读确认目标是仓库根目录、`SYNC_ENV=0` 的远端 `.env` 存在、existing 数据目录有效,任一不满足即停止且不执行 rsync。既有部署参数以受控 systemd unit 为 canonical source,不再从 Compose 标签临时拼装路径。 +- Docker 生产镜像的依赖层不得因每次变化的 `BUILD_SHA/VITE_BUILD_SHA` 失效。基础系统包与 client/server lockfile 依赖安装必须位于构建标识注入之前;构建标识仍须进入后端运行时和前端 12 位公开标识。部署流程与 smoke 日志不得输出完整登录标识、联系方式、企业微信配置标识或 Secret。 ## Scope @@ -112,6 +114,9 @@ - [x] PC/移动报销详情的总额、明细和发票金额统一使用共享人民币格式,覆盖整数、小数、千分位、空值和非法值。 - [x] 移动草稿详情“报销材料”区保留“添加发票”,底部固定栏不再重复该动作且“提交审批”仍可见、可达并满足安全区/触控尺寸合同。 - [x] R5 双设备用例按当前四项常用动作合同通过,且已进入本地候选门禁的 acceptance spec 清单;门禁资产解析能列出该 spec,不再依赖手工全目录执行才发现漂移。 +- [x] 标准部署在 rsync 前验证仓库根目录、远端 `.env` 与 existing 数据目录;错误根目录或缺失 `.env` 的合成回归与脚本顺序断言证明失败发生在 rsync 之前。 +- [x] Dockerfile 顺序合同及双 SHA 实际构建证明提交 SHA 变化不会使 `apk add`、client `npm ci` 或 server `npm ci` 层失效,同时前端短 SHA 与运行镜像完整 SHA 仍可核对;`/ready` 合同未改动。 +- [x] 部署 runbook/spec 固化既有部署参数来源、只读 preflight、失败停止点和脱敏日志边界;本地验证通过后单独提交,不把该未部署的流程加固冒充为已上线。 ## Out of Scope diff --git a/deploy/trialrun/Dockerfile b/deploy/trialrun/Dockerfile index 62521f8d..8101fee0 100644 --- a/deploy/trialrun/Dockerfile +++ b/deploy/trialrun/Dockerfile @@ -1,10 +1,5 @@ FROM node:22-alpine AS base -ARG BUILD_SHA=unknown -ARG VITE_BUILD_SHA=unknown -ENV BUILD_SHA=$BUILD_SHA -ENV VITE_BUILD_SHA=$VITE_BUILD_SHA - WORKDIR /app RUN apk add --no-cache bash curl @@ -14,6 +9,8 @@ COPY client/package.json client/package-lock.json ./client/ RUN cd client && npm ci FROM client_deps AS client_build +ARG VITE_BUILD_SHA=unknown +ENV VITE_BUILD_SHA=$VITE_BUILD_SHA COPY client ./client RUN cd client && npm run build @@ -23,6 +20,8 @@ COPY server/prisma ./server/prisma RUN cd server && npm ci FROM server_deps AS server_build +ARG BUILD_SHA=unknown +ENV BUILD_SHA=$BUILD_SHA COPY server ./server COPY scripts ./scripts COPY --from=client_build /app/client/dist ./client/dist diff --git a/deploy/trialrun/checklist.md b/deploy/trialrun/checklist.md index 471ba0cd..40c9140c 100644 --- a/deploy/trialrun/checklist.md +++ b/deploy/trialrun/checklist.md @@ -71,7 +71,10 @@ - [ ] 本地工作树 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 +- [ ] 已有部署通过 `DEPLOY_SERVER= bash deploy/trialrun/deploy-existing.sh` 执行;首次初始化才使用完整显式参数的 `deploy.sh` +- [ ] 未从 Compose `working_dir` 猜仓库根目录;同步前 preflight 已确认仓库根标识、普通远端 `.env`、PG_VERSION 与附件目录,且该检查发生在 rsync 之前 +- [ ] 脚本自动注入完整后端 SHA 与 12 位前端 SHA,并完成构建、init 与 smoke +- [ ] 构建日志显示 lockfile 未变化时 client/server 依赖层使用缓存;build SHA 变化没有触发 `apk add` 或两次 `npm ci` - [ ] 发布命令显式携带批准的新 project、两个绝对数据路径和 `DEPLOY_DATA_MODE=existing`;未依赖 `.env.example` 相对默认值 - [ ] 若为首次初始化或受控应急而直接使用 Compose,已显式设置 `BUILD_SHA=` 与 `VITE_BUILD_SHA=`;未使用 `unknown` - [ ] 探活: diff --git a/deploy/trialrun/deploy-existing.sh b/deploy/trialrun/deploy-existing.sh new file mode 100755 index 00000000..dba1dd67 --- /dev/null +++ b/deploy/trialrun/deploy-existing.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DEPLOY_SERVER="${DEPLOY_SERVER:-}" +DEPLOY_CONTRACT_UNIT="${DEPLOY_CONTRACT_UNIT:-hyb-oa-automatic-backup-daily.service}" + +fail() { + echo "[deploy-existing][ERROR] $1" >&2 + exit 1 +} + +[[ "$DEPLOY_SERVER" =~ ^([A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$ ]] \ + || fail "必须显式提供安全的 DEPLOY_SERVER" +[[ "$DEPLOY_CONTRACT_UNIT" =~ ^[A-Za-z0-9_.@-]+\.service$ ]] \ + || fail "DEPLOY_CONTRACT_UNIT 格式无效" +command -v ssh >/dev/null 2>&1 || fail "缺少 ssh" + +unit_environment="$(ssh "$DEPLOY_SERVER" \ + "systemctl show '${DEPLOY_CONTRACT_UNIT}' -p Environment --value")" +[[ -n "$unit_environment" ]] || fail "无法读取既有部署合同 unit" + +declare -A contract=() +for entry in $unit_environment; do + key="${entry%%=*}" + value="${entry#*=}" + case "$key" in + REMOTE_DIR|REMOTE_ENV_FILE|COMPOSE_FILE|COMPOSE_PROJECT_NAME|POSTGRES_DATA_DIR|STORAGE_HOST_DIR) + contract["$key"]="$value" + ;; + esac +done + +for key in REMOTE_DIR REMOTE_ENV_FILE COMPOSE_FILE COMPOSE_PROJECT_NAME POSTGRES_DATA_DIR STORAGE_HOST_DIR; do + [[ -n "${contract[$key]:-}" ]] || fail "既有部署合同缺少 $key" +done + +echo "[deploy-existing] 已读取受控 unit 合同;路径与标识不回显" +DEPLOY_SERVER="$DEPLOY_SERVER" \ +REMOTE_DIR="${contract[REMOTE_DIR]}" \ +REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \ +COMPOSE_FILE="${contract[COMPOSE_FILE]}" \ +COMPOSE_PROJECT_NAME="${contract[COMPOSE_PROJECT_NAME]}" \ +POSTGRES_DATA_DIR="${contract[POSTGRES_DATA_DIR]}" \ +STORAGE_HOST_DIR="${contract[STORAGE_HOST_DIR]}" \ +DEPLOY_DATA_MODE=existing \ +SYNC_ENV=0 \ +bash "$ROOT_DIR/deploy/trialrun/deploy.sh" diff --git a/deploy/trialrun/deploy.sh b/deploy/trialrun/deploy.sh index 21e860cc..821d4d8a 100755 --- a/deploy/trialrun/deploy.sh +++ b/deploy/trialrun/deploy.sh @@ -21,6 +21,7 @@ ENV_CONTRACT_SCRIPT="deploy/trialrun/validate-env-contract.js" RUNTIME_SMOKE_SCRIPT="server/src/scripts/smoke-trialrun-runtime.js" EXTERNAL_DEPS_SMOKE_SCRIPT="server/src/scripts/smoke-external-deps.js" PROVENANCE_SCRIPT="deploy/trialrun/check-source-provenance.mjs" +REMOTE_PREFLIGHT_SCRIPT="deploy/trialrun/preflight-remote.sh" STAGING_DIR="" RSYNC_PROTECT_ARGS=() @@ -148,6 +149,11 @@ if [[ ! -f "${ROOT_DIR}/${EXTERNAL_DEPS_SMOKE_SCRIPT}" ]]; then exit 1 fi +if [[ ! -f "${ROOT_DIR}/${REMOTE_PREFLIGHT_SCRIPT}" ]]; then + echo "[ERROR] 缺少 ${REMOTE_PREFLIGHT_SCRIPT}" >&2 + exit 1 +fi + if [[ "${SYNC_ENV}" == "1" && ! -f "${ROOT_DIR}/${LOCAL_ENV_FILE}" ]]; then echo "[ERROR] SYNC_ENV=1 但本地不存在 ${LOCAL_ENV_FILE}" >&2 exit 1 @@ -160,7 +166,12 @@ echo ">>> 2. 从已验证提交生成不可变同步目录" STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/hyb-oa-deploy.XXXXXX")" git -C "$ROOT_DIR" archive --format=tar "$SOURCE_COMMIT" | tar -xf - -C "$STAGING_DIR" -echo ">>> 3. 创建远端目录" +echo ">>> 3. 同步前只读校验远端部署合同" +ssh "${DEPLOY_SERVER}" \ + "REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' POSTGRES_DATA_DIR='${POSTGRES_DATA_DIR}' STORAGE_HOST_DIR='${STORAGE_HOST_DIR}' DEPLOY_DATA_MODE='${DEPLOY_DATA_MODE}' SYNC_ENV='${SYNC_ENV}' bash -s" \ + < "${ROOT_DIR}/${REMOTE_PREFLIGHT_SCRIPT}" + +echo ">>> 3.1 创建远端目录" ssh "${DEPLOY_SERVER}" "mkdir -p '${REMOTE_DIR}'" echo ">>> 4. 同步已验证提交到服务器" diff --git a/deploy/trialrun/preflight-remote.sh b/deploy/trialrun/preflight-remote.sh new file mode 100755 index 00000000..24a9c713 --- /dev/null +++ b/deploy/trialrun/preflight-remote.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +fail() { + echo "[remote-preflight][ERROR] $1" >&2 + exit 1 +} + +for key in REMOTE_DIR REMOTE_ENV_FILE COMPOSE_FILE POSTGRES_DATA_DIR STORAGE_HOST_DIR DEPLOY_DATA_MODE SYNC_ENV; do + [[ -n "${!key:-}" ]] || fail "缺少 $key" +done + +if [[ "$DEPLOY_DATA_MODE" == "existing" ]]; then + [[ -d "$REMOTE_DIR" \ + && -f "$REMOTE_DIR/server/package.json" \ + && -f "$REMOTE_DIR/$COMPOSE_FILE" ]] \ + || fail "existing 模式要求 REMOTE_DIR 是现存仓库根目录" + [[ -f "$POSTGRES_DATA_DIR/PG_VERSION" && -d "$STORAGE_HOST_DIR" ]] \ + || fail "existing 模式的数据目录合同未满足" +fi + +if [[ "$SYNC_ENV" == "0" ]]; then + [[ -f "$REMOTE_ENV_FILE" && ! -L "$REMOTE_ENV_FILE" ]] \ + || fail "SYNC_ENV=0 要求同步前已有普通远端 .env 文件" +fi + +echo "[remote-preflight][PASS] 远端仓库、环境与数据合同通过" diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md index 6137cde8..33975003 100644 --- a/deploy/trialrun/runbook.md +++ b/deploy/trialrun/runbook.md @@ -40,6 +40,7 @@ curl -fsS - 反向代理:`caddy` 正在运行,`oa.hyinda.com` 应反代到 `.env` 中 `APP_HOST_PORT` 对应的宿主回环端口。 - 部署目录通过 `REMOTE_DIR` 显式传入。 - 远端目录当前不是 git 工作树;发布应优先从本地执行 `bash deploy/trialrun/deploy.sh`,由脚本 `rsync` 到服务器 +- 已有部署优先执行 `DEPLOY_SERVER= bash deploy/trialrun/deploy-existing.sh`;包装脚本只从受控 automatic-backup systemd unit 读取 allowlist 参数且不回显路径。不得把 Compose `com.docker.compose.project.working_dir` 当仓库根目录,该标签可能指向 `deploy/trialrun` 配置目录。 - 远端容器编排当前实际为 `docker-compose 1.29.2`;本 runbook 命令以 `docker-compose` 为准 - 所有 Compose 命令必须显式使用同一个 `COMPOSE_PROJECT_NAME` 和 `-p`;不得依赖目录名推导项目身份,也不得写死容器名。 - 当前公网 `.env` 已确认:`APP_ORIGIN=https://oa.hyinda.com`、`COOKIE_SECURE=true`、`PUBLIC_SECURITY_HARDENING_ENABLED=true` @@ -121,7 +122,7 @@ docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml 说明: - `init` 是一次性任务:`migrate:deploy + seed:config + seed:trialrun`。`seed:config` 仅在审批规则表为空时创建默认三档;已有任意审批规则时必须全部保留,生产规则变更另走获批的原子事务。 - 初始化必须发生在依赖新增列的新 server 切换之前;正式部署脚本会先用新 init 镜像校验生产配置并执行迁移,再替换 server。 -- 若 `TRIALRUN_ADMIN_PASSWORD` 为空,seed 会生成一次性口令并输出到控制台(请立刻保存)。 +- 首次创建试运行管理员前必须通过受控 `.env` 提供 `TRIALRUN_ADMIN_PASSWORD`;seed 不生成或输出口令,登录标识也不进入部署日志。 - 正式启用前,推荐先保持“空基线 + 默认种子”,不要直接导入开发库、测试库或旧 OA 整库备份。 - 如确需导入,仅导入最小必要主数据:部门、员工、角色、在用资产、活跃项目、合同元数据。 - 历史审批单、通知历史、审计日志、聊天记录以及涉密正文/附件继续保留在原系统、文件盘或离线档案中。 @@ -392,7 +393,10 @@ systemctl status hyb-oa-public-app-health.service --no-pager 步骤: 1) 先做备份(见 E14 脚本) -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 文件带入发布。`REMOTE_DIR` 必须与 PostgreSQL、附件宿主目录完全分离;任一方向嵌套都会在 SSH 和 `rsync --delete` 前阻断。位于同步树内的 `REMOTE_ENV_FILE` 按实际相对路径动态保护。 +2) 已有部署从本地仓库执行 `DEPLOY_SERVER= bash deploy/trialrun/deploy-existing.sh`(推荐);它从受控 systemd unit 读取既有 `REMOTE_DIR`、env、Compose project 与数据目录合同,不从 Docker 标签猜路径,也不回显参数值。首次初始化或 unit 不存在时,才使用完整显式参数执行 `deploy.sh`。 + - `deploy.sh` 只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。 + - 在任何 rsync 前,远端 preflight 必须确认 existing 目标同时含 `server/package.json` 与 Compose 文件、PGDATA 含 `PG_VERSION`、storage 存在,且 `SYNC_ENV=0` 的 `.env` 是普通文件。失败即停止,不能先同步再验证。 + - `REMOTE_DIR` 必须与 PostgreSQL、附件宿主目录完全分离;位于同步树内的 `REMOTE_ENV_FILE` 按精确相对路径动态保护。 - 在现网目录统一任务中,新的 `COMPOSE_PROJECT_NAME`、`POSTGRES_DATA_DIR`、`STORAGE_HOST_DIR` 必须与活动数据目录复制在同一停机窗口启用;本地提交本身不授权切换。 - 旧栈停止命令必须使用冻结阶段由 `docker compose ls` 实测的旧 project;不得拿新 project 名去停止旧容器。 - `artifacts/` 和 `output/` 是运行期证据/截图目录,不作为源码发布输入,也不由 `rsync --delete` 清理远端历史证据。 diff --git a/deploy/trialrun/validate-env-contract.js b/deploy/trialrun/validate-env-contract.js index b7a4762b..85163c06 100755 --- a/deploy/trialrun/validate-env-contract.js +++ b/deploy/trialrun/validate-env-contract.js @@ -16,6 +16,9 @@ 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 deployExistingScriptPath = path.join(rootDir, 'deploy/trialrun/deploy-existing.sh'); +const remotePreflightScriptPath = path.join(rootDir, 'deploy/trialrun/preflight-remote.sh'); +const dockerfilePath = path.join(rootDir, 'deploy/trialrun/Dockerfile'); const timerScriptPath = path.resolve( process.env.ENV_CONTRACT_TIMER_SCRIPT_PATH || path.join(rootDir, 'deploy/trialrun/install-warch3-ops-timer.sh'), @@ -155,6 +158,9 @@ const composeContent = readFile(composePath); const runbookContent = readFile(runbookPath); const checklistContent = readFile(checklistPath); const deployScriptContent = readFile(deployScriptPath); +const deployExistingScriptContent = readFile(deployExistingScriptPath); +const remotePreflightScriptContent = readFile(remotePreflightScriptPath); +const dockerfileContent = readFile(dockerfilePath); const timerScriptContent = readFile(timerScriptPath); const automaticBackupTimerScriptContent = readFile(automaticBackupTimerScriptPath); const offhostReceiverInstallerContent = readFile(offhostReceiverInstallerPath); @@ -439,12 +445,52 @@ for (const requiredFragment of [ 'DEPLOY_DATA_MODE" != "existing"', 'DEPLOY_DATA_MODE" != "initialize"', '"$POSTGRES_DATA_DIR/PG_VERSION"', + '>>> 3. 同步前只读校验远端部署合同', + 'REMOTE_PREFLIGHT_SCRIPT="deploy/trialrun/preflight-remote.sh"', ]) { if (!deployScriptContent.includes(requiredFragment)) { errors.push(`deploy.sh 缺少正式部署数据目录护栏: ${requiredFragment}`); } } +if (deployScriptContent.indexOf('>>> 3. 同步前只读校验远端部署合同') + > deployScriptContent.indexOf('rsync -az --delete')) { + errors.push('deploy.sh 必须在 rsync 前执行远端只读 preflight'); +} + +for (const requiredFragment of [ + 'systemctl show', + 'DEPLOY_DATA_MODE=existing', + 'SYNC_ENV=0', + '路径与标识不回显', +]) { + if (!deployExistingScriptContent.includes(requiredFragment)) { + errors.push(`deploy-existing.sh 缺少既有部署合同: ${requiredFragment}`); + } +} + +for (const requiredFragment of [ + 'REMOTE_DIR 是现存仓库根目录', + 'SYNC_ENV=0 要求同步前已有普通远端 .env 文件', + '"$POSTGRES_DATA_DIR/PG_VERSION"', +]) { + if (!remotePreflightScriptContent.includes(requiredFragment)) { + errors.push(`preflight-remote.sh 缺少同步前护栏: ${requiredFragment}`); + } +} + +for (const [earlier, later] of [ + ['RUN apk add --no-cache bash curl', 'ARG VITE_BUILD_SHA'], + ['RUN cd client && npm ci', 'ARG VITE_BUILD_SHA'], + ['RUN cd server && npm ci', 'ARG BUILD_SHA'], +]) { + if (dockerfileContent.indexOf(earlier) === -1 + || dockerfileContent.indexOf(later) === -1 + || dockerfileContent.indexOf(earlier) > dockerfileContent.indexOf(later)) { + errors.push(`Dockerfile 缓存顺序无效: ${earlier} 必须早于 ${later}`); + } +} + for (const { filePath, content } of nginxContents) { const relativePath = path.relative(rootDir, filePath); if (content.includes('$proxy_add_x_forwarded_for')) { diff --git a/server/src/scripts/seed-trialrun.js b/server/src/scripts/seed-trialrun.js index 91f9757b..c4c3248a 100644 --- a/server/src/scripts/seed-trialrun.js +++ b/server/src/scripts/seed-trialrun.js @@ -1,7 +1,5 @@ require("dotenv/config"); -const crypto = require("node:crypto"); - const { createPrismaClient } = require("../prismaClient"); const { hashPassword } = require("../utils/crypto"); const { validatePassword } = require("../utils/passwordPolicy"); @@ -94,12 +92,6 @@ async function ensureDepartment(prisma, payload) { }); } -function generateOneTimePassword() { - // 确保满足 passwordPolicy:>=12 + 小写 + 大写 + 数字 - const suffix = crypto.randomBytes(6).toString("hex"); - return `TrialrunA1${suffix}`; -} - async function ensureFeatureFlag(prisma, payload) { const now = new Date(); const existing = await prisma.featureFlag.findUnique({ @@ -248,7 +240,10 @@ async function ensureAdminEmployee(prisma, params) { return { employeeId: existing.id, created: false, password: null }; } - const password = params.password || generateOneTimePassword(); + if (!params.password) { + throw new Error('TRIALRUN_ADMIN_PASSWORD is required when creating the trialrun admin; credentials are never generated into deployment logs'); + } + const password = params.password; const errors = validatePassword(password); if (errors.length) { throw new Error(`Invalid TRIALRUN_ADMIN_PASSWORD: ${errors.join("; ")}`); @@ -348,9 +343,7 @@ async function main() { // eslint-disable-next-line no-console console.log("Seeded trialrun admin:"); // eslint-disable-next-line no-console - console.log(`- email: ${adminEmail}`); - // eslint-disable-next-line no-console - console.log(`- phone: ${adminPhone}`); + console.log("- login identifiers: configured"); // eslint-disable-next-line no-console console.log(`- created: ${result.created}`); // eslint-disable-next-line no-console @@ -361,9 +354,7 @@ async function main() { } if (result.password) { // eslint-disable-next-line no-console - console.log(`- one-time password: ${result.password}`); - // eslint-disable-next-line no-console - console.log(" (请立刻保存,并在首次登录后由管理员重置/更换密码)"); + console.log("- initial password: supplied by approved configuration; not logged"); } else { // eslint-disable-next-line no-console console.log("- password: unchanged"); diff --git a/server/src/scripts/smoke-external-deps.js b/server/src/scripts/smoke-external-deps.js index 9cf4eb97..abd0b286 100644 --- a/server/src/scripts/smoke-external-deps.js +++ b/server/src/scripts/smoke-external-deps.js @@ -31,7 +31,7 @@ function evaluateWecom(config) { return { state: 'degraded', detail: `企业微信基础配置已启用,但入口=${config.wecomEntryEnabled ? 'on' : 'off'} oauth=${config.wecomOauthEnabled ? 'on' : 'off'}`, - checks: [`corpId=${config.wecomCorpId}`, `agentId=${config.wecomAgentId}`], + checks: ['corpId=present', 'agentId=present'], }; } @@ -39,9 +39,9 @@ function evaluateWecom(config) { state: 'ready', detail: '企业微信入口与免登配置齐全', checks: [ - `corpId=${config.wecomCorpId}`, - `agentId=${config.wecomAgentId}`, - `redirectUri=${config.wecomRedirectUri}`, + 'corpId=present', + 'agentId=present', + 'redirectUri=present', `pcWebLogin=${config.wecomPcWebLoginEnabled ? 'on' : 'off'}`, ], }; @@ -66,7 +66,7 @@ function evaluateEmail(config) { return { state: 'ready', detail: 'SMTP 基础配置齐全(仅做配置契约校验,不发真实邮件)', - checks: [`from=${config.emailFrom}`, `host=${config.smtpHost}`, `port=${config.smtpPort}`, `secure=${config.smtpSecure}`], + checks: ['from=present', 'host=present', 'port=valid', `secure=${config.smtpSecure}`], }; } @@ -88,7 +88,7 @@ function evaluateOcr(config) { return { state: 'ready', detail: '腾讯 OCR 配置齐全', - checks: [`provider=${provider}`, `region=${config.tencentOcrRegion}`], + checks: [`provider=${provider}`, 'credentials=present', 'region=present'], }; } @@ -112,7 +112,7 @@ function evaluateBridge(config) { return { state: 'ready', detail: '消息回流桥接配置齐全', - checks: [`appOrigin=${config.appOrigin}`], + checks: ['appOrigin=present'], }; } diff --git a/server/src/scripts/smoke-trialrun-runtime.js b/server/src/scripts/smoke-trialrun-runtime.js index 68fe8f8a..3fe17364 100644 --- a/server/src/scripts/smoke-trialrun-runtime.js +++ b/server/src/scripts/smoke-trialrun-runtime.js @@ -203,9 +203,9 @@ async function main() { }); } - assertCondition(Boolean(adminEmployee), `未找到 active admin 员工:${adminEmail}`); + assertCondition(Boolean(adminEmployee), '未找到 active admin 员工'); assertCondition(adminEmployee.status === 'active' && !adminEmployee.archived_at, `active admin 状态异常:${adminEmployee.status}`); - console.log(`[PASS] 已找到 active admin 员工:${adminEmployee.employee_no}`); + console.log('[PASS] 已找到 active admin 员工'); const workspace = await getWorkspaceSetting(prisma); assertWorkspaceSetting(workspace); diff --git a/server/tests/unit/scripts/deployment-safety-contract.test.js b/server/tests/unit/scripts/deployment-safety-contract.test.js new file mode 100644 index 00000000..5af74348 --- /dev/null +++ b/server/tests/unit/scripts/deployment-safety-contract.test.js @@ -0,0 +1,107 @@ +const { execFile } = require("node:child_process"); +const { mkdtemp, mkdir, rm, symlink, writeFile } = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const { promisify } = require("node:util"); + +const execFileAsync = promisify(execFile); +const ROOT_DIR = path.resolve(__dirname, "../../../.."); +const PREFLIGHT = path.join(ROOT_DIR, "deploy/trialrun/preflight-remote.sh"); + +describe("deployment safety contract", () => { + let fixtureRoot; + let remoteDir; + let envFile; + let postgresDir; + let storageDir; + + beforeEach(async () => { + fixtureRoot = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-deploy-safety-")); + remoteDir = path.join(fixtureRoot, "app"); + envFile = path.join(remoteDir, "deploy/trialrun/.env"); + postgresDir = path.join(fixtureRoot, "postgres"); + storageDir = path.join(fixtureRoot, "storage"); + await mkdir(path.join(remoteDir, "server"), { recursive: true }); + await mkdir(path.join(remoteDir, "deploy/trialrun"), { recursive: true }); + await mkdir(postgresDir, { recursive: true }); + await mkdir(storageDir, { recursive: true }); + await writeFile(path.join(remoteDir, "server/package.json"), "{}\n"); + await writeFile(path.join(remoteDir, "deploy/trialrun/docker-compose.yml"), "services: {}\n"); + await writeFile(envFile, "APP_ORIGIN=https://example.invalid\n", { mode: 0o600 }); + await writeFile(path.join(postgresDir, "PG_VERSION"), "16\n"); + }); + + afterEach(async () => { + await rm(fixtureRoot, { recursive: true, force: true }); + }); + + function preflight(overrides = {}) { + return execFileAsync("bash", [PREFLIGHT], { + env: { + ...process.env, + REMOTE_DIR: remoteDir, + REMOTE_ENV_FILE: envFile, + COMPOSE_FILE: "deploy/trialrun/docker-compose.yml", + POSTGRES_DATA_DIR: postgresDir, + STORAGE_HOST_DIR: storageDir, + DEPLOY_DATA_MODE: "existing", + SYNC_ENV: "0", + ...overrides, + }, + }); + } + + it("accepts a complete existing deployment contract", async () => { + await expect(preflight()).resolves.toMatchObject({ stdout: expect.stringContaining("[remote-preflight][PASS]") }); + }); + + it("rejects a config directory masquerading as the repository root", async () => { + await expect(preflight({ REMOTE_DIR: path.join(remoteDir, "deploy/trialrun") })).rejects.toMatchObject({ code: 1 }); + }); + + it("rejects a missing or symbolic remote env before synchronization", async () => { + await rm(envFile); + await expect(preflight()).rejects.toMatchObject({ code: 1 }); + await symlink(path.join(remoteDir, "missing.env"), envFile); + await expect(preflight()).rejects.toMatchObject({ code: 1 }); + }); + + it("rejects invalid existing data directories", async () => { + await rm(path.join(postgresDir, "PG_VERSION")); + await expect(preflight()).rejects.toMatchObject({ code: 1 }); + }); + + it("runs remote preflight before rsync and reads existing contracts from systemd", async () => { + const deploy = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "deploy/trialrun/deploy.sh"), "utf8"); + const wrapper = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "deploy/trialrun/deploy-existing.sh"), "utf8"); + expect(deploy.indexOf("REMOTE_PREFLIGHT_SCRIPT")).toBeGreaterThanOrEqual(0); + expect(deploy.indexOf('>>> 3. 同步前只读校验远端部署合同')).toBeLessThan(deploy.indexOf("rsync -az --delete")); + expect(deploy.indexOf('>>> 3. 同步前只读校验远端部署合同')).toBeLessThan(deploy.indexOf("mkdir -p '${REMOTE_DIR}'")); + expect(wrapper).toContain("systemctl show '${DEPLOY_CONTRACT_UNIT}' -p Environment --value"); + expect(wrapper).not.toContain("docker inspect"); + expect(wrapper).not.toContain("working_dir"); + expect(wrapper).toContain("DEPLOY_DATA_MODE=existing"); + expect(wrapper).toContain("SYNC_ENV=0"); + }); + + it("keeps immutable build args after stable dependency layers", async () => { + const dockerfile = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "deploy/trialrun/Dockerfile"), "utf8"); + expect(dockerfile.indexOf("RUN apk add --no-cache bash curl")).toBeLessThan(dockerfile.indexOf("ARG VITE_BUILD_SHA")); + expect(dockerfile.indexOf("RUN cd client && npm ci")).toBeLessThan(dockerfile.indexOf("ARG VITE_BUILD_SHA")); + expect(dockerfile.indexOf("RUN cd server && npm ci")).toBeLessThan(dockerfile.indexOf("ARG BUILD_SHA")); + }); + + it("keeps deployment smoke output free of raw account and external identifiers", async () => { + const seed = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "server/src/scripts/seed-trialrun.js"), "utf8"); + const external = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "server/src/scripts/smoke-external-deps.js"), "utf8"); + const runtime = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "server/src/scripts/smoke-trialrun-runtime.js"), "utf8"); + expect(seed).not.toContain("${adminEmail}"); + expect(seed).not.toContain("${adminPhone}"); + expect(seed).not.toContain("${result.password}"); + expect(seed).not.toContain("generateOneTimePassword"); + expect(external).not.toContain("corpId=${config.wecomCorpId}"); + expect(external).not.toContain("redirectUri=${config.wecomRedirectUri}"); + expect(external).not.toContain("from=${config.emailFrom}"); + expect(runtime).not.toContain("${adminEmployee.employee_no}"); + }); +});