From f11e2f2c257dfddf7fc142c1982fdde054d06d24 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 8 Aug 2026 17:33:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0(08-08-production-deployment-?= =?UTF-8?q?execution):=20=E6=A0=A1=E5=87=86=E7=94=9F=E4=BA=A7=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E5=8F=AA=E8=AF=BB=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/deployment-trialrun.md | 63 +++++ .../design.md | 8 +- .../implement.md | 13 +- .../prd.md | 4 +- deploy/trialrun/deploy-existing.sh | 34 ++- .../production-identity-bootstrap-remote.sh | 95 +++++++ .../production-identity-bootstrap.mjs | 242 ++++++++++++++++++ .../trialrun/production-preflight-remote.sh | 4 +- deploy/trialrun/production-preflight.mjs | 52 +++- deploy/trialrun/validate-env-contract.js | 48 +++- .../deployment-safety-contract.test.js | 133 +++++++++- 11 files changed, 666 insertions(+), 30 deletions(-) create mode 100755 deploy/trialrun/production-identity-bootstrap-remote.sh create mode 100755 deploy/trialrun/production-identity-bootstrap.mjs diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index 3da0bf1b..3dbe2dba 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -19,6 +19,69 @@ operations require explicit approval and evidence. - Do not execute real payments, import bank statements, connect payment 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 ### 1. Scope / Trigger diff --git a/.trellis/tasks/08-08-production-deployment-execution/design.md b/.trellis/tasks/08-08-production-deployment-execution/design.md index 436b92f1..332dfb75 100644 --- a/.trellis/tasks/08-08-production-deployment-execution/design.md +++ b/.trellis/tasks/08-08-production-deployment-execution/design.md @@ -41,11 +41,11 @@ ### 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 时才采用;零个或多个候选均停止。 3. 使用恢复的部署目标只读读取受控 systemd contract,并要求 contract 的远端目录、Compose project 及其余路径合同通过 13 项既有校验;历史身份与当前 contract 不一致即停止。 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。 @@ -70,11 +70,13 @@ status 原文仅存在受控 SSH/执行现场;本地 helper 写入 `prisma-mig DEPLOY_SERVER=<历史身份恢复,仅进程内> \ DEPLOY_REFRESH_SERVER= \ EXPECTED_PRODUCTION_ORIGIN= \ -EXPECTED_PRODUCTION_DB_NAME=<只读引导,仅进程内> \ +EXPECTED_PRODUCTION_DB_NAME_SHA256=<只读引导所得无明文比较基线,仅进程内> \ EXPECTED_PENDING_MIGRATIONS=<明确 allowlist> \ 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。 部署后 helper 以空 pending allowlist重新执行 target/status/reconciliation,要求 schema up to date;随后才执行 refresh 和只读 readiness。refresh 失败分类为 `DEPLOYED_BUT_CONTRACT_REFRESH_FAILED`,不重复发布应用。 diff --git a/.trellis/tasks/08-08-production-deployment-execution/implement.md b/.trellis/tasks/08-08-production-deployment-execution/implement.md index 9c55ca0c..d6300630 100644 --- a/.trellis/tasks/08-08-production-deployment-execution/implement.md +++ b/.trellis/tasks/08-08-production-deployment-execution/implement.md @@ -5,7 +5,7 @@ - [x] 已向用户呈现本 task 的最终规划摘要,并取得对该摘要的最新明确批准。 - [x] 已解决候选发布是否可进入上游的外部授权;未获授权不得使用 unpublished override。 - [x] 任务从 `planning` 切换到 `in_progress` 前,确认本清单、PRD、design 与 Open Questions 已收敛。 -- [ ] **规划修订复核:**确认唯一公网环境拓扑、精确身份匹配和数据库名只读引导的修订摘要,并重新批准后再改 tooling/连接生产。 +- [x] **规划修订复核:**确认唯一公网环境拓扑、精确身份匹配和数据库名只读引导的修订摘要,并重新批准后再改 tooling/连接生产。 ## 1. 候选与本地证据 @@ -21,11 +21,12 @@ ## 1.1 拓扑校准与 tooling 修订(生产前) - [ ] **刻意次序调整:**本轮 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。 -- [ ] 修改 production preflight:取消对合法 `trialrun` 命名的单独拒绝,增加部署目标、远端目录、Compose project、systemd contract、公网 origin 的精确身份对账;保留回环/零地址/local regression/staging/shadow/placeholder 防线。 -- [ ] 增加只读数据库身份 bootstrap:身份组匹配后读取远端 `.env` 的 `POSTGRES_DB`,只在内存中传给 target gate,禁止回显/落盘;读取失败或身份组不一致时不得进入 Prisma status。 -- [ ] 增加 synthetic fixture:合法 `hyb-oa-trialrun` 身份通过;错误目录/project/host/db、回环、staging、占位符和多 refresh 候选 fail-closed;断言 bootstrap 不泄露值且顺序早于 status。 +- [x] 修改 production preflight:取消对合法 `trialrun` 命名的单独拒绝,增加部署目标、远端目录、Compose project、systemd contract、公网 origin 的精确身份对账;保留回环/零地址/local regression/staging/shadow/placeholder 防线。 +- [x] 增加只读数据库身份 bootstrap:身份组匹配后读取远端 `.env` 的 `POSTGRES_DB`,在远端转换为 SHA-256 比较基线后仅在内存中传给 target gate;数据库名明文禁止离开远端、回显或落盘,读取失败或身份组不一致时不得进入 Prisma 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。 ## 2. 生产 preflight(全部只读) @@ -92,6 +93,6 @@ ## 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,不改业务代码或生产数据。 - 修订后的第一条远端动作仍是只读身份 bootstrap;数据库名读取排在 exact identity 匹配之后,任何 bootstrap 或 target gate FAIL 都不得继续。 diff --git a/.trellis/tasks/08-08-production-deployment-execution/prd.md b/.trellis/tasks/08-08-production-deployment-execution/prd.md index 00d567ea..a798b741 100644 --- a/.trellis/tasks/08-08-production-deployment-execution/prd.md +++ b/.trellis/tasks/08-08-production-deployment-execution/prd.md @@ -29,7 +29,7 @@ ### R1 发布来源与生产目标 - 候选 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` 唯一识别;零个或多个候选均立即停止,不按别名、顺序或连接速度猜测。 - 生产数据库名只允许在部署目标、远端目录、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。 @@ -90,7 +90,7 @@ ## Operational Inputs (not committed) - `EXPECTED_PRODUCTION_ORIGIN`(从现行 OPS7 公网基准在进程内取得) -- `EXPECTED_PRODUCTION_DB_NAME`(仅在精确身份引导通过后,从远端 `.env` 只读取得并保留在同一进程) +- `EXPECTED_PRODUCTION_DB_NAME_SHA256`(仅在精确身份引导通过后,于远端从 `.env` 读取数据库名并转换为不含明文的比较基线;数据库名明文本身不离开远端,也不进入日志或证据文件) - `EXPECTED_PENDING_MIGRATIONS` - 受控 `DEPLOY_SERVER`(从已审查历史入口恢复)、`DEPLOY_REFRESH_SERVER`(exact probe 唯一识别)、systemd contract 与 live smoke 凭据(如获批提供) diff --git a/deploy/trialrun/deploy-existing.sh b/deploy/trialrun/deploy-existing.sh index cdce062b..c2726413 100755 --- a/deploy/trialrun/deploy-existing.sh +++ b/deploy/trialrun/deploy-existing.sh @@ -42,15 +42,28 @@ deployment_contract_load "$DEPLOY_SERVER" "$DEPLOY_CONTRACT_UNIT" contract \ echo "[deploy-existing] 已读取受控 unit 合同;路径与标识不回显" -if [[ -z "${EXPECTED_PRODUCTION_ORIGIN+x}" ]]; then +if [[ -z "${EXPECTED_PRODUCTION_ORIGIN:-}" ]]; then fail "生产部署必须显式提供 EXPECTED_PRODUCTION_ORIGIN(仅进程环境)" fi -if [[ -z "${EXPECTED_PRODUCTION_DB_NAME+x}" ]]; then - fail "生产部署必须显式提供 EXPECTED_PRODUCTION_DB_NAME(仅进程环境)" +if [[ -z "${EXPECTED_PRODUCTION_DB_NAME_SHA256:-}" ]]; then + fail "生产部署必须由身份引导器提供 EXPECTED_PRODUCTION_DB_NAME_SHA256" fi if [[ -z "${EXPECTED_PENDING_MIGRATIONS+x}" ]]; then fail "生产部署必须显式提供 EXPECTED_PENDING_MIGRATIONS(可为空)" 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/$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)" 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_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" \ REMOTE_DIR="${contract[REMOTE_DIR]}" \ 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" \ || 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" \ REMOTE_DIR="${contract[REMOTE_DIR]}" \ REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \ @@ -93,8 +114,11 @@ bash "$ROOT_DIR/deploy/trialrun/deploy.sh" echo ">>> 6.1 部署后 migration status 与目标只读复核" 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_DEPLOY_SERVER="$EXPECTED_DEPLOY_SERVER" \ +EXPECTED_REMOTE_DIR="$EXPECTED_REMOTE_DIR" \ +EXPECTED_COMPOSE_PROJECT_NAME="$EXPECTED_COMPOSE_PROJECT_NAME" \ DEPLOY_SERVER="$DEPLOY_SERVER" \ REMOTE_DIR="${contract[REMOTE_DIR]}" \ REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \ diff --git a/deploy/trialrun/production-identity-bootstrap-remote.sh b/deploy/trialrun/production-identity-bootstrap-remote.sh new file mode 100755 index 00000000..bc3a4876 --- /dev/null +++ b/deploy/trialrun/production-identity-bootstrap-remote.sh @@ -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")" diff --git a/deploy/trialrun/production-identity-bootstrap.mjs b/deploy/trialrun/production-identity-bootstrap.mjs new file mode 100755 index 00000000..d6637f22 --- /dev/null +++ b/deploy/trialrun/production-identity-bootstrap.mjs @@ -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(); diff --git a/deploy/trialrun/production-preflight-remote.sh b/deploy/trialrun/production-preflight-remote.sh index 488d62f2..e3f82562 100755 --- a/deploy/trialrun/production-preflight-remote.sh +++ b/deploy/trialrun/production-preflight-remote.sh @@ -65,8 +65,8 @@ reject_non_production_identity() { [[ "$value" != *"<"* && "$value" != *">"* ]] || return 1 [[ "$value" != *"localhost"* && "$value" != *"127.0.0.1"* \ && "$value" != *"::1"* && "$value" != *"hyb_oa_regression"* \ - && "$value" != *"trialrun"* && "$value" != *"staging"* \ - && "$value" != *"stage"* && "$value" != *"regression"* \ + && "$value" != *"staging"* && "$value" != *"stage"* \ + && "$value" != *"regression"* \ && "$value" != *"shadow"* && "$value" != *"/test"* ]] } diff --git a/deploy/trialrun/production-preflight.mjs b/deploy/trialrun/production-preflight.mjs index 206c87a1..d2111562 100755 --- a/deploy/trialrun/production-preflight.mjs +++ b/deploy/trialrun/production-preflight.mjs @@ -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_COMPOSE_FILE_PATTERN = /^[A-Za-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; export class ProductionPreflightError extends Error { @@ -135,6 +135,34 @@ function assertSafeContractValue(name, value, pattern) { 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) { const result = spawnSync("git", args, { cwd: ROOT_DIR, @@ -216,14 +244,18 @@ function main() { const postgresDataDir = String(process.env.POSTGRES_DATA_DIR ?? "").trim(); const storageHostDir = String(process.env.STORAGE_HOST_DIR ?? "").trim(); 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 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 (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"); 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, { field: "EXPECTED_PENDING_MIGRATIONS", }); @@ -234,6 +266,14 @@ function main() { assertSafeContractValue("STORAGE_HOST_DIR", storageHostDir, SAFE_ABSOLUTE_PATH_PATTERN); assertSafeContractValue("COMPOSE_FILE", composeFile, SAFE_COMPOSE_FILE_PATTERN); assertSafeContractValue("COMPOSE_PROJECT_NAME", composeProjectName, SAFE_PROJECT_PATTERN); + validateExactProductionIdentity({ + deployServer, + remoteDir, + composeProjectName, + expectedDeployServer, + expectedRemoteDir, + expectedComposeProjectName, + }); const candidateCommit = String( process.env.CANDIDATE_COMMIT ?? gitText(["rev-parse", "--verify", "HEAD"]), @@ -266,7 +306,7 @@ function main() { `POSTGRES_DATA_DIR=${shellQuote(postgresDataDir)}`, `STORAGE_HOST_DIR=${shellQuote(storageHostDir)}`, `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(","))}`, `EXPECTED_PENDING_MIGRATIONS_CSV=${shellQuote(expectedPending.join(","))}`, "bash -s", @@ -283,7 +323,7 @@ function main() { const rawStatus = extractPrismaStatus(stdout); if (rawStatus != null) { validatePrismaStatusText(rawStatus); - const sanitizedStatus = sanitizePrismaStatus(rawStatus, [expectedOrigin, expectedDbName]); + const sanitizedStatus = sanitizePrismaStatus(rawStatus, [expectedOrigin]); writeFileSync(path.join(reportDir, "prisma-migrate-status.txt"), `${sanitizedStatus}\n`, { encoding: "utf8", mode: 0o600, diff --git a/deploy/trialrun/validate-env-contract.js b/deploy/trialrun/validate-env-contract.js index 9861f06c..ee48d4c5 100755 --- a/deploy/trialrun/validate-env-contract.js +++ b/deploy/trialrun/validate-env-contract.js @@ -43,6 +43,14 @@ const productionPreflightRemoteScriptPath = path.join( rootDir, '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 timerScriptPath = path.resolve( process.env.ENV_CONTRACT_TIMER_SCRIPT_PATH @@ -279,6 +287,8 @@ const deploymentRefreshHelperContent = readFile(deploymentRefreshHelperPath); const remotePreflightScriptContent = readFile(remotePreflightScriptPath); const productionPreflightScriptContent = readFile(productionPreflightScriptPath); const productionPreflightRemoteScriptContent = readFile(productionPreflightRemoteScriptPath); +const productionIdentityBootstrapScriptContent = readFile(productionIdentityBootstrapScriptPath); +const productionIdentityBootstrapRemoteScriptContent = readFile(productionIdentityBootstrapRemoteScriptPath); const dockerfileContent = readFile(dockerfilePath); const timerScriptContent = readFile(timerScriptPath); const automaticBackupTimerScriptContent = readFile(automaticBackupTimerScriptPath); @@ -604,8 +614,12 @@ for (const requiredFragment of [ for (const requiredFragment of [ 'EXPECTED_PRODUCTION_ORIGIN', - 'EXPECTED_PRODUCTION_DB_NAME', + 'EXPECTED_PRODUCTION_DB_NAME_SHA256', 'EXPECTED_PENDING_MIGRATIONS', + 'EXPECTED_DEPLOY_SERVER', + 'EXPECTED_REMOTE_DIR', + 'EXPECTED_COMPOSE_PROJECT_NAME', + 'PRODUCTION_READ_ONLY_PREFLIGHT', 'production-preflight.mjs', 'CANDIDATE_COMMIT', '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 [ 'systemctl show', '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"')) { 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')) { errors.push('deploy.sh 必须支持受控发布 wrapper 的 quiet migration status 输出模式'); } diff --git a/server/tests/unit/scripts/deployment-safety-contract.test.js b/server/tests/unit/scripts/deployment-safety-contract.test.js index dd05555f..bc19a456 100644 --- a/server/tests/unit/scripts/deployment-safety-contract.test.js +++ b/server/tests/unit/scripts/deployment-safety-contract.test.js @@ -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 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_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", () => { 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 preflight = await readFile(PRODUCTION_PREFLIGHT, "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_DB_NAME"); + expect(wrapper).toContain("EXPECTED_PRODUCTION_DB_NAME_SHA256"); 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( 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("PRISMA_STATUS_OUTPUT_MISSING"); expect(preflight).toContain("PRISMA_STATUS_OUTPUT_UNREADABLE"); @@ -128,6 +138,12 @@ describe("deployment safety contract", () => { expect(remote).toContain("rollbackWindow=OPEN"); 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(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 () => { @@ -147,6 +163,53 @@ describe("deployment safety contract", () => { expect(() => target.validateProductionDbName("hyb_oa_regression")).toThrowError( 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.objectContaining({ code: "PRISMA_STATUS_OUTPUT_UNREADABLE" }), ); @@ -188,16 +251,19 @@ describe("deployment safety contract", () => { env: { ...process.env, PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`, - DEPLOY_SERVER: "deploy.example.invalid", + DEPLOY_SERVER: "deploy.production.invalid", REMOTE_DIR: remoteDir, REMOTE_ENV_FILE: envFile, COMPOSE_FILE: "deploy/trialrun/docker-compose.yml", - COMPOSE_PROJECT_NAME: "production_app", + COMPOSE_PROJECT_NAME: "hyb-oa-trialrun", POSTGRES_DATA_DIR: postgresDir, STORAGE_HOST_DIR: storageDir, 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_DEPLOY_SERVER: "deploy.production.invalid", + EXPECTED_REMOTE_DIR: remoteDir, + EXPECTED_COMPOSE_PROJECT_NAME: "hyb-oa-trialrun", CANDIDATE_COMMIT: candidateCommit, PRODUCTION_PREFLIGHT_REPORT_DIR: reportDir, FAKE_SSH_CAPTURE: sshCapture, @@ -218,6 +284,63 @@ describe("deployment safety contract", () => { 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 () => { const fakeBin = path.join(fixtureRoot, "remote-bin"); await mkdir(fakeBin, { recursive: true }); @@ -277,7 +400,7 @@ describe("deployment safety contract", () => { REMOTE_DIR: remoteDir, REMOTE_ENV_FILE: envFile, COMPOSE_FILE: "deploy/trialrun/docker-compose.yml", - COMPOSE_PROJECT_NAME: "production_app", + COMPOSE_PROJECT_NAME: "hyb-oa-trialrun", POSTGRES_DATA_DIR: postgresDir, STORAGE_HOST_DIR: storageDir, EXPECTED_PRODUCTION_ORIGIN_SHA256: hash("https://oa.example.com"),