加固既有生产部署流程(07-27-yang-jun-reimbursement-readiness)

这个提交包含在:
2026-08-01 18:25:20 +08:00
父节点 5dac336fbf
当前提交 07de198b51
共修改 15 个文件,包含 375 行新增和 34 行删除
+61 -1
查看文件
@@ -7,7 +7,7 @@ operations require explicit approval and evidence.
## Rules
- Use `SYNC_ENV=0 POSTGRES_DATA_DIR=<absolute> STORAGE_HOST_DIR=<absolute> DEPLOY_DATA_MODE=<existing|initialize> bash deploy/trialrun/deploy.sh` only when deployment is approved; do not overwrite remote `.env`.
- Existing installations use `DEPLOY_SERVER=<ssh-target> 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
<explicit-controlled-paths> \
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=<ssh-target> 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 <server> --format '<working_dir-label>')" \
bash deploy/trialrun/deploy.sh
```
#### Correct
```bash
DEPLOY_SERVER=<approved-ssh-target> \
bash deploy/trialrun/deploy-existing.sh
```
@@ -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 缓存顺序与日志禁敏片段均有静态断言。
@@ -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 个历史未登记文件)。本步骤只完成本地流程加固,尚未提交、推送或部署。
@@ -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
+4 -5
查看文件
@@ -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
+4 -1
查看文件
@@ -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=<ssh-target> 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=<approved-full-commit>` 与 `VITE_BUILD_SHA=<matching-12-char-commit>`;未使用 `unknown`
- [ ] 探活:
+48
查看文件
@@ -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"
+12 -1
查看文件
@@ -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. 同步已验证提交到服务器"
+27
查看文件
@@ -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] 远端仓库、环境与数据合同通过"
+6 -2
查看文件
@@ -40,6 +40,7 @@ curl -fsS <shadow-ready-url>
- 反向代理:`caddy` 正在运行,`oa.hyinda.com` 应反代到 `.env` 中 `APP_HOST_PORT` 对应的宿主回环端口。
- 部署目录通过 `REMOTE_DIR` 显式传入。
- 远端目录当前不是 git 工作树;发布应优先从本地执行 `bash deploy/trialrun/deploy.sh`,由脚本 `rsync` 到服务器
- 已有部署优先执行 `DEPLOY_SERVER=<ssh-target> 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=<ssh-target> REMOTE_DIR=<remote-dir> COMPOSE_PROJECT_NAME=<approved-compose-project> POSTGRES_DATA_DIR=<absolute-pgdata> STORAGE_HOST_DIR=<absolute-storage> 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=<ssh-target> 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` 清理远端历史证据。
+46
查看文件
@@ -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')) {
+6 -15
查看文件
@@ -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");
+7 -7
查看文件
@@ -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'],
};
}
+2 -2
查看文件
@@ -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);
@@ -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}");
});
});