修复自动备份运行身份漂移(07-28-automatic-backup-runtime-identity-failure)

这个提交包含在:
2026-07-28 07:54:37 +08:00
父节点 4e3c6c4376
当前提交 cba74cf97d
共修改 9 个文件,包含 198 行新增和 1 行删除
+30 -1
查看文件
@@ -1005,6 +1005,13 @@ test "$(printf '%s\n' "$actual" | LC_ALL=C sort)" = \
Compose-project-mismatched state. A fresh recovery point with a different
build SHA is `PASS_WITH_FINDINGS`, because deploy-time code identity and
recoverable data freshness are separate facts.
- Automatic-backup units freeze the active Compose project, build, PGDATA and
storage mount when installed. After any deployment, rollback or direct
container rebuild that changes the active container identity or host mounts,
compare all frozen values with the unique running server/database pair. If
the units are already installed and any value differs, rerun the existing
installer with the complete current contract and recheck all identities
before declaring the deployment complete; do not patch one unit in place.
- Installer `install` is disabled-by-default; `enable`, real transfer, real
WeCom alert and all server/account/key changes remain `EXTERNAL_GATED`.
@@ -1027,6 +1034,10 @@ test "$(printf '%s\n' "$actual" | LC_ALL=C sort)" = \
authority -> preserve all objects and fail.
- Missing, corrupt, stale, future or project-mismatched success record -> hard
watchdog failure. Fresh build mismatch -> one finding without a failing exit.
- `phase_stale_check_failed` can also mean the watchdog failed its runtime
identity precheck before reading the success record. Diagnose the nested safe
category and active-unit identity first; do not infer that the last archive is
stale or corrupt from this outer phase alone.
- Missing/non-regular/symlink/non-`0600` Webhook file -> fail closed without
printing the file value. Identical alert state -> update/evaluate without send.
- Ordinary shell, rsync sender/read/delete or archive overwrite -> receiver
@@ -1040,12 +1051,16 @@ test "$(printf '%s\n' "$actual" | LC_ALL=C sort)" = \
resumes inside inbox, ARM finalize verifies and archives it, then production
writes success and prunes old automatic source points. If production later
stops, ARM independently raises one dead-man alert and one recovery notice.
- Good: a deployment rebuilds the server with a different storage mount, then
refreshes the installed backup units through the installer and proves every
frozen identity matches before closing the deployment.
- Base: local implementation and synthetic contracts pass while both server
installers remain disabled and explicitly externally gated.
- Bad: send through `compose exec server`, hard-code a personal employee number,
persist a robot URL in `.env`/unit/repository, give production a normal ARM
login key, put archive below inbox, accept a new host fingerprint automatically,
or prune local points before target-side finalize.
prune local points before target-side finalize, or leave installed backup
units bound to a pre-deployment mount until the next scheduled alert.
### 6. Tests Required
@@ -1068,6 +1083,10 @@ test "$(printf '%s\n' "$actual" | LC_ALL=C sort)" = \
timers, ARM dead-man timer, explicit project/data/build/SSH/Webhook-file
binding, disabled-by-default installation and absence of a literal complete
robot URL in tracked or untracked repository files.
- Deployment and rebuild review must assert that installed production units
still match the unique active project, build, PGDATA and storage mount. A
changed mount with otherwise healthy `/ready` is not a completed deployment
until installer refresh, timer state and runtime identity all pass.
- Real ARM setup, first paired batch, deliberate source-timer stop or transfer
failure, dead-man first/repeated/recovery behavior and quarterly shadow restore
are separately approved operational evidence. A direct robot send test alone
@@ -1078,6 +1097,10 @@ test "$(printf '%s\n' "$actual" | LC_ALL=C sort)" = \
#### Wrong
```bash
# Rebuild the application, observe /ready, but leave installed backup units
# frozen to the pre-deployment runtime identity.
docker-compose <approved-compose-arguments> up -d --build server
compose exec server send-alert --recipient=<personal-employee-number>
rsync -a <batch>/ backup-host:<archive>/
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" db pg_dump ...
@@ -1086,6 +1109,12 @@ docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" db pg_dump ...
#### Correct
```bash
# After a deployment/rebuild, compare the installed unit contract with the
# unique active server/database pair. If any frozen identity changed, refresh
# all units through the installer before verifying timers and identity again.
<complete-current-contract-env> \
bash deploy/trialrun/install-automatic-backup-timer.sh enable
# Install only with explicit active identity and a root-only Webhook file path;
# default remains disabled.
<explicit-project-data-build-ssh-and-webhook-file-env> \
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,37 @@
# 自动备份运行身份失败修复设计
## 1. Root Cause
自动备份安装时把附件宿主目录冻结进 systemd unit。后续 server 容器重建使用了另一实际宿主目录,但 unit 未同步刷新。runner 在 `runtime_identity` 阶段比较 Docker 实际 mount source 与 unit 的 `STORAGE_HOST_DIR`,发现不一致后在 DB dump、附件归档和传输前失败关闭。
严格检查本身正确:两个目录均存在、非符号链接、realpath 和 inode 不同,因此不能通过路径规范化或放宽校验修复。
## 2. Safe Refresh
从 daily unit 读取现有环境合同,从唯一运行 server 容器解析实际附件 mount source,仅在内存中替换 `STORAGE_HOST_DIR`。验证所有路径、权限、build 对应关系后,调用仓库安装器 `enable` 原子重写三个 service/timer 文件并 daemon-reload;写入前保存三个 services/timers 的 root-only 临时副本用于失败回滚,成功验证后删除副本。
不在命令输出、task 或文档中记录实际路径、容器 ID、连接串或 webhook。
## 3. Validation Sequence
```text
local 73-check contract
-> production read-only recheck
-> snapshot six unit files
-> installer enable with actual storage mount
-> six-dimension runtime identity check
-> manual daily service start
-> verify new success state + ARM finalize + recovery alert state
-> verify timers and unrelated monitors
-> remove temporary unit snapshot
```
## 4. Rollback
- 安装器或 unit 验证失败:恢复六个 unit 快照、daemon-reload、恢复原 timer 状态;不运行备份。
- unit 正确但补跑备份后续阶段失败:不恢复旧 `STORAGE_HOST_DIR`,因为旧值已证实错误;保留新配置、失败摘要和本地诊断,按具体 phase 另行修复。
- 新批次只有在 ARM finalize 后才更新 success state 和触发本地保留策略,失败不会删除现有最后成功恢复点。
## 5. Prevention
把“部署后 active mount 与自动备份 unit 合同一致”写入部署/试运行规约和 checklist。涉及 data path 的部署不能只验证应用 `/ready`;若自动备份 units 已安装,必须在交付结束前核对或刷新冻结的 project/build/PGDATA/storage 身份。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,37 @@
# 自动备份运行身份失败实施清单
## 1. Pre-fix
- [x] 确认 daily service 02:30 非零退出且告警类别为 `phase_runtime_identity_failed`。
- [x] 按 runner 的 Compose 选择逻辑确认唯一根因为附件宿主挂载不匹配。
- [x] 确认不是 symlink/realpath/inode 等价,其他五项 runtime identity 通过。
- [x] 确认最近成功恢复点约 11 小时,尚未超过 watchdog/dead-man 窗口。
- [x] 确认 07:10 `phase_stale_check_failed` 由 watchdog 复用同一 runtime identity 检查触发,不是独立的归档损坏结论。
- [x] 运行本地 `bash scripts/qa/run-automatic-backup-contract.sh`(73 项通过)。
## 2. Unit Refresh
- [x] 重新只读解析当前实际附件 mount source并验证安全边界。
- [x] 保存六个自动备份 unit 的 root-only 临时快照。
- [x] 沿用现有 unit 合同,仅替换实际附件宿主目录,调用安装器 `enable`。
- [x] 验证 daily/watchdog/drill timers enabled/active 和六项 runtime identity 全部匹配。
## 3. Recovery Backup
- [x] 手工启动 daily service;记录稳定 phase/result,不输出原始日志。
- [x] 验证新的 last-success 时间、项目/build 身份和 manifest 哈希格式。
- [x] 通过仅在 ARM finalize 成功后写入的 success state 验证新归档闭环;普通 ARM 账号无 archive 读取权限,未扩大权限取证。
- [x] 验证 alert state 为 `PASS_AUTOMATIC_BACKUP_COMPLETE`。
- [x] 用户确认群内只收到一条 `PASS_AUTOMATIC_BACKUP_COMPLETE / healthy` 恢复通知。
- [x] 验证应用健康 timer、备份 watchdog/drill 和 ARM dead-man/receiver 不受影响。
## 4. Cleanup and Governance
- [x] 删除临时 unit 快照;不删除旧目录、旧批次或失败证据。
- [x] 更新部署规约/checklist 的自动备份 unit 漂移门禁。
- [x] 运行 task validate、governance、sensitive、docs audit 和 `git diff --check`;全部无错误,docs audit 仅保留既有 22 项未登记文档 warning。
## 5. Rollback
- unit 刷新或 identity 复检失败:恢复六个 unit 快照并 daemon-reload,不补跑备份。
- 补跑在 paired/offhost phase 失败:保留已纠正 unit,依据安全 phase 诊断继续处理,不回退旧附件目录。
@@ -0,0 +1,62 @@
# 自动备份运行身份失败
## Goal
恢复每日自动成对备份,使生产 unit 冻结的存储路径重新与当前运行容器一致,补跑并完成一次数据库、附件和 ARM 异机归档闭环,同时保留失败告警的脱敏证据。
## Scope
- 只读确认 02:30 `phase_runtime_identity_failed` 的精确失败维度和最近成功恢复点。
- 使用当前运行 server 容器的实际附件挂载源刷新自动备份三个 systemd services/timers;其他已验证环境值沿用现有 unit,禁止打印路径或秘密。
- 刷新前后验证 active server/db 数量、运行状态、构建 SHA、附件挂载、PGDATA 和数据库身份。
- 手工运行一次 daily backup,要求本地完整批次、ARM finalize、success state 和一条恢复通知全部成功。
- 保持应用健康监控、备份 receiver、ARM dead-man 和业务服务不变。
## Confirmed Facts
- 2026-07-28 02:30 daily service 以非零状态失败,脱敏状态为 `FAIL_AUTOMATIC_BACKUP / phase_runtime_identity_failed`。
- 生产机使用 standalone `docker-compose`;最初诊断命令误用 `docker compose`,已按 runner 相同选择逻辑更正,未把诊断工具错误当作根因。
- 当前 server/db 各唯一且运行中;unit 冻结的构建 SHA、PGDATA、应用/数据库身份均与运行容器匹配。
- 唯一不匹配项是 `STORAGE_HOST_DIR`:unit 配置目录和当前容器实际挂载源都存在,但 realpath/inode 均不同,不是符号链接或路径规范化差异。
- unit 早于当前 server 容器启动,说明部署/重建后容器使用了新附件目录,而自动备份 unit 仍保留旧目录。
- 最近一次成功异机恢复点约 11 小时前,项目和构建身份均匹配;当前未超过 24/26 小时窗口,但本次 02:30 没有产生新恢复点。
- runtime identity 阶段不写 `last-error.log`,因此没有原始诊断残留;脚本按设计在任何备份写入前失败关闭。
- 2026-07-28 07:10 watchdog 又发送 `phase_stale_check_failed`。`check-stale` 在读取 success state 前同样调用 `resolve_active_runtime`,因此该消息由同一个附件挂载漂移触发;它不是第二个独立故障,也不证明最后成功批次本身损坏或过期。
## Requirements
- R1:刷新前再次从运行容器解析唯一实际附件挂载源,要求安全绝对目录、存在、非符号链接,且不得输出路径。
- R2:不得直接手改 unit 单行;使用已提交安装器以完整合同重写 daily/watchdog/drill units,并保持三个 timers enabled/active。
- R3:除 `STORAGE_HOST_DIR` 改为当前实际挂载源外,Compose project、PGDATA、build、远端目标、SSH、webhook 和备份目录等合同必须与现有 unit 一致。
- R4:刷新后先只读复跑六项 runtime identity;任何一项不匹配均不得补跑备份。
- R5:手工补跑必须使用现有 runner,完成数据库 dump、附件归档、manifest、受限传输和 ARM finalize 后才写 success state。
- R6:成功后应把当前告警状态(最新类别为 `phase_stale_check_failed`)切换为 `PASS_AUTOMATIC_BACKUP_COMPLETE` 并发送一次恢复通知;相同健康状态不得重复发送。
- R7:不得删除旧成功批次、旧目录或失败状态;正常保留策略只在新批次已由 ARM finalize 后运行。
- R8:如果 unit 刷新失败,恢复原 unit 文件并 daemon-reload;如果补跑失败,保留刷新后的正确 identity 配置和脱敏失败状态,不回退到旧附件目录。
## Acceptance Criteria
- [x] AC1:根因证据明确为非规范化的附件宿主目录漂移,其他 runtime identity 维度全部匹配。
- [x] AC2:三个自动备份 timer 仍为 enabled/active,刷新后的 daily unit 与当前附件挂载、PGDATA、build、项目和数据库身份全部匹配。
- [x] AC3:手工 daily backup 成功生成并在 ARM finalize 一个新的完整批次,生产 success state 时间更新。
- [x] AC4:群内只收到一次自动备份恢复通知,状态为成功;无秘密、路径或原始日志进入消息/任务证据。
- [x] AC5:应用健康 timer、ARM backup dead-man 和备份 receiver 未受影响。
- [x] AC6:补充防复发合同:部署后若容器宿主挂载变化,必须刷新/核对自动备份 unit,不能等次日告警发现。
## Out of Scope
- 修改数据库、附件内容、OA/Caddy、备份保留数量或 ARM 归档结构。
- 删除旧附件目录或判断其业务处置。
- 重置数据库、恢复数据、改 webhook、SSH key 或真实业务数据。
- 将路径、容器 ID、数据库身份、连接串或原始日志写入文档/聊天。
## Test Plan
- 刷新前后运行脱敏 runtime identity 矩阵。
- 运行本地自动备份 73 项合同,确保安装器/helper/runner 行为无回归。
- 生产补跑后检查 service result、last-success 新鲜度、ARM 新批次 finalize、alert state 和三个 timers。
- 运行 task validate、Trellis governance、敏感内容扫描、文档审计和 `git diff --check`。
## Key Decision
立即修复 unit 的附件路径并补跑备份,不修改严格相等检查。两个目录不是同一 inode,放宽为 realpath 或“目录存在即可”会掩盖真实数据源漂移。
@@ -0,0 +1,26 @@
{
"id": "automatic-backup-runtime-identity-failure",
"name": "automatic-backup-runtime-identity-failure",
"title": "自动备份运行身份失败",
"description": "诊断 02:30 自动备份 phase_runtime_identity_failed 告警,确认影响范围、根因与安全修复路径。",
"status": "in_progress",
"dev_type": "operations",
"scope": "deployment",
"package": "hyb-oa",
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-28",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "02:30 daily 与 07:10 watchdog 均由自动备份 unit 的旧附件宿主目录漂移触发,其他运行身份正常。已用现有安装器刷新 units,三个 timer 与全部运行身份通过;手工补跑、ARM finalize、成功状态、单条群恢复通知和关联监控复核通过,临时快照已删除。73 项合同及治理、敏感、流程、文档和格式门禁无错误,全部验收项闭环。",
"meta": {}
}
+1
查看文件
@@ -93,6 +93,7 @@
- [ ] 公网 live smoke(只读,不做写操作):`bash scripts/qa/run-web-gates.sh smoke-live`
- [ ] 若本地已配置 `LIVE_SMOKE_*`,确认 `bash deploy/trialrun/deploy.sh` 输出中包含 live smoke 结果
- [ ] 已确认 release gate / live smoke 结果为通过;关键链路不再作为每次发布的手工必做项
- [ ] 若自动备份 units 已安装,已脱敏核对其冻结的 Compose project、build、PGDATA、附件宿主挂载与当前唯一活动 server/db;任一项变化均已用现有安装器按完整合同刷新,三个 timer 仍为 enabled/active,未直接手改 unit
- [ ] 查看 server 日志(无异常错误风暴):
- [ ] `docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env logs --tail=200 server`
- [ ] 已确认 smoke 账号为独立账号(`smoke-admin` / `smoke-employee`),且不参与真实业务
+3
查看文件
@@ -236,6 +236,8 @@ manifest 必须至少记录一个真实来源标识:活动 `/ready` 有构建
- 生产与 ARM 都直接使用独立企微运维群机器人,不依赖 OA server、数据库或个人工号。Webhook 正文只由用户手工写入两台主机的 root-only `0600` 文件;仓库、普通 `.env`、systemd unit、日志和聊天只能出现受控文件路径,不能出现 URL 或 key。
- 失败告警只包含阶段、状态、finding 数和类别建议;原始命令诊断只保留在源端状态目录的 `0600` 最近失败日志。同一失败降噪;首次失败、状态恶化、新 finding、恢复通知和每季度首次恢复提醒才发送。新部署构建与最近恢复点 build SHA 不同只记 finding,缺失、损坏、超龄、未来时间或 Compose project 不同仍硬失败。
自动备份 unit 会冻结安装时的 Compose project、build、PGDATA 和附件宿主挂载。每次发布、回滚或直接重建容器后,若这些 units 已安装,必须把 unit 中的四项身份与唯一活动 server/db 逐项脱敏核对;任一项变化都要使用现有安装器和完整当前合同刷新 units,再复核三个 timer 与全部运行身份,不能直接手改单个 unit,也不能等次日定时告警后再处理。`phase_stale_check_failed` 是 watchdog 外层阶段,它也可能表示读取成功状态前的运行身份检查失败;应先看安全子类别和 unit/活动容器身份,不能仅凭该阶段认定归档已经超龄或损坏。
创建机器人后,由用户分别登录两台主机,在 root shell 中用静默输入把完整 Webhook 写入受控路径;不要把值作为命令参数、环境文件内容或聊天消息交给 Codex:
```bash
@@ -431,6 +433,7 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT##*:}/ready"
重建后必须补做:
- `smoke-external-deps.js`
- `smoke-trialrun-runtime.js`
- 若自动备份 units 已安装,核对其 Compose project、build、PGDATA、附件宿主挂载与当前唯一活动 server/db;不一致时用安装器完整刷新并复核三个 timer
- 公网页面视觉检查:至少覆盖 `/invoice-pool`、`/reimbursements`、`/mobile/capture` 的 390px 视口
发布/回滚 Checklist: