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