修正(08-08-production-deployment-execution): 先验生产公网身份再冻结目录
这个提交包含在:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(全部只读)
|
||||
|
||||
@@ -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 凭据(如获批提供)
|
||||
|
||||
@@ -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")"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户