From bf2631c0d41e222181862559ba92284f358cd1b7 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 8 Aug 2026 19:36:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3(08-08-production-deployment-?= =?UTF-8?q?execution):=20=E5=85=88=E9=AA=8C=E7=94=9F=E4=BA=A7=E5=85=AC?= =?UTF-8?q?=E7=BD=91=E8=BA=AB=E4=BB=BD=E5=86=8D=E5=86=BB=E7=BB=93=E7=9B=AE?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/deployment-trialrun.md | 21 ++--- .../design.md | 13 ++-- .../implement.md | 3 +- .../prd.md | 11 ++- .../production-identity-bootstrap-remote.sh | 20 +++-- .../production-identity-bootstrap.mjs | 40 +++++++--- deploy/trialrun/validate-env-contract.js | 14 +++- .../deployment-safety-contract.test.js | 77 ++++++++++++++++--- 8 files changed, 151 insertions(+), 48 deletions(-) diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index 3dbe2dba..926c85ab 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -29,30 +29,32 @@ operations require explicit approval and evidence. ### 2. Signatures - Bootstrap entry: `node deploy/trialrun/production-identity-bootstrap.mjs`. +- Identity-only stop: `PRODUCTION_IDENTITY_ONLY=1 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. +- Recover deployment target and Compose project from reviewed Git history. A shell `${REMOTE_DIR:-...}` default that operations routinely override is a fallback, not proof of the production repository root. - 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. +- Compare target and project against their independent history. Use a structurally validated systemd contract only to locate the remote env/runtime, verify both origin samples, then freeze its directory as the exact expected value for later preflight. 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. +- Identity-only mode must stop after env/runtime origin agreement and contract-directory freeze; it must not read `POSTGRES_DB`, run aggregate checks or enter Prisma status. +- Read `POSTGRES_DB` only after history target/project, systemd contract structure and both public-origin samples 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. +- Historical target/project or contract-relative-path 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. +- Good: a legacy-named deployment passes target/project checks, both origin samples match, the contract directory becomes the exact in-process baseline, then identity-only mode exits before DB/status reads. - 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. @@ -60,10 +62,10 @@ operations require explicit approval and evidence. ### 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. +- Separate negative assertions and stable error codes for deploy target, Compose project, contract-relative env path and later preflight directory 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. +- Ordering assertion: contract structure -> env/runtime origin -> contract-directory freeze -> identity-only exit; only a later approved run may read DB -> target/pre-status/window -> read-only exit -> `deploy.sh` when read-only mode is off. - Evidence assertions proving target/path/database values are absent from stdout and committed reports. ### 7. Wrong vs Correct @@ -77,8 +79,9 @@ 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 +reviewed target/project + unique refresh probe + contract structure +-> env/runtime origin agreement -> freeze contract directory -> identity-only stop +-> separately approved remote-only DB identity hash -> target/pre-status/window -> explicit read-only stop or separately approved deploy ``` diff --git a/.trellis/tasks/08-08-production-deployment-execution/design.md b/.trellis/tasks/08-08-production-deployment-execution/design.md index 332dfb75..c89fa5fa 100644 --- a/.trellis/tasks/08-08-production-deployment-execution/design.md +++ b/.trellis/tasks/08-08-production-deployment-execution/design.md @@ -13,7 +13,8 @@ ```text 候选发布来源 -> 历史身份恢复 + refresh exact probe(只读) - -> systemd contract / 公网 origin 精确核对(只读) + -> systemd contract 结构读取 + 公网 origin 双取样精确核对(只读) + -> origin PASS 后冻结 contract REMOTE_DIR 为进程基线(只读) -> 远端 POSTGRES_DB 只读引导(同一进程、不回显) -> 上游/工作区/13 项部署体检 -> 生产 target identity(只读) @@ -41,13 +42,13 @@ ### 4.1 只读身份引导 -1. 从已审查历史提交 `34181e3` 恢复既有 `DEPLOY_SERVER`、`REMOTE_DIR`,从首次显式冻结 Compose project 的提交 `5e2d03893826` 恢复 `COMPOSE_PROJECT_NAME`;两份历史记录的 `REMOTE_DIR` 必须一致,且不输出任何字面值。 +1. 从已审查历史提交 `34181e3` 恢复既有 `DEPLOY_SERVER`,从首次显式冻结 Compose project 的提交 `5e2d03893826` 恢复 `COMPOSE_PROJECT_NAME`;不输出任何字面值。两份脚本里的 `REMOTE_DIR` 只是可被显式输入覆盖的 fallback,不作为生产目录基线。 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` 并在远端转换为 SHA-256 比较基线;数据库名明文不离开远端、不打印、不写文件。该基线用于后续 env、`current_database()` 和运行时 `DATABASE_URL` 三方一致性核对。 +3. 使用恢复的部署目标只读读取受控 systemd contract,先校验六项存在、路径/Compose 格式、`REMOTE_ENV_FILE` 必须位于 contract `REMOTE_DIR` 的固定相对位置、Compose file 固定且 project 与历史基线一致;此时不把历史目录 fallback 与 contract 比较。 +4. 从 OPS7 当前公网检查基准取得预期 HTTPS origin;以 contract 仅作为定位信息,只读比较远端 `.env` 和运行容器的 `APP_ORIGIN`。两个独立取样均一致后才确认目标主机,并把 contract `REMOTE_DIR` 冻结为当前进程 `EXPECTED_REMOTE_DIR`;origin 不一致时不得采信或输出该目录。 +5. 身份确认复核模式在第 4 步成功后立即停止,不读取数据库名、不进入 13 项体检或 Prisma。获得继续批准后,重新执行第 4 步,并从同一远端 `.env` 读取 `POSTGRES_DB`、在远端转换为 SHA-256 比较基线;数据库名明文不离开远端、不打印、不写文件。该基线用于后续 env、`current_database()` 和运行时 `DATABASE_URL` 三方一致性核对。 -数据库名读取不是“连接任意目标后相信其自述”:它必须排在历史目标、远端 contract 和公网 origin 三层独立匹配之后。任一前置失败时不得读取或进入 Prisma status。 +数据库名读取不是“连接任意目标后相信其自述”:它必须排在历史 target/project、contract 结构约束和公网 origin 双取样之后。任一前置失败时不得读取或进入 Prisma status。 ### 4.2 完整 target/pre-status diff --git a/.trellis/tasks/08-08-production-deployment-execution/implement.md b/.trellis/tasks/08-08-production-deployment-execution/implement.md index 1a1e15a8..38e41492 100644 --- a/.trellis/tasks/08-08-production-deployment-execution/implement.md +++ b/.trellis/tasks/08-08-production-deployment-execution/implement.md @@ -21,12 +21,13 @@ ## 1.1 拓扑校准与 tooling 修订(生产前) - [ ] **刻意次序调整:**本轮 target gate 不再是第一条生产命令;历史身份恢复、refresh exact probe、systemd contract/公网 origin 核对、远端 `POSTGRES_DB` 只读引导四步探测排在其之前。四步全程只读、逐步 fail-closed,任一失败均不得进入 target gate 或 Prisma status。 -- [x] 从 `34181e3` 只读恢复历史 `DEPLOY_SERVER`、`REMOTE_DIR`,从 `5e2d03893826` 恢复首次显式冻结的 `COMPOSE_PROJECT_NAME`,并要求两份历史记录的 `REMOTE_DIR` 一致;仅保存在当前进程,不把值写入 task、日志或对话。 +- [x] 从 `34181e3` 只读恢复历史 `DEPLOY_SERVER`,从 `5e2d03893826` 恢复首次显式冻结的 `COMPOSE_PROJECT_NAME`;确认历史 `REMOTE_DIR` 只是未验证 fallback,不作为身份基线。值仅保存在当前进程,不写入 task、日志或对话。 - [x] 无回显探测 `DEPLOY_REFRESH_SERVER`,要求 exact forced-command `probe` 唯一 PASS;多个候选或零候选均 PAUSED。 - [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` 前退出;只读证据复核通过前不得运行写入路径。 +- [x] 增加 `PRODUCTION_IDENTITY_ONLY=1` 停止点:以 contract 定位远端 `.env`/容器并完成 origin 双取样后,冻结 contract `REMOTE_DIR` 为进程内精确基线并立即退出;不得读取数据库名、运行 13 项体检或 Prisma。 - [ ] 重新运行部署安全测试、shell/Node syntax、task/governance/sensitive/diff 门禁,提交并推送新的候选后重新通过 provenance。 ## 2. 生产 preflight(全部只读) diff --git a/.trellis/tasks/08-08-production-deployment-execution/prd.md b/.trellis/tasks/08-08-production-deployment-execution/prd.md index a798b741..5c0d2a27 100644 --- a/.trellis/tasks/08-08-production-deployment-execution/prd.md +++ b/.trellis/tasks/08-08-production-deployment-execution/prd.md @@ -10,6 +10,7 @@ - 生产部署入口是 `deploy/trialrun/deploy-existing.sh`。它读取受控 systemd deployment contract,固定 `SYNC_ENV=0`、`DEPLOY_DATA_MODE=existing`,并在 `deploy.sh` 前调用生产 target/pre-migration gate,部署后再做 post-status gate。 - 当前没有独立预发环境:历史命名为 `hyb-oa-trialrun` 的既有部署就是承载公网 OA 的唯一运行环境。`trialrun` 是遗留命名,不代表另一套非生产实例;这次 schema 迁移无法先在同构预发环境演练。 +- Git 历史中的 `REMOTE_DIR="${REMOTE_DIR:-...}"` 是未证明曾生效的 fallback,不是生产目录事实源。只有历史 SSH target 与 Compose project 继续作为独立基线;`REMOTE_DIR` 必须在远端 `.env` 和运行容器 origin 双取样一致后,从该生产主机的受控 systemd contract 冻结为当前进程精确基线。 - 生产 target gate 必须确认 HTTPS 公网 origin、`NODE_ENV=production`、`COOKIE_SECURE=true`、数据库身份和既有部署合同。不得再以名称中含 `trialrun` 作为拒绝依据;改为精确匹配已确认的部署身份组,并继续拒绝回环、本地回归库、staging、shadow、测试占位符等真实非生产目标。 - pre-status 的原始 stdout/stderr 只能留在受控执行现场;任务目录和报告只保存脱敏副本、稳定状态码、迁移 ID、计数和证据引用。 - 候选发布必须是干净、已提交并已进入上游的 commit。规划阶段曾发现 `origin/main` 落后并已通过受控提交解决;严格 wrapper 仍拒绝未发布提交 override,任何规划修订都必须重新提交、推送并复核 provenance。 @@ -29,10 +30,11 @@ ### R1 发布来源与生产目标 - 候选 commit 必须干净、已提交、已进入配置的上游远端;严格 wrapper 不接受 `DEPLOY_ALLOW_UNPUBLISHED_COMMIT` 或 `DEPLOY_UNPUBLISHED_REASON`。 -- 公网 origin 从当前 OPS7 运维交接 Runbook 的现行公网检查基准取得;部署 SSH 目标和远端目录从已审查历史提交 `34181e3` 恢复,Compose project 从首次显式冻结该值的历史提交 `5e2d03893826` 恢复,并要求两份历史记录的远端目录一致。三项只在执行进程内使用,不复制到任务文档或普通日志。 +- 公网 origin 从当前 OPS7 运维交接 Runbook 的现行公网检查基准取得;部署 SSH 目标从已审查历史提交 `34181e3` 恢复,Compose project 从首次显式冻结该值的历史提交 `5e2d03893826` 恢复。历史 `REMOTE_DIR` fallback 不参与生产身份判断。 - `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。 +- 使用 structurally valid 的 systemd contract 定位远端 `.env` 和运行容器,只读比较两个 `APP_ORIGIN` 取样;两者均与 OPS7 基准一致后,才确认该主机承载生产 origin,并把 contract 的 `REMOTE_DIR` 冻结为当前进程预期值。origin 不一致立即停止,不得采信 contract 目录。 +- 生产数据库名只允许在部署目标、Compose project、受控 systemd contract 和公网 origin 已确认后,通过一次获批的远端 `.env` 只读读取取得;值仅保留在远端并转换为 target gate 所需的比较输入,不回显、不落盘。 +- target gate 必须精确验证 `DEPLOY_SERVER`、origin 验证后冻结的 `REMOTE_DIR`、`COMPOSE_PROJECT_NAME`、公网 origin 与读取后的 `POSTGRES_DB` 身份组,并继续核对运行时 `DATABASE_URL` host/database、当前数据库、`NODE_ENV` 和 cookie 安全配置;任一身份不确定均 fail-closed。 - `EXPECTED_PENDING_MIGRATIONS` 可先以空集合运行只读 pre-status 发现实际 pending;只要实际集合非空或不等于批准 allowlist,就必须停止并逐条复核 SQL,不能自动进入部署。 ### R2 pre-status 与回滚窗口先行 @@ -63,7 +65,7 @@ ## Acceptance Criteria - [ ] AC1:候选 commit 已提交、工作区 clean、已进入上游;来源校验和既有 13 项体检均 PASS。若候选未发布,任务在此处 PAUSED,不使用 override。 -- [ ] AC2:只读身份引导证明历史部署目标、远端目录、Compose project、受控 systemd contract 与公网 origin 属于同一唯一运行环境;数据库名只在该前提下读取到同一进程。target gate 的原始输出在受控现场可供人工阅读,脱敏副本只证明 PASS/FAIL 和原因,未泄露任何身份值。 +- [ ] AC2:只读身份引导证明历史部署目标、Compose project、受控 systemd contract 与公网 origin 属于同一唯一运行环境;远端 `.env` 与运行容器 origin 双取样均匹配后,contract `REMOTE_DIR` 已冻结为进程内精确基线,数据库名只在该前提下读取。target gate 的原始输出在受控现场可供人工阅读,脱敏副本只证明 PASS/FAIL 和原因,未泄露任何身份值。 - [ ] AC3:任何 schema 写入前完成 pre-status;原始 status 输出与脱敏副本可对账,异常集合为空,pending 与 allowlist 精确一致,`terminated` 行数和 `rollbackWindow=OPEN|CLOSED` 判定已记录。 - [ ] AC4:仅在 AC1–AC3 全部 PASS 后调用 `deploy-existing.sh`;未执行 reset、db push、resolve、手工 DDL、seed 之外的业务写入或真实 `terminated` 写入。 - [ ] AC5:部署后 status 显示全部候选迁移 applied/schema up to date,`/ready`、build SHA、smoke、external/runtime smoke、bundle scan 结果与候选 commit 一致。 @@ -90,6 +92,7 @@ ## Operational Inputs (not committed) - `EXPECTED_PRODUCTION_ORIGIN`(从现行 OPS7 公网基准在进程内取得) +- `PRODUCTION_IDENTITY_ONLY=1`(只完成 origin 双取样与 contract 目录基线冻结,随后在数据库读取和 pre-status 前退出) - `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/production-identity-bootstrap-remote.sh b/deploy/trialrun/production-identity-bootstrap-remote.sh index bc3a4876..0151f5a7 100755 --- a/deploy/trialrun/production-identity-bootstrap-remote.sh +++ b/deploy/trialrun/production-identity-bootstrap-remote.sh @@ -9,11 +9,16 @@ fail() { exit 1 } -for key in REMOTE_DIR REMOTE_ENV_FILE COMPOSE_FILE COMPOSE_PROJECT_NAME \ - EXPECTED_PRODUCTION_ORIGIN_SHA256; do +for key in IDENTITY_BOOTSTRAP_MODE REMOTE_DIR REMOTE_ENV_FILE COMPOSE_FILE \ + COMPOSE_PROJECT_NAME EXPECTED_PRODUCTION_ORIGIN_SHA256; do [[ -n "${!key:-}" ]] || fail "${key}_MISSING" done +case "$IDENTITY_BOOTSTRAP_MODE" in + origin|database) ;; + *) fail "IDENTITY_BOOTSTRAP_MODE_INVALID" ;; +esac + [[ "$EXPECTED_PRODUCTION_ORIGIN_SHA256" =~ ^[0-9a-f]{64}$ ]] \ || fail "PROD_TARGET_ORIGIN_HASH_INVALID" cd "$REMOTE_DIR" @@ -62,11 +67,8 @@ reject_non_production_identity() { } 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") @@ -90,6 +92,14 @@ runtime_origin="$( [[ "$(hash_text "$runtime_origin")" == "$EXPECTED_PRODUCTION_ORIGIN_SHA256" ]] \ || fail "PROD_TARGET_RUNTIME_ORIGIN_MISMATCH" +if [[ "$IDENTITY_BOOTSTRAP_MODE" == "origin" ]]; then + printf '[production-identity-bootstrap][PASS] origin_identity\n' + exit 0 +fi + # Only the comparison baseline crosses the SSH channel. The database name # itself remains remote and cannot be reconstructed from task evidence. +actual_db_name="$(dotenv_value POSTGRES_DB)" || fail "PROD_TARGET_DB_NAME_MISSING" +reject_non_production_identity "$actual_db_name" \ + || fail "PROD_TARGET_DB_NON_PRODUCTION_IDENTITY" 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 index d6637f22..509bcf1a 100755 --- a/deploy/trialrun/production-identity-bootstrap.mjs +++ b/deploy/trialrun/production-identity-bootstrap.mjs @@ -60,14 +60,10 @@ function parseHistoricalDefault(source, key) { 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 }; + return { deployServer, composeProjectName }; } export function parseExpectedProductionOrigin(runbook) { @@ -145,9 +141,8 @@ export function parseDeploymentContract(raw) { 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`) { +export function assertContractForOriginVerification(identity, contract) { + if (contract.REMOTE_ENV_FILE !== `${contract.REMOTE_DIR}/deploy/trialrun/.env`) { fail("SYSTEMD_REMOTE_ENV_FILE_MISMATCH"); } if (contract.COMPOSE_FILE !== "deploy/trialrun/docker-compose.yml") { @@ -166,8 +161,9 @@ function shellQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; } -function bootstrapDatabaseIdentityHash(identity, contract, expectedOrigin) { +function runRemoteIdentityBootstrap(mode, identity, contract, expectedOrigin) { const remoteCommand = [ + `IDENTITY_BOOTSTRAP_MODE=${shellQuote(mode)}`, `REMOTE_DIR=${shellQuote(contract.REMOTE_DIR)}`, `REMOTE_ENV_FILE=${shellQuote(contract.REMOTE_ENV_FILE)}`, `COMPOSE_FILE=${shellQuote(contract.COMPOSE_FILE)}`, @@ -181,7 +177,18 @@ function bootstrapDatabaseIdentityHash(identity, contract, expectedOrigin) { 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(); + return String(result.stdout ?? "").trim(); +} + +function verifyOriginIdentity(identity, contract, expectedOrigin) { + const output = runRemoteIdentityBootstrap("origin", identity, contract, expectedOrigin); + if (output !== "[production-identity-bootstrap][PASS] origin_identity") { + fail("REMOTE_ORIGIN_IDENTITY_PAYLOAD_INVALID"); + } +} + +function bootstrapDatabaseIdentityHash(identity, contract, expectedOrigin) { + const output = runRemoteIdentityBootstrap("database", identity, contract, expectedOrigin); 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]; @@ -189,6 +196,8 @@ function bootstrapDatabaseIdentityHash(identity, contract, expectedOrigin) { function main() { try { + const identityOnly = process.env.PRODUCTION_IDENTITY_ONLY ?? "0"; + if (!["0", "1"].includes(identityOnly)) fail("PRODUCTION_IDENTITY_ONLY_INVALID"); const historicalTargetScript = requiredCommandOutput( "git", ["show", `${HISTORICAL_TARGET_COMMIT}:${DEPLOY_SCRIPT_AT_COMMIT}`], @@ -208,7 +217,14 @@ function main() { "SYSTEMD_CONTRACT_UNAVAILABLE", ); const contract = parseDeploymentContract(contractRaw); - assertExactHistoricalContract(identity, contract); + assertContractForOriginVerification(identity, contract); + verifyOriginIdentity(identity, contract, expectedOrigin); + const expectedRemoteDir = contract.REMOTE_DIR; + + if (identityOnly === "1") { + console.log("[production-identity-bootstrap][PASS] origin_verified_remote_dir_baseline=systemd_contract"); + return; + } const databaseIdentityHash = bootstrapDatabaseIdentityHash(identity, contract, expectedOrigin); console.log("[production-identity-bootstrap][PASS] exact_identity_group"); @@ -220,7 +236,7 @@ function main() { DEPLOY_SERVER: identity.deployServer, DEPLOY_REFRESH_SERVER: refreshTarget, EXPECTED_DEPLOY_SERVER: identity.deployServer, - EXPECTED_REMOTE_DIR: identity.remoteDir, + EXPECTED_REMOTE_DIR: expectedRemoteDir, EXPECTED_COMPOSE_PROJECT_NAME: identity.composeProjectName, EXPECTED_PRODUCTION_ORIGIN: expectedOrigin, EXPECTED_PRODUCTION_DB_NAME_SHA256: databaseIdentityHash, diff --git a/deploy/trialrun/validate-env-contract.js b/deploy/trialrun/validate-env-contract.js index ee48d4c5..72690c03 100755 --- a/deploy/trialrun/validate-env-contract.js +++ b/deploy/trialrun/validate-env-contract.js @@ -453,6 +453,14 @@ for (const requiredFragment of [ errors.push(`install-warch3-ops-timer.sh 缺少活动部署环境绑定: ${requiredFragment}`); } } +if (productionIdentityBootstrapScriptContent.indexOf('if (identityOnly === "1")') + > productionIdentityBootstrapScriptContent.indexOf('const databaseIdentityHash = bootstrapDatabaseIdentityHash')) { + errors.push('production-identity-bootstrap.mjs 的 identity-only 停止点必须位于数据库身份读取前'); +} +if (productionIdentityBootstrapScriptContent.indexOf('verifyOriginIdentity(identity, contract, expectedOrigin);') + > productionIdentityBootstrapScriptContent.indexOf('const expectedRemoteDir = contract.REMOTE_DIR')) { + errors.push('production-identity-bootstrap.mjs 必须在 origin 双取样后冻结 contract REMOTE_DIR'); +} for (const requiredFragment of [ 'MODE="${1:-install}"', @@ -634,8 +642,10 @@ for (const requiredFragment of [ 'HISTORICAL_TARGET_COMMIT', 'HISTORICAL_PROJECT_COMMIT', 'discoverRefreshTarget', - 'assertExactHistoricalContract', + 'assertContractForOriginVerification', + 'verifyOriginIdentity', 'bootstrapDatabaseIdentityHash', + 'PRODUCTION_IDENTITY_ONLY', 'EXPECTED_PRODUCTION_DB_NAME_SHA256', 'DEPLOY_EXISTING_SCRIPT', ]) { @@ -647,6 +657,8 @@ for (const requiredFragment of [ 'EXPECTED_PRODUCTION_ORIGIN_SHA256', 'PROD_TARGET_RUNTIME_ORIGIN_MISMATCH', 'PROD_TARGET_DB_NON_PRODUCTION_IDENTITY', + 'origin_identity', + 'IDENTITY_BOOTSTRAP_MODE', 'DB_SHA256', ]) { if (!productionIdentityBootstrapRemoteScriptContent.includes(requiredFragment)) { diff --git a/server/tests/unit/scripts/deployment-safety-contract.test.js b/server/tests/unit/scripts/deployment-safety-contract.test.js index bc19a456..1aa27489 100644 --- a/server/tests/unit/scripts/deployment-safety-contract.test.js +++ b/server/tests/unit/scripts/deployment-safety-contract.test.js @@ -138,10 +138,19 @@ 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("assertContractForOriginVerification")).toBeLessThan( + bootstrap.indexOf("verifyOriginIdentity(identity, contract, expectedOrigin);"), ); - expect(bootstrap.indexOf("bootstrapDatabaseIdentityHash(identity")).toBeLessThan( + expect(bootstrap.indexOf("verifyOriginIdentity(identity, contract, expectedOrigin);")).toBeLessThan( + bootstrap.indexOf("const expectedRemoteDir = contract.REMOTE_DIR"), + ); + expect(bootstrap.indexOf("const expectedRemoteDir = contract.REMOTE_DIR")).toBeLessThan( + bootstrap.indexOf("const databaseIdentityHash = bootstrapDatabaseIdentityHash"), + ); + expect(bootstrap.indexOf('if (identityOnly === "1")')).toBeLessThan( + bootstrap.indexOf("const databaseIdentityHash = bootstrapDatabaseIdentityHash"), + ); + expect(bootstrap.indexOf("const databaseIdentityHash = bootstrapDatabaseIdentityHash")).toBeLessThan( bootstrap.indexOf("spawnSync(\"bash\", [DEPLOY_EXISTING_SCRIPT]"), ); }); @@ -296,7 +305,6 @@ describe("deployment safety contract", () => { ].join("\n"); expect(bootstrap.parseHistoricalDeploymentIdentity(historicalTarget, historicalProject)).toEqual({ deployServer: "deploy.production.invalid", - remoteDir: "/srv/hyb-oa-trialrun", composeProjectName: "hyb-oa-trialrun", }); expect(bootstrap.selectUniqueRefreshTarget([ @@ -319,18 +327,27 @@ describe("deployment safety contract", () => { "POSTGRES_DATA_DIR=/srv/data/postgres", "STORAGE_HOST_DIR=/srv/data/storage", ].join(" ")); - expect(() => bootstrap.assertExactHistoricalContract( + expect(() => bootstrap.assertContractForOriginVerification( bootstrap.parseHistoricalDeploymentIdentity(historicalTarget, historicalProject), contract, )).not.toThrow(); - expect(() => bootstrap.assertExactHistoricalContract( + const relocatedContract = { + ...contract, + REMOTE_DIR: "/srv/current-production", + REMOTE_ENV_FILE: "/srv/current-production/deploy/trialrun/.env", + }; + expect(() => bootstrap.assertContractForOriginVerification( + bootstrap.parseHistoricalDeploymentIdentity(historicalTarget, historicalProject), + relocatedContract, + )).not.toThrow(); + expect(() => bootstrap.assertContractForOriginVerification( 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" })); + expect(() => bootstrap.assertContractForOriginVerification( + bootstrap.parseHistoricalDeploymentIdentity(historicalTarget, historicalProject), + { ...contract, REMOTE_ENV_FILE: "/srv/different/.env" }, + )).toThrowError(expect.objectContaining({ code: "SYSTEMD_REMOTE_ENV_FILE_MISMATCH" })); const bootstrapSource = await readFile(PRODUCTION_IDENTITY_BOOTSTRAP, "utf8"); const remoteSource = await readFile(PRODUCTION_IDENTITY_BOOTSTRAP_REMOTE, "utf8"); @@ -339,6 +356,46 @@ describe("deployment safety contract", () => { expect(remoteSource).toContain("DB_SHA256"); expect(remoteSource).not.toContain("DB_BASE64"); expect(remoteSource).not.toContain("prisma migrate status"); + expect(remoteSource.indexOf('IDENTITY_BOOTSTRAP_MODE" == "origin"')).toBeLessThan( + remoteSource.indexOf("actual_db_name="), + ); + }); + + it("verifies both origin samples before reading the remote database name", async () => { + const fakeBin = path.join(fixtureRoot, "origin-bin"); + await mkdir(fakeBin, { recursive: true }); + await writeFile(path.join(fakeBin, "docker"), [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "joined=\"$*\"", + "if [[ \"$1\" == compose && \"${2:-}\" == version ]]; then exit 0; fi", + "if [[ \"$1\" == ps ]]; then printf 'server1\\n'; exit 0; fi", + "if [[ \"$1\" == inspect ]]; then printf 'APP_ORIGIN=https://example.invalid\\n'; exit 0; fi", + "printf 'unexpected fake docker call: %s\\n' \"$joined\" >&2", + "exit 97", + "", + ].join("\n"), { mode: 0o700 }); + await chmod(path.join(fakeBin, "docker"), 0o700); + const hash = createHash("sha256").update("https://example.invalid", "utf8").digest("hex"); + const runBootstrapRemote = (mode) => execFileAsync("bash", [PRODUCTION_IDENTITY_BOOTSTRAP_REMOTE], { + cwd: remoteDir, + env: { + ...process.env, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`, + IDENTITY_BOOTSTRAP_MODE: mode, + REMOTE_DIR: remoteDir, + REMOTE_ENV_FILE: envFile, + COMPOSE_FILE: "deploy/trialrun/docker-compose.yml", + COMPOSE_PROJECT_NAME: "hyb-oa-trialrun", + EXPECTED_PRODUCTION_ORIGIN_SHA256: hash, + }, + }); + await expect(runBootstrapRemote("origin")).resolves.toMatchObject({ + stdout: "[production-identity-bootstrap][PASS] origin_identity\n", + }); + await expect(runBootstrapRemote("database")).rejects.toMatchObject({ + stderr: expect.stringContaining("PROD_TARGET_DB_NAME_MISSING"), + }); }); it("runs the remote gate against synthetic status and migration history", async () => {