实现(08-08-production-deployment-execution): 校准生产身份只读门禁
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-08 17:33:25 +08:00
父节点 6ccb9cd5e3
当前提交 f11e2f2c25
共修改 11 个文件,包含 666 行新增和 30 行删除
@@ -19,6 +19,69 @@ operations require explicit approval and evidence.
- Do not execute real payments, import bank statements, connect payment - Do not execute real payments, import bank statements, connect payment
providers or write ERP without a separate approved Trellis task. providers or write ERP without a separate approved Trellis task.
## Scenario: Exact Production Identity Bootstrap Before Migration Status
### 1. Scope / Trigger
- Trigger: the public environment has a legacy name that resembles a trial or staging environment, or the production database identity is intentionally absent from the repository.
- Purpose: identify the one approved deployment by independent exact values without treating a name token as proof for or against production.
### 2. Signatures
- Bootstrap entry: `node deploy/trialrun/production-identity-bootstrap.mjs`.
- Read-only stop: `PRODUCTION_READ_ONLY_PREFLIGHT=1 node deploy/trialrun/production-identity-bootstrap.mjs`.
- Exact local inputs passed to the existing wrapper: `EXPECTED_DEPLOY_SERVER`, `EXPECTED_REMOTE_DIR`, `EXPECTED_COMPOSE_PROJECT_NAME`, `EXPECTED_PRODUCTION_ORIGIN` and `EXPECTED_PRODUCTION_DB_NAME_SHA256`.
### 3. Contracts
- Recover deployment target, repository root and Compose project from reviewed Git history; when values come from different commits, require their shared identity fields to agree.
- Select the refresh identity only when exactly one concrete SSH candidate returns the exact forced-command probe token. Do not print candidate aliases.
- Compare target, directory and project independently. Missing or empty expected values are errors, not reasons to skip a comparison.
- A legacy token such as `trialrun` is neutral. Continue to reject loopback/zero addresses, local regression, staging, shadow and placeholder identities.
- Read `POSTGRES_DB` only after history, systemd contract and public origin agree. Keep the database name remote; pass only a hash comparison baseline to the preflight.
- Read-only mode must exit after target/pre-status and rollback-window checks and before `deploy.sh`, rsync, migration or application switching.
### 4. Validation & Error Matrix
- Zero refresh probe matches -> `REFRESH_IDENTITY_NOT_FOUND`; stop before target gate.
- Multiple exact probe matches -> `REFRESH_IDENTITY_NOT_UNIQUE`; do not select the first candidate.
- Historical target/directory/project disagreement -> an explicit `HISTORICAL_*` or `SYSTEMD_*_MISMATCH`; stop before database identity read.
- Any expected exact value missing/empty -> `EXPECTED_*_MISSING`; no SSH preflight fallback.
- Env origin or running-container origin differs from the approved origin -> `PROD_TARGET_*_ORIGIN_MISMATCH`; do not read migration status.
- Read-only mode reaches `deploy.sh` -> contract failure; tests and env validation must reject the ordering.
### 5. Good/Base/Bad Cases
- Good: a legacy-named deployment passes because all independent identity values match exactly, then read-only mode records status/window and exits before writes.
- Base: an explicitly supplied, unambiguous production identity passes the same exact comparisons.
- Bad: reject or accept solely because the alias/project contains or omits `trialrun`.
- Bad: database name lookup fails and the wrapper continues without comparing database identity.
### 6. Tests Required
- Positive fixture for the approved legacy naming pattern.
- Separate negative assertions and stable error codes for deploy target, remote directory and Compose project mismatches.
- Missing-value tests proving exact comparison cannot be skipped.
- Zero/multiple refresh-probe fixtures.
- Ordering assertion: identity/DB bootstrap -> target/pre-status/window -> read-only exit -> `deploy.sh` only when read-only mode is off.
- Evidence assertions proving target/path/database values are absent from stdout and committed reports.
### 7. Wrong vs Correct
#### Wrong
```text
name does not contain staging -> assume production -> run migrate deploy
```
#### Correct
```text
reviewed history + unique refresh probe + exact systemd contract + exact origin
-> remote-only DB identity hash -> target/pre-status/window
-> explicit read-only stop or separately approved deploy
```
## Scenario: Existing Deployment SSH Identity Disambiguation ## Scenario: Existing Deployment SSH Identity Disambiguation
### 1. Scope / Trigger ### 1. Scope / Trigger
@@ -41,11 +41,11 @@
### 4.1 只读身份引导 ### 4.1 只读身份引导
1. 从已审查历史提交 `34181e3` 的部署入口,在本地进程内恢复既有 `DEPLOY_SERVER`、`REMOTE_DIR` 和 `COMPOSE_PROJECT_NAME`;不输出字面值。 1. 从已审查历史提交 `34181e3` 恢复既有 `DEPLOY_SERVER`、`REMOTE_DIR`,从首次显式冻结 Compose project 的提交 `5e2d03893826` 恢复 `COMPOSE_PROJECT_NAME`;两份历史记录的 `REMOTE_DIR` 必须一致,且不输出任何字面值。
2. 从本机受控 SSH 配置无回显探测 refresh 候选,只有 exact forced-command `probe` 唯一 PASS 时才采用;零个或多个候选均停止。 2. 从本机受控 SSH 配置无回显探测 refresh 候选,只有 exact forced-command `probe` 唯一 PASS 时才采用;零个或多个候选均停止。
3. 使用恢复的部署目标只读读取受控 systemd contract,并要求 contract 的远端目录、Compose project 及其余路径合同通过 13 项既有校验;历史身份与当前 contract 不一致即停止。 3. 使用恢复的部署目标只读读取受控 systemd contract,并要求 contract 的远端目录、Compose project 及其余路径合同通过 13 项既有校验;历史身份与当前 contract 不一致即停止。
4. 从 OPS7 当前公网检查基准取得预期 HTTPS origin;只读比较远端 `.env` 和运行容器的 `APP_ORIGIN`,一致后才认为目标已被独立识别。 4. 从 OPS7 当前公网检查基准取得预期 HTTPS origin;只读比较远端 `.env` 和运行容器的 `APP_ORIGIN`,一致后才认为目标已被独立识别。
5. 仅在前四步通过后,从同一远端 `.env` 读取 `POSTGRES_DB` 到当前进程变量;不打印、不写文件。它作为已确认目标的数据库身份基线,用于后续 env、`current_database()` 和运行时 `DATABASE_URL` 三方一致性核对。 5. 仅在前四步通过后,从同一远端 `.env` 读取 `POSTGRES_DB` 并在远端转换为 SHA-256 比较基线;数据库名明文不离开远端、不打印、不写文件。该基线用于后续 env、`current_database()` 和运行时 `DATABASE_URL` 三方一致性核对。
数据库名读取不是“连接任意目标后相信其自述”:它必须排在历史目标、远端 contract 和公网 origin 三层独立匹配之后。任一前置失败时不得读取或进入 Prisma status。 数据库名读取不是“连接任意目标后相信其自述”:它必须排在历史目标、远端 contract 和公网 origin 三层独立匹配之后。任一前置失败时不得读取或进入 Prisma status。
@@ -70,11 +70,13 @@ status 原文仅存在受控 SSH/执行现场;本地 helper 写入 `prisma-mig
DEPLOY_SERVER=<历史身份恢复,仅进程内> \ DEPLOY_SERVER=<历史身份恢复,仅进程内> \
DEPLOY_REFRESH_SERVER=<exact probe 唯一身份,仅进程内> \ DEPLOY_REFRESH_SERVER=<exact probe 唯一身份,仅进程内> \
EXPECTED_PRODUCTION_ORIGIN=<OPS7 公网基准,仅进程内> \ EXPECTED_PRODUCTION_ORIGIN=<OPS7 公网基准,仅进程内> \
EXPECTED_PRODUCTION_DB_NAME=<只读引导,仅进程内> \ EXPECTED_PRODUCTION_DB_NAME_SHA256=<只读引导所得无明文比较基线,仅进程内> \
EXPECTED_PENDING_MIGRATIONS=<明确 allowlist> \ EXPECTED_PENDING_MIGRATIONS=<明确 allowlist> \
bash deploy/trialrun/deploy-existing.sh bash deploy/trialrun/deploy-existing.sh
``` ```
仅执行身份引导、target/pre-status 与回滚窗口检查时,顶层引导器向该入口传入 `PRODUCTION_READ_ONLY_PREFLIGHT=1`;preflight 成功后必须在 `deploy.sh` 前退出。该开关不跳过任何只读门禁,也不允许进入同步、迁移或应用切换。
包装器固定 `SYNC_ENV=0`、`DEPLOY_DATA_MODE=existing` 和 `DEPLOY_STATUS_OUTPUT_MODE=quiet`。`deploy.sh` 内部的 `prisma migrate deploy` 是本 task 唯一允许的 schema 写入;不允许 seed、业务 API 写入、reset、resolve 或手工 DDL。 包装器固定 `SYNC_ENV=0`、`DEPLOY_DATA_MODE=existing` 和 `DEPLOY_STATUS_OUTPUT_MODE=quiet`。`deploy.sh` 内部的 `prisma migrate deploy` 是本 task 唯一允许的 schema 写入;不允许 seed、业务 API 写入、reset、resolve 或手工 DDL。
部署后 helper 以空 pending allowlist重新执行 target/status/reconciliation,要求 schema up to date;随后才执行 refresh 和只读 readiness。refresh 失败分类为 `DEPLOYED_BUT_CONTRACT_REFRESH_FAILED`,不重复发布应用。 部署后 helper 以空 pending allowlist重新执行 target/status/reconciliation,要求 schema up to date;随后才执行 refresh 和只读 readiness。refresh 失败分类为 `DEPLOYED_BUT_CONTRACT_REFRESH_FAILED`,不重复发布应用。
@@ -5,7 +5,7 @@
- [x] 已向用户呈现本 task 的最终规划摘要,并取得对该摘要的最新明确批准。 - [x] 已向用户呈现本 task 的最终规划摘要,并取得对该摘要的最新明确批准。
- [x] 已解决候选发布是否可进入上游的外部授权;未获授权不得使用 unpublished override。 - [x] 已解决候选发布是否可进入上游的外部授权;未获授权不得使用 unpublished override。
- [x] 任务从 `planning` 切换到 `in_progress` 前,确认本清单、PRD、design 与 Open Questions 已收敛。 - [x] 任务从 `planning` 切换到 `in_progress` 前,确认本清单、PRD、design 与 Open Questions 已收敛。
- [ ] **规划修订复核:**确认唯一公网环境拓扑、精确身份匹配和数据库名只读引导的修订摘要,并重新批准后再改 tooling/连接生产。 - [x] **规划修订复核:**确认唯一公网环境拓扑、精确身份匹配和数据库名只读引导的修订摘要,并重新批准后再改 tooling/连接生产。
## 1. 候选与本地证据 ## 1. 候选与本地证据
@@ -21,11 +21,12 @@
## 1.1 拓扑校准与 tooling 修订(生产前) ## 1.1 拓扑校准与 tooling 修订(生产前)
- [ ] **刻意次序调整:**本轮 target gate 不再是第一条生产命令;历史身份恢复、refresh exact probe、systemd contract/公网 origin 核对、远端 `POSTGRES_DB` 只读引导四步探测排在其之前。四步全程只读、逐步 fail-closed,任一失败均不得进入 target gate 或 Prisma status。 - [ ] **刻意次序调整:**本轮 target gate 不再是第一条生产命令;历史身份恢复、refresh exact probe、systemd contract/公网 origin 核对、远端 `POSTGRES_DB` 只读引导四步探测排在其之前。四步全程只读、逐步 fail-closed,任一失败均不得进入 target gate 或 Prisma status。
- [ ] 从 `34181e3` 只读恢复历史 `DEPLOY_SERVER`、`REMOTE_DIR`、`COMPOSE_PROJECT_NAME`,仅保存在当前进程;不把值写入 task、日志或对话。 - [x] 从 `34181e3` 只读恢复历史 `DEPLOY_SERVER`、`REMOTE_DIR`,从 `5e2d03893826` 恢复首次显式冻结的 `COMPOSE_PROJECT_NAME`,并要求两份历史记录的 `REMOTE_DIR` 一致;仅保存在当前进程,不把值写入 task、日志或对话。
- [ ] 无回显探测 `DEPLOY_REFRESH_SERVER`,要求 exact forced-command `probe` 唯一 PASS;多个候选或零候选均 PAUSED。 - [ ] 无回显探测 `DEPLOY_REFRESH_SERVER`,要求 exact forced-command `probe` 唯一 PASS;多个候选或零候选均 PAUSED。
- [ ] 修改 production preflight:取消对合法 `trialrun` 命名的单独拒绝,增加部署目标、远端目录、Compose project、systemd contract、公网 origin 的精确身份对账;保留回环/零地址/local regression/staging/shadow/placeholder 防线。 - [x] 修改 production preflight:取消对合法 `trialrun` 命名的单独拒绝,增加部署目标、远端目录、Compose project、systemd contract、公网 origin 的精确身份对账;保留回环/零地址/local regression/staging/shadow/placeholder 防线。
- [ ] 增加只读数据库身份 bootstrap:身份组匹配后读取远端 `.env` 的 `POSTGRES_DB`,只在内存中传给 target gate,禁止回显/落盘;读取失败或身份组不一致时不得进入 Prisma status。 - [x] 增加只读数据库身份 bootstrap:身份组匹配后读取远端 `.env` 的 `POSTGRES_DB`,在远端转换为 SHA-256 比较基线后仅在内存中传给 target gate;数据库名明文禁止离开远端、回显或落盘,读取失败或身份组不一致时不得进入 Prisma status。
- [ ] 增加 synthetic fixture:合法 `hyb-oa-trialrun` 身份通过;错误目录/project/host/db、回环、staging、占位符和多 refresh 候选 fail-closed;断言 bootstrap 不泄露值且顺序早于 status。 - [x] 增加 synthetic fixture:合法 `hyb-oa-trialrun` 身份通过;错误目录/project/host/db、回环、staging、占位符和多 refresh 候选 fail-closed;断言 bootstrap 不泄露值且顺序早于 status。
- [x] 增加 `PRODUCTION_READ_ONLY_PREFLIGHT=1` 停止点:完成 target/pre-status 与回滚窗口后、进入 `deploy.sh` 前退出;只读证据复核通过前不得运行写入路径。
- [ ] 重新运行部署安全测试、shell/Node syntax、task/governance/sensitive/diff 门禁,提交并推送新的候选后重新通过 provenance。 - [ ] 重新运行部署安全测试、shell/Node syntax、task/governance/sensitive/diff 门禁,提交并推送新的候选后重新通过 provenance。
## 2. 生产 preflight(全部只读) ## 2. 生产 preflight(全部只读)
@@ -92,6 +93,6 @@
## Planning Revision Evidence (2026-08-08) ## Planning Revision Evidence (2026-08-08)
- 历史提交 `34181e3` 的旧 `deploy.sh` 确有默认试运行身份;该身份的目录/project 命名与公网 origin 属于同一唯一运行环境,不能因 `trialrun` 字样单独拒绝。 - 历史提交 `34181e3` 的旧 `deploy.sh` 确有默认部署 target/目录,但当时尚未显式设置 Compose project;`5e2d03893826` 首次冻结 project 且沿用同一远端目录。两份证据共同证明该遗留命名与公网 origin 属于同一唯一运行环境,不能因 `trialrun` 字样单独拒绝。
- 现行 gate 尚未对 `DEPLOY_SERVER`、`REMOTE_DIR`、`COMPOSE_PROJECT_NAME` 与独立预期身份做精确对账;本节修订只允许先改部署/QA tooling,不改业务代码或生产数据。 - 现行 gate 尚未对 `DEPLOY_SERVER`、`REMOTE_DIR`、`COMPOSE_PROJECT_NAME` 与独立预期身份做精确对账;本节修订只允许先改部署/QA tooling,不改业务代码或生产数据。
- 修订后的第一条远端动作仍是只读身份 bootstrap;数据库名读取排在 exact identity 匹配之后,任何 bootstrap 或 target gate FAIL 都不得继续。 - 修订后的第一条远端动作仍是只读身份 bootstrap;数据库名读取排在 exact identity 匹配之后,任何 bootstrap 或 target gate FAIL 都不得继续。
@@ -29,7 +29,7 @@
### R1 发布来源与生产目标 ### R1 发布来源与生产目标
- 候选 commit 必须干净、已提交、已进入配置的上游远端;严格 wrapper 不接受 `DEPLOY_ALLOW_UNPUBLISHED_COMMIT` 或 `DEPLOY_UNPUBLISHED_REASON`。 - 候选 commit 必须干净、已提交、已进入配置的上游远端;严格 wrapper 不接受 `DEPLOY_ALLOW_UNPUBLISHED_COMMIT` 或 `DEPLOY_UNPUBLISHED_REASON`。
- 公网 origin 从当前 OPS7 运维交接 Runbook 的现行公网检查基准取得;部署 SSH 目标、远端目录和 Compose project 从已审查历史提交 `34181e3` 的既有部署入口恢复,只在执行进程内使用,不复制到任务文档或普通日志。 - 公网 origin 从当前 OPS7 运维交接 Runbook 的现行公网检查基准取得;部署 SSH 目标和远端目录从已审查历史提交 `34181e3` 恢复,Compose project 从首次显式冻结该值的历史提交 `5e2d03893826` 恢复,并要求两份历史记录的远端目录一致。三项只在执行进程内使用,不复制到任务文档或普通日志。
- `DEPLOY_REFRESH_SERVER` 通过本机受控 SSH 配置的 exact forced-command `probe` 唯一识别;零个或多个候选均立即停止,不按别名、顺序或连接速度猜测。 - `DEPLOY_REFRESH_SERVER` 通过本机受控 SSH 配置的 exact forced-command `probe` 唯一识别;零个或多个候选均立即停止,不按别名、顺序或连接速度猜测。
- 生产数据库名只允许在部署目标、远端目录、Compose project、受控 systemd contract 和公网 origin 已精确匹配后,通过一次获批的远端 `.env` 只读读取取得;值仅保留在同一执行进程内并转换为 target gate 所需的比较输入,不回显、不落盘。 - 生产数据库名只允许在部署目标、远端目录、Compose project、受控 systemd contract 和公网 origin 已精确匹配后,通过一次获批的远端 `.env` 只读读取取得;值仅保留在同一执行进程内并转换为 target gate 所需的比较输入,不回显、不落盘。
- target gate 必须精确验证 `DEPLOY_SERVER`、`REMOTE_DIR`、`COMPOSE_PROJECT_NAME`、公网 origin 与读取后的 `POSTGRES_DB` 身份组,并继续核对运行时 `DATABASE_URL` host/database、当前数据库、`NODE_ENV` 和 cookie 安全配置;任一身份不确定均 fail-closed。 - target gate 必须精确验证 `DEPLOY_SERVER`、`REMOTE_DIR`、`COMPOSE_PROJECT_NAME`、公网 origin 与读取后的 `POSTGRES_DB` 身份组,并继续核对运行时 `DATABASE_URL` host/database、当前数据库、`NODE_ENV` 和 cookie 安全配置;任一身份不确定均 fail-closed。
@@ -90,7 +90,7 @@
## Operational Inputs (not committed) ## Operational Inputs (not committed)
- `EXPECTED_PRODUCTION_ORIGIN`(从现行 OPS7 公网基准在进程内取得) - `EXPECTED_PRODUCTION_ORIGIN`(从现行 OPS7 公网基准在进程内取得)
- `EXPECTED_PRODUCTION_DB_NAME`(仅在精确身份引导通过后,从远端 `.env` 只读取得并保留在同一进程) - `EXPECTED_PRODUCTION_DB_NAME_SHA256`(仅在精确身份引导通过后,于远端从 `.env` 读取数据库名并转换为不含明文的比较基线;数据库名明文本身不离开远端,也不进入日志或证据文件)
- `EXPECTED_PENDING_MIGRATIONS` - `EXPECTED_PENDING_MIGRATIONS`
- 受控 `DEPLOY_SERVER`(从已审查历史入口恢复)、`DEPLOY_REFRESH_SERVER`(exact probe 唯一识别)、systemd contract 与 live smoke 凭据(如获批提供) - 受控 `DEPLOY_SERVER`(从已审查历史入口恢复)、`DEPLOY_REFRESH_SERVER`(exact probe 唯一识别)、systemd contract 与 live smoke 凭据(如获批提供)
+29 -5
查看文件
@@ -42,15 +42,28 @@ deployment_contract_load "$DEPLOY_SERVER" "$DEPLOY_CONTRACT_UNIT" contract \
echo "[deploy-existing] 已读取受控 unit 合同;路径与标识不回显" echo "[deploy-existing] 已读取受控 unit 合同;路径与标识不回显"
if [[ -z "${EXPECTED_PRODUCTION_ORIGIN+x}" ]]; then if [[ -z "${EXPECTED_PRODUCTION_ORIGIN:-}" ]]; then
fail "生产部署必须显式提供 EXPECTED_PRODUCTION_ORIGIN(仅进程环境)" fail "生产部署必须显式提供 EXPECTED_PRODUCTION_ORIGIN(仅进程环境)"
fi fi
if [[ -z "${EXPECTED_PRODUCTION_DB_NAME+x}" ]]; then if [[ -z "${EXPECTED_PRODUCTION_DB_NAME_SHA256:-}" ]]; then
fail "生产部署必须显式提供 EXPECTED_PRODUCTION_DB_NAME(仅进程环境)" fail "生产部署必须由身份引导器提供 EXPECTED_PRODUCTION_DB_NAME_SHA256"
fi fi
if [[ -z "${EXPECTED_PENDING_MIGRATIONS+x}" ]]; then if [[ -z "${EXPECTED_PENDING_MIGRATIONS+x}" ]]; then
fail "生产部署必须显式提供 EXPECTED_PENDING_MIGRATIONS(可为空)" fail "生产部署必须显式提供 EXPECTED_PENDING_MIGRATIONS(可为空)"
fi fi
if [[ -z "${EXPECTED_DEPLOY_SERVER:-}" ]]; then
fail "生产部署必须由身份引导器提供 EXPECTED_DEPLOY_SERVER"
fi
if [[ -z "${EXPECTED_REMOTE_DIR:-}" ]]; then
fail "生产部署必须由身份引导器提供 EXPECTED_REMOTE_DIR"
fi
if [[ -z "${EXPECTED_COMPOSE_PROJECT_NAME:-}" ]]; then
fail "生产部署必须由身份引导器提供 EXPECTED_COMPOSE_PROJECT_NAME"
fi
case "${PRODUCTION_READ_ONLY_PREFLIGHT:-0}" in
0|1) ;;
*) fail "PRODUCTION_READ_ONLY_PREFLIGHT 只能为 0 或 1" ;;
esac
[[ -f "$ROOT_DIR/$PROVENANCE_SCRIPT" ]] || fail "缺少发布来源校验脚本" [[ -f "$ROOT_DIR/$PROVENANCE_SCRIPT" ]] || fail "缺少发布来源校验脚本"
[[ -f "$ROOT_DIR/$PRODUCTION_PREFLIGHT_SCRIPT" ]] || fail "缺少生产迁移 preflight 脚本" [[ -f "$ROOT_DIR/$PRODUCTION_PREFLIGHT_SCRIPT" ]] || fail "缺少生产迁移 preflight 脚本"
@@ -64,8 +77,11 @@ source_commit="$(node -e 'const value = JSON.parse(process.argv[1]); process.std
echo ">>> 0.2 生产目标与迁移 preflight(只读,先于 deploy.sh)" echo ">>> 0.2 生产目标与迁移 preflight(只读,先于 deploy.sh)"
PRODUCTION_PREFLIGHT_REPORT_ROOT="${PRODUCTION_PREFLIGHT_REPORT_ROOT:-artifacts/production-preflight-$(date +%Y%m%d-%H%M%S)}" PRODUCTION_PREFLIGHT_REPORT_ROOT="${PRODUCTION_PREFLIGHT_REPORT_ROOT:-artifacts/production-preflight-$(date +%Y%m%d-%H%M%S)}"
EXPECTED_PRODUCTION_ORIGIN="$EXPECTED_PRODUCTION_ORIGIN" \ EXPECTED_PRODUCTION_ORIGIN="$EXPECTED_PRODUCTION_ORIGIN" \
EXPECTED_PRODUCTION_DB_NAME="$EXPECTED_PRODUCTION_DB_NAME" \ EXPECTED_PRODUCTION_DB_NAME_SHA256="$EXPECTED_PRODUCTION_DB_NAME_SHA256" \
EXPECTED_PENDING_MIGRATIONS="$EXPECTED_PENDING_MIGRATIONS" \ EXPECTED_PENDING_MIGRATIONS="$EXPECTED_PENDING_MIGRATIONS" \
EXPECTED_DEPLOY_SERVER="$EXPECTED_DEPLOY_SERVER" \
EXPECTED_REMOTE_DIR="$EXPECTED_REMOTE_DIR" \
EXPECTED_COMPOSE_PROJECT_NAME="$EXPECTED_COMPOSE_PROJECT_NAME" \
DEPLOY_SERVER="$DEPLOY_SERVER" \ DEPLOY_SERVER="$DEPLOY_SERVER" \
REMOTE_DIR="${contract[REMOTE_DIR]}" \ REMOTE_DIR="${contract[REMOTE_DIR]}" \
REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \ REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \
@@ -78,6 +94,11 @@ PRODUCTION_PREFLIGHT_REPORT_DIR="$PRODUCTION_PREFLIGHT_REPORT_ROOT/pre" \
node "$ROOT_DIR/$PRODUCTION_PREFLIGHT_SCRIPT" \ node "$ROOT_DIR/$PRODUCTION_PREFLIGHT_SCRIPT" \
|| fail "生产目标或迁移 preflight 未通过;未进入 deploy.sh" || fail "生产目标或迁移 preflight 未通过;未进入 deploy.sh"
if [[ "${PRODUCTION_READ_ONLY_PREFLIGHT:-0}" == "1" ]]; then
echo "[deploy-existing][PASS] 只读身份、迁移与回滚窗口门禁通过;未进入 deploy.sh"
exit 0
fi
DEPLOY_SERVER="$DEPLOY_SERVER" \ DEPLOY_SERVER="$DEPLOY_SERVER" \
REMOTE_DIR="${contract[REMOTE_DIR]}" \ REMOTE_DIR="${contract[REMOTE_DIR]}" \
REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \ REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \
@@ -93,8 +114,11 @@ bash "$ROOT_DIR/deploy/trialrun/deploy.sh"
echo ">>> 6.1 部署后 migration status 与目标只读复核" echo ">>> 6.1 部署后 migration status 与目标只读复核"
EXPECTED_PRODUCTION_ORIGIN="$EXPECTED_PRODUCTION_ORIGIN" \ EXPECTED_PRODUCTION_ORIGIN="$EXPECTED_PRODUCTION_ORIGIN" \
EXPECTED_PRODUCTION_DB_NAME="$EXPECTED_PRODUCTION_DB_NAME" \ EXPECTED_PRODUCTION_DB_NAME_SHA256="$EXPECTED_PRODUCTION_DB_NAME_SHA256" \
EXPECTED_PENDING_MIGRATIONS="" \ EXPECTED_PENDING_MIGRATIONS="" \
EXPECTED_DEPLOY_SERVER="$EXPECTED_DEPLOY_SERVER" \
EXPECTED_REMOTE_DIR="$EXPECTED_REMOTE_DIR" \
EXPECTED_COMPOSE_PROJECT_NAME="$EXPECTED_COMPOSE_PROJECT_NAME" \
DEPLOY_SERVER="$DEPLOY_SERVER" \ DEPLOY_SERVER="$DEPLOY_SERVER" \
REMOTE_DIR="${contract[REMOTE_DIR]}" \ REMOTE_DIR="${contract[REMOTE_DIR]}" \
REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \ REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \
+95
查看文件
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Read-only identity bootstrap. The caller captures the database identity in
# memory and must never forward this script's stdout to an ordinary log.
set -euo pipefail
fail() {
printf '[production-identity-bootstrap][FAIL] code=%s\n' "$1" >&2
exit 1
}
for key in REMOTE_DIR REMOTE_ENV_FILE COMPOSE_FILE COMPOSE_PROJECT_NAME \
EXPECTED_PRODUCTION_ORIGIN_SHA256; do
[[ -n "${!key:-}" ]] || fail "${key}_MISSING"
done
[[ "$EXPECTED_PRODUCTION_ORIGIN_SHA256" =~ ^[0-9a-f]{64}$ ]] \
|| fail "PROD_TARGET_ORIGIN_HASH_INVALID"
cd "$REMOTE_DIR"
[[ -f "$REMOTE_ENV_FILE" && ! -L "$REMOTE_ENV_FILE" && -r "$REMOTE_ENV_FILE" ]] \
|| fail "PROD_TARGET_ENV_UNREADABLE"
dotenv_value() {
local key="$1"
local value
value="$(awk -v wanted="$key" '
$0 !~ /^[[:space:]]*#/ && $0 ~ "^[[:space:]]*" wanted "=" {
sub("^[[:space:]]*" wanted "=", "")
print
exit
}
' "$REMOTE_ENV_FILE")" || return 1
value="${value%$'\r'}"
case "$value" in
\"*\") value="${value:1:${#value}-2}" ;;
\'*\') value="${value:1:${#value}-2}" ;;
esac
[[ -n "$value" ]] || return 1
printf '%s' "$value"
}
hash_text() {
if command -v sha256sum >/dev/null 2>&1; then
printf '%s' "$1" | sha256sum | awk '{print $1}'
return 0
fi
if command -v shasum >/dev/null 2>&1; then
printf '%s' "$1" | shasum -a 256 | awk '{print $1}'
return 0
fi
return 1
}
reject_non_production_identity() {
local value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
[[ "$value" != *"<"* && "$value" != *">"* ]] || return 1
[[ "$value" != *"localhost"* && "$value" != *"127.0.0.1"* \
&& "$value" != *"::1"* && "$value" != *"hyb_oa_regression"* \
&& "$value" != *"staging"* && "$value" != *"stage"* \
&& "$value" != *"regression"* && "$value" != *"shadow"* \
&& "$value" != *"/test"* ]]
}
actual_origin="$(dotenv_value APP_ORIGIN)" || fail "PROD_TARGET_ORIGIN_MISSING"
actual_db_name="$(dotenv_value POSTGRES_DB)" || fail "PROD_TARGET_DB_NAME_MISSING"
[[ "$(hash_text "$actual_origin")" == "$EXPECTED_PRODUCTION_ORIGIN_SHA256" ]] \
|| fail "PROD_TARGET_ORIGIN_MISMATCH"
reject_non_production_identity "$actual_db_name" \
|| fail "PROD_TARGET_DB_NON_PRODUCTION_IDENTITY"
if docker compose version >/dev/null 2>&1; then
compose_cmd=(docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE")
elif command -v docker-compose >/dev/null 2>&1; then
compose_cmd=(docker-compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE")
else
fail "PROD_TARGET_COMPOSE_UNAVAILABLE"
fi
mapfile -t server_ids < <(
docker ps \
--filter "label=com.docker.compose.project=$COMPOSE_PROJECT_NAME" \
--filter "label=com.docker.compose.service=server" \
--format '{{.ID}}' 2>/dev/null || true
)
[[ "${#server_ids[@]}" -eq 1 ]] || fail "PROD_TARGET_RUNTIME_NOT_UNIQUE"
runtime_origin="$(
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "${server_ids[0]}" \
| sed -n 's/^APP_ORIGIN=//p' | tail -n 1
)"
[[ "$(hash_text "$runtime_origin")" == "$EXPECTED_PRODUCTION_ORIGIN_SHA256" ]] \
|| fail "PROD_TARGET_RUNTIME_ORIGIN_MISMATCH"
# Only the comparison baseline crosses the SSH channel. The database name
# itself remains remote and cannot be reconstructed from task evidence.
printf '[production-identity-bootstrap][DB_SHA256] %s\n' "$(hash_text "$actual_db_name")"
+242
查看文件
@@ -0,0 +1,242 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import process from "node:process";
import { spawnSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(SCRIPT_DIR, "../..");
const HISTORICAL_TARGET_COMMIT = "34181e3";
const HISTORICAL_PROJECT_COMMIT = "5e2d038938265c926e06d3cc6d0841f6dcc06148";
const DEPLOY_SCRIPT_AT_COMMIT = "deploy/trialrun/deploy.sh";
const RUNBOOK_PATH = path.join(ROOT_DIR, "docs/trialrun/OPS7运维交接Runbook.md");
const REMOTE_BOOTSTRAP_SCRIPT = path.join(SCRIPT_DIR, "production-identity-bootstrap-remote.sh");
const DEPLOY_EXISTING_SCRIPT = path.join(SCRIPT_DIR, "deploy-existing.sh");
const DEFAULT_CONTRACT_UNIT = "hyb-oa-automatic-backup-daily.service";
const EXACT_REFRESH_PROBE = "[deployment-contract-refresh][PASS] probe";
const SAFE_SSH_TARGET_PATTERN = /^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$/u;
const SAFE_ABSOLUTE_PATH_PATTERN = /^\/[A-Za-z0-9._/-]+$/u;
const SAFE_COMPOSE_FILE_PATTERN = /^[A-Za-z0-9._/-]+$/u;
const SAFE_PROJECT_PATTERN = /^[a-z0-9][a-z0-9_-]*$/u;
export class ProductionIdentityBootstrapError extends Error {
constructor(code) {
super(code);
this.name = "ProductionIdentityBootstrapError";
this.code = code;
}
}
function fail(code) {
throw new ProductionIdentityBootstrapError(code);
}
function run(command, args, options = {}) {
return spawnSync(command, args, {
cwd: ROOT_DIR,
encoding: "utf8",
stdio: [options.input == null ? "ignore" : "pipe", "pipe", "pipe"],
input: options.input,
env: options.env ?? process.env,
});
}
function requiredCommandOutput(command, args, code) {
const result = run(command, args);
if (result.status !== 0 || !String(result.stdout ?? "").trim()) fail(code);
return String(result.stdout).trim();
}
function parseHistoricalDefault(source, key) {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
const match = source.match(new RegExp(`^${escapedKey}="\\$\\{${escapedKey}:-([^}]+)\\}"$`, "mu"));
if (!match?.[1]) fail(`HISTORICAL_${key}_UNAVAILABLE`);
return match[1].trim();
}
export function parseHistoricalDeploymentIdentity(targetSource, projectSource) {
const deployServer = parseHistoricalDefault(targetSource, "DEPLOY_SERVER");
const remoteDir = parseHistoricalDefault(targetSource, "REMOTE_DIR");
const projectRemoteDir = parseHistoricalDefault(projectSource, "REMOTE_DIR");
const composeProjectName = parseHistoricalDefault(projectSource, "COMPOSE_PROJECT_NAME");
if (projectRemoteDir !== remoteDir) fail("HISTORICAL_REMOTE_DIR_DIVERGED");
if (!SAFE_SSH_TARGET_PATTERN.test(deployServer)) fail("HISTORICAL_DEPLOY_SERVER_INVALID");
if (!SAFE_ABSOLUTE_PATH_PATTERN.test(remoteDir)) fail("HISTORICAL_REMOTE_DIR_INVALID");
if (!SAFE_PROJECT_PATTERN.test(composeProjectName)) fail("HISTORICAL_COMPOSE_PROJECT_INVALID");
return { deployServer, remoteDir, composeProjectName };
}
export function parseExpectedProductionOrigin(runbook) {
const matches = [...String(runbook).matchAll(/curl\s+-fsS\s+(https:\/\/[^\s/]+)\/ready\b/gu)]
.map((match) => match[1]);
const unique = [...new Set(matches)];
if (unique.length !== 1) fail("EXPECTED_PRODUCTION_ORIGIN_NOT_UNIQUE");
return unique[0];
}
export function parseConcreteSshHosts(config) {
const hosts = [];
for (const match of String(config).matchAll(/^\s*Host\s+(.+)$/gmi)) {
for (const host of match[1].trim().split(/\s+/u)) {
if (host && !/[*!?]/u.test(host) && SAFE_SSH_TARGET_PATTERN.test(host)) hosts.push(host);
}
}
return [...new Set(hosts)];
}
export function selectUniqueRefreshTarget(probeResults) {
const matches = probeResults.filter((item) => item.status === 0 && item.stdout.trim() === EXACT_REFRESH_PROBE);
if (matches.length === 0) fail("REFRESH_IDENTITY_NOT_FOUND");
if (matches.length !== 1) fail("REFRESH_IDENTITY_NOT_UNIQUE");
return matches[0].target;
}
function discoverRefreshTarget() {
const sshConfigPath = path.join(homedir(), ".ssh", "config");
let config;
try {
config = readFileSync(sshConfigPath, "utf8");
} catch {
fail("SSH_CONFIG_UNREADABLE");
}
const candidates = parseConcreteSshHosts(config);
if (candidates.length === 0) fail("SSH_CONFIG_HAS_NO_CONCRETE_HOSTS");
const probeResults = candidates.map((target) => {
const result = run("ssh", [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
target,
"probe",
]);
return { target, status: result.status, stdout: String(result.stdout ?? "") };
});
return selectUniqueRefreshTarget(probeResults);
}
export function parseDeploymentContract(raw) {
const allowed = new Set([
"REMOTE_DIR",
"REMOTE_ENV_FILE",
"COMPOSE_FILE",
"COMPOSE_PROJECT_NAME",
"POSTGRES_DATA_DIR",
"STORAGE_HOST_DIR",
]);
const contract = {};
for (const entry of String(raw).trim().split(/\s+/u)) {
const separator = entry.indexOf("=");
if (separator <= 0) continue;
const key = entry.slice(0, separator);
if (allowed.has(key)) contract[key] = entry.slice(separator + 1);
}
for (const key of allowed) {
if (!contract[key]) fail("SYSTEMD_CONTRACT_INCOMPLETE");
}
if (!SAFE_ABSOLUTE_PATH_PATTERN.test(contract.REMOTE_DIR)) fail("SYSTEMD_REMOTE_DIR_INVALID");
if (!SAFE_ABSOLUTE_PATH_PATTERN.test(contract.REMOTE_ENV_FILE)) fail("SYSTEMD_REMOTE_ENV_FILE_INVALID");
if (!SAFE_ABSOLUTE_PATH_PATTERN.test(contract.POSTGRES_DATA_DIR)) fail("SYSTEMD_POSTGRES_DATA_DIR_INVALID");
if (!SAFE_ABSOLUTE_PATH_PATTERN.test(contract.STORAGE_HOST_DIR)) fail("SYSTEMD_STORAGE_HOST_DIR_INVALID");
if (!SAFE_COMPOSE_FILE_PATTERN.test(contract.COMPOSE_FILE)) fail("SYSTEMD_COMPOSE_FILE_INVALID");
if (!SAFE_PROJECT_PATTERN.test(contract.COMPOSE_PROJECT_NAME)) fail("SYSTEMD_COMPOSE_PROJECT_INVALID");
return contract;
}
export function assertExactHistoricalContract(identity, contract) {
if (contract.REMOTE_DIR !== identity.remoteDir) fail("SYSTEMD_REMOTE_DIR_MISMATCH");
if (contract.REMOTE_ENV_FILE !== `${identity.remoteDir}/deploy/trialrun/.env`) {
fail("SYSTEMD_REMOTE_ENV_FILE_MISMATCH");
}
if (contract.COMPOSE_FILE !== "deploy/trialrun/docker-compose.yml") {
fail("SYSTEMD_COMPOSE_FILE_MISMATCH");
}
if (contract.COMPOSE_PROJECT_NAME !== identity.composeProjectName) {
fail("SYSTEMD_COMPOSE_PROJECT_MISMATCH");
}
}
function sha256Text(value) {
return createHash("sha256").update(String(value), "utf8").digest("hex");
}
function shellQuote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`;
}
function bootstrapDatabaseIdentityHash(identity, contract, expectedOrigin) {
const remoteCommand = [
`REMOTE_DIR=${shellQuote(contract.REMOTE_DIR)}`,
`REMOTE_ENV_FILE=${shellQuote(contract.REMOTE_ENV_FILE)}`,
`COMPOSE_FILE=${shellQuote(contract.COMPOSE_FILE)}`,
`COMPOSE_PROJECT_NAME=${shellQuote(contract.COMPOSE_PROJECT_NAME)}`,
`EXPECTED_PRODUCTION_ORIGIN_SHA256=${shellQuote(sha256Text(expectedOrigin))}`,
"bash -s",
].join(" ");
const remoteScript = readFileSync(REMOTE_BOOTSTRAP_SCRIPT, "utf8");
const result = run("ssh", [identity.deployServer, remoteCommand], { input: remoteScript });
if (result.status !== 0) {
const code = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.match(/code=([A-Z0-9_]+)/u)?.[1];
fail(code || "REMOTE_IDENTITY_BOOTSTRAP_FAILED");
}
const output = String(result.stdout ?? "").trim();
const match = output.match(/^\[production-identity-bootstrap\]\[DB_SHA256\] ([0-9a-f]{64})$/u);
if (!match) fail("REMOTE_DB_IDENTITY_PAYLOAD_INVALID");
return match[1];
}
function main() {
try {
const historicalTargetScript = requiredCommandOutput(
"git",
["show", `${HISTORICAL_TARGET_COMMIT}:${DEPLOY_SCRIPT_AT_COMMIT}`],
"HISTORICAL_IDENTITY_UNAVAILABLE",
);
const historicalProjectScript = requiredCommandOutput(
"git",
["show", `${HISTORICAL_PROJECT_COMMIT}:${DEPLOY_SCRIPT_AT_COMMIT}`],
"HISTORICAL_PROJECT_IDENTITY_UNAVAILABLE",
);
const identity = parseHistoricalDeploymentIdentity(historicalTargetScript, historicalProjectScript);
const expectedOrigin = parseExpectedProductionOrigin(readFileSync(RUNBOOK_PATH, "utf8"));
const refreshTarget = discoverRefreshTarget();
const contractRaw = requiredCommandOutput(
"ssh",
[identity.deployServer, `systemctl show '${DEFAULT_CONTRACT_UNIT}' -p Environment --value`],
"SYSTEMD_CONTRACT_UNAVAILABLE",
);
const contract = parseDeploymentContract(contractRaw);
assertExactHistoricalContract(identity, contract);
const databaseIdentityHash = bootstrapDatabaseIdentityHash(identity, contract, expectedOrigin);
console.log("[production-identity-bootstrap][PASS] exact_identity_group");
const result = spawnSync("bash", [DEPLOY_EXISTING_SCRIPT], {
cwd: ROOT_DIR,
stdio: "inherit",
env: {
...process.env,
DEPLOY_SERVER: identity.deployServer,
DEPLOY_REFRESH_SERVER: refreshTarget,
EXPECTED_DEPLOY_SERVER: identity.deployServer,
EXPECTED_REMOTE_DIR: identity.remoteDir,
EXPECTED_COMPOSE_PROJECT_NAME: identity.composeProjectName,
EXPECTED_PRODUCTION_ORIGIN: expectedOrigin,
EXPECTED_PRODUCTION_DB_NAME_SHA256: databaseIdentityHash,
EXPECTED_PENDING_MIGRATIONS: process.env.EXPECTED_PENDING_MIGRATIONS ?? "",
PRODUCTION_READ_ONLY_PREFLIGHT: process.env.PRODUCTION_READ_ONLY_PREFLIGHT ?? "0",
},
});
if (result.status !== 0) fail("DEPLOY_EXISTING_FAILED");
} catch (error) {
const code = error instanceof ProductionIdentityBootstrapError
? error.code
: "PRODUCTION_IDENTITY_BOOTSTRAP_UNEXPECTED_ERROR";
console.error(`[production-identity-bootstrap][PAUSED] code=${code}`);
process.exitCode = 1;
}
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
if (isMain) main();
@@ -65,8 +65,8 @@ reject_non_production_identity() {
[[ "$value" != *"<"* && "$value" != *">"* ]] || return 1 [[ "$value" != *"<"* && "$value" != *">"* ]] || return 1
[[ "$value" != *"localhost"* && "$value" != *"127.0.0.1"* \ [[ "$value" != *"localhost"* && "$value" != *"127.0.0.1"* \
&& "$value" != *"::1"* && "$value" != *"hyb_oa_regression"* \ && "$value" != *"::1"* && "$value" != *"hyb_oa_regression"* \
&& "$value" != *"trialrun"* && "$value" != *"staging"* \ && "$value" != *"staging"* && "$value" != *"stage"* \
&& "$value" != *"stage"* && "$value" != *"regression"* \ && "$value" != *"regression"* \
&& "$value" != *"shadow"* && "$value" != *"/test"* ]] && "$value" != *"shadow"* && "$value" != *"/test"* ]]
} }
+46 -6
查看文件
@@ -16,7 +16,7 @@ const SSH_TARGET_PATTERN = /^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$/u;
const SAFE_ABSOLUTE_PATH_PATTERN = /^\/[A-Za-z0-9._/-]+$/u; const SAFE_ABSOLUTE_PATH_PATTERN = /^\/[A-Za-z0-9._/-]+$/u;
const SAFE_COMPOSE_FILE_PATTERN = /^[A-Za-z0-9._/-]+$/u; const SAFE_COMPOSE_FILE_PATTERN = /^[A-Za-z0-9._/-]+$/u;
const SAFE_PROJECT_PATTERN = /^[a-z0-9][a-z0-9_-]*$/u; const SAFE_PROJECT_PATTERN = /^[a-z0-9][a-z0-9_-]*$/u;
const FORBIDDEN_IDENTITY_TOKEN_PATTERN = /(^|[._-])(local|localhost|dev|development|test|testing|stage|staging|trialrun|regression|shadow|example)([._-]|$)/u; const FORBIDDEN_IDENTITY_TOKEN_PATTERN = /(^|[._/-])(local|localhost|dev|development|test|testing|stage|staging|regression|shadow|example)([._/-]|$)/u;
const PRISMA_STATUS_KNOWN_PATTERN = /(?:database schema is up to date|have not yet been applied|no pending migrations)/iu; const PRISMA_STATUS_KNOWN_PATTERN = /(?:database schema is up to date|have not yet been applied|no pending migrations)/iu;
export class ProductionPreflightError extends Error { export class ProductionPreflightError extends Error {
@@ -135,6 +135,34 @@ function assertSafeContractValue(name, value, pattern) {
if (!pattern.test(String(value ?? ""))) fail(`${name}_INVALID`); if (!pattern.test(String(value ?? ""))) fail(`${name}_INVALID`);
} }
function assertProductionIdentityValue(name, value) {
const normalized = String(value ?? "").trim().toLowerCase();
if (!normalized || normalized.includes("<") || normalized.includes(">") || FORBIDDEN_IDENTITY_TOKEN_PATTERN.test(normalized)) {
fail(`${name}_NON_PRODUCTION_IDENTITY`);
}
}
function assertExactIdentity(name, actual, expected) {
if (expected == null || !String(expected).trim()) fail(`EXPECTED_${name}_MISSING`);
if (actual !== String(expected).trim()) fail(`${name}_MISMATCH`);
}
export function validateExactProductionIdentity({
deployServer,
remoteDir,
composeProjectName,
expectedDeployServer,
expectedRemoteDir,
expectedComposeProjectName,
}) {
assertProductionIdentityValue("DEPLOY_SERVER", deployServer);
assertProductionIdentityValue("REMOTE_DIR", remoteDir);
assertProductionIdentityValue("COMPOSE_PROJECT_NAME", composeProjectName);
assertExactIdentity("DEPLOY_SERVER", deployServer, expectedDeployServer);
assertExactIdentity("REMOTE_DIR", remoteDir, expectedRemoteDir);
assertExactIdentity("COMPOSE_PROJECT_NAME", composeProjectName, expectedComposeProjectName);
}
function gitText(args) { function gitText(args) {
const result = spawnSync("git", args, { const result = spawnSync("git", args, {
cwd: ROOT_DIR, cwd: ROOT_DIR,
@@ -216,14 +244,18 @@ function main() {
const postgresDataDir = String(process.env.POSTGRES_DATA_DIR ?? "").trim(); const postgresDataDir = String(process.env.POSTGRES_DATA_DIR ?? "").trim();
const storageHostDir = String(process.env.STORAGE_HOST_DIR ?? "").trim(); const storageHostDir = String(process.env.STORAGE_HOST_DIR ?? "").trim();
const expectedOriginRaw = process.env.EXPECTED_PRODUCTION_ORIGIN; const expectedOriginRaw = process.env.EXPECTED_PRODUCTION_ORIGIN;
const expectedDbNameRaw = process.env.EXPECTED_PRODUCTION_DB_NAME; const expectedDbNameHashRaw = process.env.EXPECTED_PRODUCTION_DB_NAME_SHA256;
const expectedPendingRaw = process.env.EXPECTED_PENDING_MIGRATIONS; const expectedPendingRaw = process.env.EXPECTED_PENDING_MIGRATIONS;
const expectedDeployServer = process.env.EXPECTED_DEPLOY_SERVER;
const expectedRemoteDir = process.env.EXPECTED_REMOTE_DIR;
const expectedComposeProjectName = process.env.EXPECTED_COMPOSE_PROJECT_NAME;
if (expectedOriginRaw == null) fail("EXPECTED_PRODUCTION_ORIGIN_MISSING"); if (expectedOriginRaw == null) fail("EXPECTED_PRODUCTION_ORIGIN_MISSING");
if (expectedDbNameRaw == null) fail("EXPECTED_PRODUCTION_DB_NAME_MISSING"); if (expectedDbNameHashRaw == null) fail("EXPECTED_PRODUCTION_DB_NAME_SHA256_MISSING");
if (expectedPendingRaw == null) fail("EXPECTED_PENDING_MIGRATIONS_MISSING"); if (expectedPendingRaw == null) fail("EXPECTED_PENDING_MIGRATIONS_MISSING");
const expectedOrigin = validateProductionOrigin(expectedOriginRaw); const expectedOrigin = validateProductionOrigin(expectedOriginRaw);
const expectedDbName = validateProductionDbName(expectedDbNameRaw); const expectedDbNameHash = String(expectedDbNameHashRaw).trim();
if (!/^[0-9a-f]{64}$/u.test(expectedDbNameHash)) fail("EXPECTED_PRODUCTION_DB_NAME_SHA256_INVALID");
const expectedPending = parseMigrationList(expectedPendingRaw, { const expectedPending = parseMigrationList(expectedPendingRaw, {
field: "EXPECTED_PENDING_MIGRATIONS", field: "EXPECTED_PENDING_MIGRATIONS",
}); });
@@ -234,6 +266,14 @@ function main() {
assertSafeContractValue("STORAGE_HOST_DIR", storageHostDir, SAFE_ABSOLUTE_PATH_PATTERN); assertSafeContractValue("STORAGE_HOST_DIR", storageHostDir, SAFE_ABSOLUTE_PATH_PATTERN);
assertSafeContractValue("COMPOSE_FILE", composeFile, SAFE_COMPOSE_FILE_PATTERN); assertSafeContractValue("COMPOSE_FILE", composeFile, SAFE_COMPOSE_FILE_PATTERN);
assertSafeContractValue("COMPOSE_PROJECT_NAME", composeProjectName, SAFE_PROJECT_PATTERN); assertSafeContractValue("COMPOSE_PROJECT_NAME", composeProjectName, SAFE_PROJECT_PATTERN);
validateExactProductionIdentity({
deployServer,
remoteDir,
composeProjectName,
expectedDeployServer,
expectedRemoteDir,
expectedComposeProjectName,
});
const candidateCommit = String( const candidateCommit = String(
process.env.CANDIDATE_COMMIT ?? gitText(["rev-parse", "--verify", "HEAD"]), process.env.CANDIDATE_COMMIT ?? gitText(["rev-parse", "--verify", "HEAD"]),
@@ -266,7 +306,7 @@ function main() {
`POSTGRES_DATA_DIR=${shellQuote(postgresDataDir)}`, `POSTGRES_DATA_DIR=${shellQuote(postgresDataDir)}`,
`STORAGE_HOST_DIR=${shellQuote(storageHostDir)}`, `STORAGE_HOST_DIR=${shellQuote(storageHostDir)}`,
`EXPECTED_PRODUCTION_ORIGIN_SHA256=${shellQuote(sha256Text(expectedOrigin))}`, `EXPECTED_PRODUCTION_ORIGIN_SHA256=${shellQuote(sha256Text(expectedOrigin))}`,
`EXPECTED_PRODUCTION_DB_NAME_SHA256=${shellQuote(sha256Text(expectedDbName))}`, `EXPECTED_PRODUCTION_DB_NAME_SHA256=${shellQuote(expectedDbNameHash)}`,
`CANDIDATE_MIGRATIONS_CSV=${shellQuote(candidateManifest.join(","))}`, `CANDIDATE_MIGRATIONS_CSV=${shellQuote(candidateManifest.join(","))}`,
`EXPECTED_PENDING_MIGRATIONS_CSV=${shellQuote(expectedPending.join(","))}`, `EXPECTED_PENDING_MIGRATIONS_CSV=${shellQuote(expectedPending.join(","))}`,
"bash -s", "bash -s",
@@ -283,7 +323,7 @@ function main() {
const rawStatus = extractPrismaStatus(stdout); const rawStatus = extractPrismaStatus(stdout);
if (rawStatus != null) { if (rawStatus != null) {
validatePrismaStatusText(rawStatus); validatePrismaStatusText(rawStatus);
const sanitizedStatus = sanitizePrismaStatus(rawStatus, [expectedOrigin, expectedDbName]); const sanitizedStatus = sanitizePrismaStatus(rawStatus, [expectedOrigin]);
writeFileSync(path.join(reportDir, "prisma-migrate-status.txt"), `${sanitizedStatus}\n`, { writeFileSync(path.join(reportDir, "prisma-migrate-status.txt"), `${sanitizedStatus}\n`, {
encoding: "utf8", encoding: "utf8",
mode: 0o600, mode: 0o600,
+47 -1
查看文件
@@ -43,6 +43,14 @@ const productionPreflightRemoteScriptPath = path.join(
rootDir, rootDir,
'deploy/trialrun/production-preflight-remote.sh', 'deploy/trialrun/production-preflight-remote.sh',
); );
const productionIdentityBootstrapScriptPath = path.join(
rootDir,
'deploy/trialrun/production-identity-bootstrap.mjs',
);
const productionIdentityBootstrapRemoteScriptPath = path.join(
rootDir,
'deploy/trialrun/production-identity-bootstrap-remote.sh',
);
const dockerfilePath = path.join(rootDir, 'deploy/trialrun/Dockerfile'); const dockerfilePath = path.join(rootDir, 'deploy/trialrun/Dockerfile');
const timerScriptPath = path.resolve( const timerScriptPath = path.resolve(
process.env.ENV_CONTRACT_TIMER_SCRIPT_PATH process.env.ENV_CONTRACT_TIMER_SCRIPT_PATH
@@ -279,6 +287,8 @@ const deploymentRefreshHelperContent = readFile(deploymentRefreshHelperPath);
const remotePreflightScriptContent = readFile(remotePreflightScriptPath); const remotePreflightScriptContent = readFile(remotePreflightScriptPath);
const productionPreflightScriptContent = readFile(productionPreflightScriptPath); const productionPreflightScriptContent = readFile(productionPreflightScriptPath);
const productionPreflightRemoteScriptContent = readFile(productionPreflightRemoteScriptPath); const productionPreflightRemoteScriptContent = readFile(productionPreflightRemoteScriptPath);
const productionIdentityBootstrapScriptContent = readFile(productionIdentityBootstrapScriptPath);
const productionIdentityBootstrapRemoteScriptContent = readFile(productionIdentityBootstrapRemoteScriptPath);
const dockerfileContent = readFile(dockerfilePath); const dockerfileContent = readFile(dockerfilePath);
const timerScriptContent = readFile(timerScriptPath); const timerScriptContent = readFile(timerScriptPath);
const automaticBackupTimerScriptContent = readFile(automaticBackupTimerScriptPath); const automaticBackupTimerScriptContent = readFile(automaticBackupTimerScriptPath);
@@ -604,8 +614,12 @@ for (const requiredFragment of [
for (const requiredFragment of [ for (const requiredFragment of [
'EXPECTED_PRODUCTION_ORIGIN', 'EXPECTED_PRODUCTION_ORIGIN',
'EXPECTED_PRODUCTION_DB_NAME', 'EXPECTED_PRODUCTION_DB_NAME_SHA256',
'EXPECTED_PENDING_MIGRATIONS', 'EXPECTED_PENDING_MIGRATIONS',
'EXPECTED_DEPLOY_SERVER',
'EXPECTED_REMOTE_DIR',
'EXPECTED_COMPOSE_PROJECT_NAME',
'PRODUCTION_READ_ONLY_PREFLIGHT',
'production-preflight.mjs', 'production-preflight.mjs',
'CANDIDATE_COMMIT', 'CANDIDATE_COMMIT',
'PRODUCTION_PREFLIGHT_REPORT_DIR', 'PRODUCTION_PREFLIGHT_REPORT_DIR',
@@ -616,6 +630,34 @@ for (const requiredFragment of [
} }
} }
for (const requiredFragment of [
'HISTORICAL_TARGET_COMMIT',
'HISTORICAL_PROJECT_COMMIT',
'discoverRefreshTarget',
'assertExactHistoricalContract',
'bootstrapDatabaseIdentityHash',
'EXPECTED_PRODUCTION_DB_NAME_SHA256',
'DEPLOY_EXISTING_SCRIPT',
]) {
if (!productionIdentityBootstrapScriptContent.includes(requiredFragment)) {
errors.push(`production-identity-bootstrap.mjs 缺少生产身份引导合同: ${requiredFragment}`);
}
}
for (const requiredFragment of [
'EXPECTED_PRODUCTION_ORIGIN_SHA256',
'PROD_TARGET_RUNTIME_ORIGIN_MISMATCH',
'PROD_TARGET_DB_NON_PRODUCTION_IDENTITY',
'DB_SHA256',
]) {
if (!productionIdentityBootstrapRemoteScriptContent.includes(requiredFragment)) {
errors.push(`production-identity-bootstrap-remote.sh 缺少只读身份合同: ${requiredFragment}`);
}
}
if (productionIdentityBootstrapRemoteScriptContent.includes('prisma migrate status')
|| productionIdentityBootstrapRemoteScriptContent.includes('migrate deploy')) {
errors.push('production-identity-bootstrap-remote.sh 不得进入 Prisma status 或迁移写入');
}
for (const requiredFragment of [ for (const requiredFragment of [
'systemctl show', 'systemctl show',
'DEPLOY_CONTRACT_KEYS', 'DEPLOY_CONTRACT_KEYS',
@@ -735,6 +777,10 @@ if (deployExistingScriptContent.indexOf('node "$ROOT_DIR/$PRODUCTION_PREFLIGHT_S
> deployExistingScriptContent.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"')) { > deployExistingScriptContent.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"')) {
errors.push('deploy-existing.sh 必须在 deploy.sh 前执行生产目标与迁移 preflight'); errors.push('deploy-existing.sh 必须在 deploy.sh 前执行生产目标与迁移 preflight');
} }
if (deployExistingScriptContent.indexOf('PRODUCTION_READ_ONLY_PREFLIGHT')
> deployExistingScriptContent.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"')) {
errors.push('deploy-existing.sh 的只读停止点必须位于 deploy.sh 前');
}
if (!deployScriptContent.includes('DEPLOY_STATUS_OUTPUT_MODE')) { if (!deployScriptContent.includes('DEPLOY_STATUS_OUTPUT_MODE')) {
errors.push('deploy.sh 必须支持受控发布 wrapper 的 quiet migration status 输出模式'); errors.push('deploy.sh 必须支持受控发布 wrapper 的 quiet migration status 输出模式');
} }
@@ -14,6 +14,8 @@ const REFRESH_HELPER = path.join(ROOT_DIR, "scripts/refresh-deployment-contract.
const REFRESH_INSTALLER = path.join(ROOT_DIR, "deploy/trialrun/install-deployment-contract-refresh.sh"); const REFRESH_INSTALLER = path.join(ROOT_DIR, "deploy/trialrun/install-deployment-contract-refresh.sh");
const PRODUCTION_PREFLIGHT = path.join(ROOT_DIR, "deploy/trialrun/production-preflight.mjs"); const PRODUCTION_PREFLIGHT = path.join(ROOT_DIR, "deploy/trialrun/production-preflight.mjs");
const PRODUCTION_PREFLIGHT_REMOTE = path.join(ROOT_DIR, "deploy/trialrun/production-preflight-remote.sh"); const PRODUCTION_PREFLIGHT_REMOTE = path.join(ROOT_DIR, "deploy/trialrun/production-preflight-remote.sh");
const PRODUCTION_IDENTITY_BOOTSTRAP = path.join(ROOT_DIR, "deploy/trialrun/production-identity-bootstrap.mjs");
const PRODUCTION_IDENTITY_BOOTSTRAP_REMOTE = path.join(ROOT_DIR, "deploy/trialrun/production-identity-bootstrap-remote.sh");
describe("deployment safety contract", () => { describe("deployment safety contract", () => {
let fixtureRoot; let fixtureRoot;
@@ -108,13 +110,21 @@ describe("deployment safety contract", () => {
const wrapper = await readFile(path.join(ROOT_DIR, "deploy/trialrun/deploy-existing.sh"), "utf8"); const wrapper = await readFile(path.join(ROOT_DIR, "deploy/trialrun/deploy-existing.sh"), "utf8");
const preflight = await readFile(PRODUCTION_PREFLIGHT, "utf8"); const preflight = await readFile(PRODUCTION_PREFLIGHT, "utf8");
const remote = await readFile(PRODUCTION_PREFLIGHT_REMOTE, "utf8"); const remote = await readFile(PRODUCTION_PREFLIGHT_REMOTE, "utf8");
const bootstrap = await readFile(PRODUCTION_IDENTITY_BOOTSTRAP, "utf8");
expect(wrapper).toContain("EXPECTED_PRODUCTION_ORIGIN"); expect(wrapper).toContain("EXPECTED_PRODUCTION_ORIGIN");
expect(wrapper).toContain("EXPECTED_PRODUCTION_DB_NAME"); expect(wrapper).toContain("EXPECTED_PRODUCTION_DB_NAME_SHA256");
expect(wrapper).toContain("EXPECTED_PENDING_MIGRATIONS"); expect(wrapper).toContain("EXPECTED_PENDING_MIGRATIONS");
expect(wrapper).toContain("EXPECTED_DEPLOY_SERVER");
expect(wrapper).toContain("EXPECTED_REMOTE_DIR");
expect(wrapper).toContain("EXPECTED_COMPOSE_PROJECT_NAME");
expect(wrapper).toContain("PRODUCTION_READ_ONLY_PREFLIGHT");
expect(wrapper.indexOf('node "$ROOT_DIR/$PRODUCTION_PREFLIGHT_SCRIPT"')).toBeLessThan( expect(wrapper.indexOf('node "$ROOT_DIR/$PRODUCTION_PREFLIGHT_SCRIPT"')).toBeLessThan(
wrapper.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"'), wrapper.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"'),
); );
expect(wrapper.indexOf('if [[ "${PRODUCTION_READ_ONLY_PREFLIGHT:-0}" == "1" ]]')).toBeLessThan(
wrapper.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"'),
);
expect(preflight).toContain("CANDIDATE_MIGRATIONS_CSV"); expect(preflight).toContain("CANDIDATE_MIGRATIONS_CSV");
expect(preflight).toContain("PRISMA_STATUS_OUTPUT_MISSING"); expect(preflight).toContain("PRISMA_STATUS_OUTPUT_MISSING");
expect(preflight).toContain("PRISMA_STATUS_OUTPUT_UNREADABLE"); expect(preflight).toContain("PRISMA_STATUS_OUTPUT_UNREADABLE");
@@ -128,6 +138,12 @@ describe("deployment safety contract", () => {
expect(remote).toContain("rollbackWindow=OPEN"); expect(remote).toContain("rollbackWindow=OPEN");
expect(remote).not.toMatch(/\b(prisma migrate reset|prisma db push|prisma migrate resolve)\b/); expect(remote).not.toMatch(/\b(prisma migrate reset|prisma db push|prisma migrate resolve)\b/);
expect(remote.indexOf("PRISMA_STATUS_BEGIN")).toBeLessThan(remote.indexOf("_prisma_migrations")); expect(remote.indexOf("PRISMA_STATUS_BEGIN")).toBeLessThan(remote.indexOf("_prisma_migrations"));
expect(bootstrap.indexOf("assertExactHistoricalContract")).toBeLessThan(
bootstrap.indexOf("bootstrapDatabaseIdentityHash"),
);
expect(bootstrap.indexOf("bootstrapDatabaseIdentityHash(identity")).toBeLessThan(
bootstrap.indexOf("spawnSync(\"bash\", [DEPLOY_EXISTING_SCRIPT]"),
);
}); });
it("rejects loopback/staging production identities without invoking SSH", async () => { it("rejects loopback/staging production identities without invoking SSH", async () => {
@@ -147,6 +163,53 @@ describe("deployment safety contract", () => {
expect(() => target.validateProductionDbName("hyb_oa_regression")).toThrowError( expect(() => target.validateProductionDbName("hyb_oa_regression")).toThrowError(
expect.objectContaining({ code: "PROD_TARGET_DB_NON_PRODUCTION_IDENTITY" }), expect.objectContaining({ code: "PROD_TARGET_DB_NON_PRODUCTION_IDENTITY" }),
); );
expect(() => target.validateExactProductionIdentity({
deployServer: "deploy.production.invalid",
remoteDir: "/srv/hyb-oa-trialrun",
composeProjectName: "hyb-oa-trialrun",
expectedDeployServer: "deploy.production.invalid",
expectedRemoteDir: "/srv/hyb-oa-trialrun",
expectedComposeProjectName: "hyb-oa-trialrun",
})).not.toThrow();
expect(() => target.validateExactProductionIdentity({
deployServer: "deploy.production.invalid",
remoteDir: "/srv/hyb-oa-trialrun",
composeProjectName: "hyb-oa-trialrun",
expectedDeployServer: "deploy.production.invalid",
expectedRemoteDir: "/srv/different",
expectedComposeProjectName: "hyb-oa-trialrun",
})).toThrowError(expect.objectContaining({ code: "REMOTE_DIR_MISMATCH" }));
expect(() => target.validateExactProductionIdentity({
deployServer: "deploy.production.invalid",
remoteDir: "/srv/hyb-oa-trialrun",
composeProjectName: "hyb-oa-trialrun",
expectedDeployServer: "different.production.invalid",
expectedRemoteDir: "/srv/hyb-oa-trialrun",
expectedComposeProjectName: "hyb-oa-trialrun",
})).toThrowError(expect.objectContaining({ code: "DEPLOY_SERVER_MISMATCH" }));
expect(() => target.validateExactProductionIdentity({
deployServer: "deploy.production.invalid",
remoteDir: "/srv/hyb-oa-trialrun",
composeProjectName: "hyb-oa-trialrun",
expectedDeployServer: "deploy.production.invalid",
expectedRemoteDir: "/srv/hyb-oa-trialrun",
expectedComposeProjectName: "different",
})).toThrowError(expect.objectContaining({ code: "COMPOSE_PROJECT_NAME_MISMATCH" }));
expect(() => target.validateExactProductionIdentity({
deployServer: "deploy.production.invalid",
remoteDir: "/srv/hyb-oa-trialrun",
composeProjectName: "hyb-oa-trialrun",
expectedDeployServer: "deploy.production.invalid",
expectedRemoteDir: "/srv/hyb-oa-trialrun",
})).toThrowError(expect.objectContaining({ code: "EXPECTED_COMPOSE_PROJECT_NAME_MISSING" }));
expect(() => target.validateExactProductionIdentity({
deployServer: "deploy.staging.invalid",
remoteDir: "/srv/hyb-oa-trialrun",
composeProjectName: "hyb-oa-trialrun",
expectedDeployServer: "deploy.staging.invalid",
expectedRemoteDir: "/srv/hyb-oa-trialrun",
expectedComposeProjectName: "hyb-oa-trialrun",
})).toThrowError(expect.objectContaining({ code: "DEPLOY_SERVER_NON_PRODUCTION_IDENTITY" }));
expect(() => target.validatePrismaStatusText("")).toThrowError( expect(() => target.validatePrismaStatusText("")).toThrowError(
expect.objectContaining({ code: "PRISMA_STATUS_OUTPUT_UNREADABLE" }), expect.objectContaining({ code: "PRISMA_STATUS_OUTPUT_UNREADABLE" }),
); );
@@ -188,16 +251,19 @@ describe("deployment safety contract", () => {
env: { env: {
...process.env, ...process.env,
PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`, PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`,
DEPLOY_SERVER: "deploy.example.invalid", DEPLOY_SERVER: "deploy.production.invalid",
REMOTE_DIR: remoteDir, REMOTE_DIR: remoteDir,
REMOTE_ENV_FILE: envFile, REMOTE_ENV_FILE: envFile,
COMPOSE_FILE: "deploy/trialrun/docker-compose.yml", COMPOSE_FILE: "deploy/trialrun/docker-compose.yml",
COMPOSE_PROJECT_NAME: "production_app", COMPOSE_PROJECT_NAME: "hyb-oa-trialrun",
POSTGRES_DATA_DIR: postgresDir, POSTGRES_DATA_DIR: postgresDir,
STORAGE_HOST_DIR: storageDir, STORAGE_HOST_DIR: storageDir,
EXPECTED_PRODUCTION_ORIGIN: "https://oa.hyinda.com", EXPECTED_PRODUCTION_ORIGIN: "https://oa.hyinda.com",
EXPECTED_PRODUCTION_DB_NAME: "hyb_oa", EXPECTED_PRODUCTION_DB_NAME_SHA256: createHash("sha256").update("hyb_oa", "utf8").digest("hex"),
EXPECTED_PENDING_MIGRATIONS: "", EXPECTED_PENDING_MIGRATIONS: "",
EXPECTED_DEPLOY_SERVER: "deploy.production.invalid",
EXPECTED_REMOTE_DIR: remoteDir,
EXPECTED_COMPOSE_PROJECT_NAME: "hyb-oa-trialrun",
CANDIDATE_COMMIT: candidateCommit, CANDIDATE_COMMIT: candidateCommit,
PRODUCTION_PREFLIGHT_REPORT_DIR: reportDir, PRODUCTION_PREFLIGHT_REPORT_DIR: reportDir,
FAKE_SSH_CAPTURE: sshCapture, FAKE_SSH_CAPTURE: sshCapture,
@@ -218,6 +284,63 @@ describe("deployment safety contract", () => {
expect(sshCommand).toContain("EXPECTED_PRODUCTION_DB_NAME_SHA256"); expect(sshCommand).toContain("EXPECTED_PRODUCTION_DB_NAME_SHA256");
}); });
it("bootstraps only an exact historical identity and a unique refresh probe", async () => {
const bootstrap = await import(pathToFileURL(PRODUCTION_IDENTITY_BOOTSTRAP).href);
const historicalTarget = [
'DEPLOY_SERVER="${DEPLOY_SERVER:-deploy.production.invalid}"',
'REMOTE_DIR="${REMOTE_DIR:-/srv/hyb-oa-trialrun}"',
].join("\n");
const historicalProject = [
'REMOTE_DIR="${REMOTE_DIR:-/srv/hyb-oa-trialrun}"',
'COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-hyb-oa-trialrun}"',
].join("\n");
expect(bootstrap.parseHistoricalDeploymentIdentity(historicalTarget, historicalProject)).toEqual({
deployServer: "deploy.production.invalid",
remoteDir: "/srv/hyb-oa-trialrun",
composeProjectName: "hyb-oa-trialrun",
});
expect(bootstrap.selectUniqueRefreshTarget([
{ target: "ordinary", status: 1, stdout: "" },
{ target: "refresh", status: 0, stdout: "[deployment-contract-refresh][PASS] probe\n" },
])).toBe("refresh");
expect(() => bootstrap.selectUniqueRefreshTarget([])).toThrowError(
expect.objectContaining({ code: "REFRESH_IDENTITY_NOT_FOUND" }),
);
expect(() => bootstrap.selectUniqueRefreshTarget([
{ target: "refresh-a", status: 0, stdout: "[deployment-contract-refresh][PASS] probe" },
{ target: "refresh-b", status: 0, stdout: "[deployment-contract-refresh][PASS] probe" },
])).toThrowError(expect.objectContaining({ code: "REFRESH_IDENTITY_NOT_UNIQUE" }));
const contract = bootstrap.parseDeploymentContract([
"REMOTE_DIR=/srv/hyb-oa-trialrun",
"REMOTE_ENV_FILE=/srv/hyb-oa-trialrun/deploy/trialrun/.env",
"COMPOSE_FILE=deploy/trialrun/docker-compose.yml",
"COMPOSE_PROJECT_NAME=hyb-oa-trialrun",
"POSTGRES_DATA_DIR=/srv/data/postgres",
"STORAGE_HOST_DIR=/srv/data/storage",
].join(" "));
expect(() => bootstrap.assertExactHistoricalContract(
bootstrap.parseHistoricalDeploymentIdentity(historicalTarget, historicalProject),
contract,
)).not.toThrow();
expect(() => bootstrap.assertExactHistoricalContract(
bootstrap.parseHistoricalDeploymentIdentity(historicalTarget, historicalProject),
{ ...contract, COMPOSE_PROJECT_NAME: "different" },
)).toThrowError(expect.objectContaining({ code: "SYSTEMD_COMPOSE_PROJECT_MISMATCH" }));
expect(() => bootstrap.parseHistoricalDeploymentIdentity(
historicalTarget,
historicalProject.replace("/srv/hyb-oa-trialrun", "/srv/different"),
)).toThrowError(expect.objectContaining({ code: "HISTORICAL_REMOTE_DIR_DIVERGED" }));
const bootstrapSource = await readFile(PRODUCTION_IDENTITY_BOOTSTRAP, "utf8");
const remoteSource = await readFile(PRODUCTION_IDENTITY_BOOTSTRAP_REMOTE, "utf8");
expect(bootstrapSource).not.toContain("console.log(databaseIdentityHash)");
expect(bootstrapSource).not.toContain("console.log(identity)");
expect(remoteSource).toContain("DB_SHA256");
expect(remoteSource).not.toContain("DB_BASE64");
expect(remoteSource).not.toContain("prisma migrate status");
});
it("runs the remote gate against synthetic status and migration history", async () => { it("runs the remote gate against synthetic status and migration history", async () => {
const fakeBin = path.join(fixtureRoot, "remote-bin"); const fakeBin = path.join(fixtureRoot, "remote-bin");
await mkdir(fakeBin, { recursive: true }); await mkdir(fakeBin, { recursive: true });
@@ -277,7 +400,7 @@ describe("deployment safety contract", () => {
REMOTE_DIR: remoteDir, REMOTE_DIR: remoteDir,
REMOTE_ENV_FILE: envFile, REMOTE_ENV_FILE: envFile,
COMPOSE_FILE: "deploy/trialrun/docker-compose.yml", COMPOSE_FILE: "deploy/trialrun/docker-compose.yml",
COMPOSE_PROJECT_NAME: "production_app", COMPOSE_PROJECT_NAME: "hyb-oa-trialrun",
POSTGRES_DATA_DIR: postgresDir, POSTGRES_DATA_DIR: postgresDir,
STORAGE_HOST_DIR: storageDir, STORAGE_HOST_DIR: storageDir,
EXPECTED_PRODUCTION_ORIGIN_SHA256: hash("https://oa.example.com"), EXPECTED_PRODUCTION_ORIGIN_SHA256: hash("https://oa.example.com"),