文件
hyb-oa/.trellis/tasks/08-23-isolated-backup-restore-drill/research/restore-drill-evidence.md
T

294 行
22 KiB
Markdown
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# 隔离备份恢复演练证据
## 证据边界
- 仅记录日期、角色、短构建标识、状态、finding 类别和清理结果。
- 不记录远端地址、SSH 身份、完整目录、凭据、账号、原始 manifest、业务字段或附件内容。
- 腾讯云是 OA 生产;ARM 仅是异机备份、外部健康探测和隔离恢复演练节点。
## Attempt 1:前置门禁失败关闭
日期:2026-08-23
| 检查项 | 结果 |
| --- | --- |
| 生产公网 `/ready`(演练前) | `PASS` |
| 计划基线构建短标识 | `7b86ffb` |
| 最新 ARM 正式日批次选择 | 已选择最新候选,未降级到旧批次 |
| manifest 来源构建门禁 | `FAIL`:`build_mismatch` |
| 失败阶段 | `select_latest_batch` |
| finding 类别 | `latest_batch_invalid` |
| 数据库/附件复制 | 未开始 |
| 一次性 PostgreSQL / 恢复写入 | 未创建、未执行 |
| runtime 清理 | `PASS` |
| 临时 root 入口清理 | `PASS` |
| 结论 | `NO_GO` |
该结果只证明最新候选未通过来源构建门禁,不能单独证明备份内容损坏。首次失败证据必须保留,禁止自动改选更旧批次或放宽门禁。
## 失败后只读复核
日期:2026-08-23
| 检查项 | 结果 |
| --- | --- |
| 生产公网 `/ready` | `PASS`,构建短标识 `7b86ffb3` |
| 生产自动备份 timer | `active` |
| 生产自动备份 unit 冻结构建 | `7b86ffb3` |
| ARM dead-man timer | `active` |
| ARM 公网健康 timer | `active` |
| ARM 一次性 root 入口残留 | `0` |
| ARM 本轮 runtime 残留 | `0` |
| ARM 普通用户暂存候选 | 窄名称初扫发现 `1`;随后按本轮 5 个文件的 SHA-256 精确匹配并全部删除 |
只读复核排除了“当前生产构建事实源过期”和“生产自动备份 unit 仍冻结旧构建”两种解释。ARM 上本轮 5 个普通用户暂存文件均经 SHA-256 与本地候选逐一匹配后精确删除,未执行宽泛目录删除。剩余调查重点是:最新 ARM 正式日批次是否来自当前生产自动备份链,以及 manifest 构建身份在生产生成、异机传输或 ARM 日归档选择中的哪一环发生漂移。
## Attempt 1 当时判定与重试门禁
- Attempt 1 仍为 `NO_GO`,当时 AC1、AC3、AC4、AC5 未满足;该次没有形成数据恢复 RTO/RPO 实测值。
- 不重试恢复,不触碰 ARM 正式归档,不修改腾讯云生产数据或配置。
- 下一步只允许以窄权限读取以下脱敏事实:最新 ARM manifest 的构建短标识、批次时间与最新生产成功状态是否指向同一 run id、manifest 摘要是否一致。
- 若确认只是 ARM 最新批次来源/归档选择异常,另行规划修复备份链;若确认归档身份正确且仅演练器口径有误,先更新任务设计并重新批准。
- 任何第二次恢复尝试前,必须更新规划、重新生成一次性入口并再次取得用户明确批准。
## 只读身份探针结果
日期:2026-08-23
| 检查项 | 结果 |
| --- | --- |
| 最新 ARM manifest 构建短标识 | `ec682565` |
| 当前生产/自动备份 unit 短标识 | `7b86ffb3` |
| 构建匹配 | `NO` |
| 最新批次年龄 | 约 18 小时,`within_24h=yes` |
| run id 与目录配对 | `PASS` |
| DB/附件 manifest 大小与 SHA-256 | `PASS` |
| source image digest | 存在 |
| manifest 摘要 | 已取得 12 位脱敏前缀,不保存完整摘要 |
| 探针临时 root 入口清理 | `PASS`,残留 `0` |
| 普通用户暂存清理 | 3 个候选均按 SHA-256 精确删除,目录残留 `0` |
| ARM 两个监控 timer | `active` |
`ec682565` 是仓库可识别的历史提交,也是当前生产提交的祖先。随后只读对时确认:当前生产自动备份合同约 1.3 小时前才刷新,而最新 ARM 批次约 18 小时前生成;下一次自然调度约 14 小时后执行。因此最新 ARM 批次是在当前发布之前由当时活动构建生成,不支持“错误来源批次”或“传输损坏”的判断。
当前 finding 应归类为“发布后尚未产生新构建恢复点”。首次恢复入口按当时已批准的精确构建门禁正确失败关闭;该 `NO_GO` 是 Attempt 1 的真实历史结论,不覆盖也不回写。后续设计复盘确认,严格相等不是原始备份可恢复性的必要条件,新的准入合同见 2026-08-24 规划修订。
当时用户决策:不手工触发生产自动备份,等待既有 timer 自然生成并异机归档下一批恢复点。该决策不是完成项,也不永久排除手工备份;若后续自然批次异常,可重新作为需明确批准的备选项。
## 2026-08-24 设计复盘与有效产出
- 自 2026-08-23 对时到当前日期,下一次自然调度窗口已经过去;当前唯一可立即执行的动作是按新设计重新只读探测 ARM 最新批次,而不是继续等待。
- Attempt 1 已经观测到:生产合同刷新后,到下一个自然备份抵达前,没有与当前构建严格相等的异机恢复点。该“同源恢复点可用滞后”是独立于数据 RPO/RTO 的有效恢复画像,即使数据库恢复未开始也必须保留。
- `ec682565` 与 `7b86ffb3` 的 migration 目录均为 65 个,目录差异为 0;这证明本次历史批次与当前 schema 没有 migration 增量,但不能证明“65”适合作为长期硬编码断言。
- 修订后的恢复点门禁为:manifest 来源提交是当前生产构建的已知祖先或相同,批次年龄不超过 24 小时,且 manifest/两份产物完整。严格相等只用于度量同源恢复点何时形成。
- migration 验证改为来源提交目录名称集合与恢复库成功集合精确一致;来源/当前集合不同则把“升级到当前 schema”单独标为未验证,不在本轮临时执行 migration。
## 2026-08-24 最新自然批次只读探针
本节仅追加,不改写 Attempt 1 及 2026-08-23 既有证据。
| 检查项 | 结果 |
| --- | --- |
| 演练开始时公网 `/ready` | `PASS` |
| 公网当前构建唯一解析 | `PASS`,短标识 `7b86ffb3` |
| 生产自动备份 unit 与公网构建 | `MATCH`,timer `active` |
| ARM 最新批次构建短标识 | `7b86ffb3` |
| 来源与当前构建关系 | `SAME`;因此同时满足“祖先或相同” |
| 批次年龄 | `68491` 秒,约 19 小时 2 分,`within_24h=yes` |
| run id 与目录配对 | `PASS` |
| DB/附件 manifest 大小与 SHA-256 | `PASS` |
| source image digest | 存在 |
| manifest 摘要 | 已取得 12 位脱敏前缀,不保存完整摘要 |
| 来源/当前 migration 目录 | 均为 65 个,差异文件 `0` |
| 探针临时 root 入口 | 自清理 `PASS`,复核残留 `0` |
| 普通用户暂存 | 3 个候选按 SHA-256 精确删除,目录残留 `0` |
| ARM 两个监控 timer | `active` |
同源恢复点画像:生产备份合同刷新距探针约 `94465` 秒;最新同源 manifest 距探针 `68491` 秒,因此该同源批次的 manifest 创建时间约在合同刷新后 `25974` 秒(约 7 小时 13 分)。当前证据没有直接采集 ARM 原子归档完成时刻,不能把该差值表述为精确“到达 ARM”耗时;只能证明同源批次创建滞后约 7 小时 13 分,并且在本次探针时已经可用。
执行方结论:最新自然批次满足修订后的来源关系、新鲜度、run id 配对和产物完整性门禁,可进入恢复重试审批。该结论不是恢复授权;本次只读探针没有复制或解包备份、创建 Docker 对象、恢复数据库/附件或修改生产与 ARM 正式归档。
## 2026-08-24 同源滞后锚点更正
本节追加说明前节 `25974` 秒的锚点,不改写既有记录。
- 起点实际是生产 `automatic-backup` service 构建 drop-in 的最大文件 mtime;终点是最新 manifest `createdAt`。
- 该差值可称为内部诊断指标“合同 drop-in 文件刷新到同源 manifest 创建”,不能称为应用发布完成到恢复点到达 ARM 的精确耗时。
- 当前证据没有采集 ARM 原子归档完成时刻,因此正式 `same_build_recovery_point_lag` 只能保留为“存在发布后空窗”的定性 finding,`7h13m` 不对外引用为精确恢复画像。
- 2026-08-23 “下一次自然调度约 14 小时后”的估算存在时区处理错误:探针把 systemd 输出的本地时区时间直接附为 UTC,导致约多算 8 小时。修正后自然调度约在当时探针后 6 小时,与 2026-08-24 最新 manifest 创建时间一致;没有证据表明中间发生非计划手工备份。
## Attempt 2:临时副本目录身份误用
日期:2026-08-24
本节仅追加,不改写 Attempt 1、只读探针或锚点更正记录。
| 检查项 | 结果 |
| --- | --- |
| 最新正式批次前置门禁 | `PASS` |
| 正式归档读取/修改 | 只读使用、零修改 |
| 临时副本创建 | 已复制到本轮私有临时区 |
| 失败阶段 | `create_isolated_runtime` |
| finding 类别 | `copied_batch_invalid` / `batch_identity` |
| PostgreSQL 容器 | 未创建 |
| DB/附件恢复 | 未开始 |
| migration/关键表/附件关联验证 | 未执行 |
| runtime 清理 | `PASS`,复核残留 `0` |
| 临时 root 入口清理 | `PASS`,复核残留 `0` |
| 普通用户暂存清理 | 6 个候选按 SHA-256 精确删除,目录残留 `0` |
| ARM 两个监控 timer | `active` |
| 结论 | `NO_GO` |
根因:runner 的 `validate_batch` 同时承担正式源目录身份和批次内容校验。正式源目录 basename 必须与 manifest `runId` 配对是正确安全门禁;复制后的私有临时目录固定名为 `batch`,内容仍保持同一 manifest/run id,却被同一个 basename 断言错误拒绝。该 finding 不表示备份损坏,也没有进入数据库或附件恢复。
修订边界:正式源目录继续执行 basename/run id 强校验;临时副本只跳过目录 basename 断言,manifest、run id 格式、文件集、构建、时间、大小、SHA-256 和与源元数据相等比较全部保留。任何重试前必须补“正式源通过、同内容改名副本通过、任一内容篡改失败”的合成回归并再次取得明确批准。
## 2026-08-25 Attempt 2 finding 分类补充
本节只追加分类,不改写 Attempt 2 原始结果。
- Attempt 2 finding 类别明确为 `runner_defect`,具体子类为“源目录身份断言误用于临时副本”。
- Attempt 1 finding 是“发布后尚未形成同源恢复点”;Attempt 2 finding 是演练器缺陷。两者均未证明备份不可恢复,不能合并表述为“备份连续失败两次”。
- Attempt 2 的正式批次前置门禁 `PASS`、临时复制完成、恢复前失败关闭、正式归档零修改和所有临时对象零残留,证明安全门禁在错误发生后按设计收口。
- 临时副本修订不是简单放宽:副本 manifest `runId` 必须重绑到源侧已验证的 run id,并继续通过 manifest、文件集、构建、时间、字节数、SHA-256 及源/副本元数据相等校验。
- 新增强制负例:源目录 basename 与 manifest run id 不配对必须 `NO_GO`;副本 manifest run id 即使内部自洽,但与源侧已验证 run id 不同也必须 `NO_GO`。
## 2026-08-25 runner 修复验证与最新批次探针
本节仅追加,不改写 Attempt 1、Attempt 2 或既有探针记录。
### Runner 修复验证
| 回归 | 结果 |
| --- | --- |
| 正式源 basename 与 manifest run id 配对 | `PASS` |
| 同内容改名副本重绑源侧 run id | `PASS` |
| 副本内容篡改 | 预期失败,`PASS` |
| 源 basename 与 manifest run id 错配 | 预期失败,`PASS` |
| 副本 manifest run id 内部自洽但不同于源 run id | 预期失败,`PASS` |
| 动态 migration 集合正/反例 | `PASS` |
| 既有隔离备份/恢复合同回归 | `PASS` |
实现结果:正式源目录继续校验 basename/run id;临时副本 manifest run id 必须等于源侧已验证 run id,并继续验证 manifest、文件集、构建、时间、大小、SHA-256 及源/副本脱敏元数据完全一致。
### 08-25 最新自然批次
| 检查项 | 结果 |
| --- | --- |
| 演练开始时公网 `/ready` | `PASS` |
| 公网构建与自动备份 unit | `MATCH`,短标识 `7b86ffb3`,timer `active` |
| ARM 最新批次构建短标识 | `7b86ffb3` |
| 来源与当前构建关系 | `SAME` |
| 批次年龄 | `22249` 秒,约 6 小时 11 分,`within_24h=yes` |
| run id 与目录配对 | `PASS` |
| DB/附件 manifest 大小与 SHA-256 | `PASS` |
| source image digest | 存在 |
| manifest 摘要 | 已取得 12 位脱敏前缀,不保存完整摘要 |
| 来源/当前 migration 目录 | 均为 65 个,差异文件 `0` |
| 探针 root 入口及普通用户暂存 | 残留均为 `0` |
| ARM 两个监控 timer | `active` |
该批次不是 Attempt 2 的旧候选,新鲜度和全部身份/完整性门禁已重新计算。当前满足 Attempt 3 恢复审批前提,但本次批准不包含实际恢复,未复制、解包或恢复该批次。
## Attempt 3:数据库恢复阶段失败关闭
日期:2026-08-25
本节仅追加,不改写 Attempt 1、Attempt 2 或既有探针记录。
| 检查项 | 结果 |
| --- | --- |
| 候选文件 SHA-256、Bash 与 sudoers 语法 | `PASS` |
| 最新正式批次前置门禁 | `PASS`;使用 08-25 新候选 |
| 正式归档读取/修改 | 只读取用;runner 没有归档写入路径 |
| 临时副本创建及源侧身份重绑 | `PASS`;否则不会进入数据库恢复阶段 |
| 失败阶段 | `restore_database` |
| runner 聚合类别 | `database_restore_failed` |
| migration/12 类表/附件恢复与关联验证 | 未执行 |
| runtime 清理 | `PASS`,复核目录残留 `0` |
| 一次性 PostgreSQL 容器清理 | `PASS`,复核残留 `0` |
| 临时 root 入口与库清理 | `PASS`,复核残留 `0` |
| ARM 两个监控 timer | `active` |
| 腾讯云生产公网 `/ready`(演练后) | `PASS`,构建短标识仍为 `7b86ffb3` |
| 结论 | `NO_GO` |
Attempt 3 在数据库恢复命令返回非零后正确停止,未进入 migration、关键表、附件恢复或附件关联检查。当前输出不能区分 dump 列表校验失败、恢复命令失败、工具/参数合同不匹配或其他 runner 调用问题,因此该次 `NO_GO` 不能被改写为“备份不可恢复”,也不能据此给出数据恢复 RTO 或完整恢复结论。
本轮还暴露了独立的演练器可观测性缺陷:`restore-db.sh` 的详细输出仅写入权限 `0600` 的本轮私有日志,而失败清理随后删除该日志,最终只保留 `database_restore_failed` 聚合类别。安全清理是正确的,但失败关闭前必须先生成不含路径、凭据、业务内容或原始 SQL 的阶段化脱敏诊断;在该合同补齐并通过回归、重新审批前,不执行 Attempt 4,也不改选旧批次或放宽门禁。
措辞对齐说明:既有 Attempt 2 段落中的“临时副本只跳过目录 basename 断言”仅描述不把自建目录名当归档证据,不构成放宽。生效合同始终是副本 manifest run id 必须等于源侧已验证 run id,并且副本全部脱敏元数据与源侧首次校验结果完全一致。
## 2026-08-25 Attempt 3 后本地诊断修复
本节只追加本地 runner 与合成回归结果;未连接 ARM、未探测新批次、未执行 Attempt 4,也未修改腾讯云生产或 ARM 正式归档。
- runner 将数据库恢复拆为 `dump_list`、`target_confirmation`、`restore_apply` 三个阶段,只输出 `PASS/FAIL/NOT_RUN`、执行路径枚举、数字退出码、数字或 `UNKNOWN` 错误计数和稳定错误类别。
- allowlist 诊断在 trap/finally 等价清理之前输出;原始 stderr 仍只存在于权限 `0600` 的本轮私有日志并随 runtime 删除。注入退出码 `23` 的回归证明:阶段诊断先输出,runtime/access 清理随后均为 `PASS`,调用方在清理后仍能读取数字退出码。
- PostgreSQL 16 custom dump 直接驱动仓库 `scripts/restore-db.sh` 的 Docker stdin 路径成功;host-file 路径通过 PostgreSQL 16 工具执行同一真实脚本成功。没有在测试中重写等价恢复逻辑。
- dump-list 失败、目标确认失败和 list/target 通过后 apply 失败三条负例均通过;后续阶段保持 `NOT_RUN`,apply 非零不会因对象错误计数较小而降级为成功。
- 捕获诊断不含 fixture 凭据、连接串、SQL、临时路径或自由文本工具错误;既有合成成对备份/恢复回归通过,自动异机备份合同 `74` 项通过。
截至 Attempt 3,最新备份是否真的可恢复这个核心问题仍然没有答案。目前验证到的是演练器、恢复点身份门禁和每次失败后的安全收口,不是最新备份的数据可恢复性。Attempt 1 是发布后恢复点缺口;Attempt 2 是临时副本身份规则误用;Attempt 3 目前只能确定演练器可观测性不足,底层数据库恢复失败原因仍未知。三次均为 `NO_GO`,但不得把次数解释为“最新备份连续三次不可恢复”。
## 2026-08-25 Attempt 4 前最新批次只读探针
本节仅追加探针与本地 Git 静态核对结果;未复制、解包或恢复批次,未安装恢复 runner,未执行 Attempt 4。
| 检查项 | 结果 |
| --- | --- |
| 演练前公网 `/ready` | `PASS`,构建短标识 `7b86ffb3c9ef` |
| ARM 最新批次探针 | `PASS` |
| 最新批次构建短标识 | `7b86ffb3` |
| 来源提交唯一解析 | `PASS` |
| 来源与当前生产构建关系 | `SAME` |
| 批次年龄 | `26652` 秒,约 7 小时 24 分,`within_24h=yes` |
| run id 与目录配对 | `PASS` |
| DB/附件大小与 SHA-256 | `PASS` |
| source image digest | 存在 |
| manifest 脱敏摘要前缀 | `ee1f97a5b42d` |
| 来源/当前 migration 集合 | `65/65`,差异条目 `0` |
| 仓库 HEAD 相对生产构建的应用代码漂移 | 文件数 `0` |
| 探针 root 入口清理 | `PASS`,残留 `0` |
| 普通用户探针暂存 | 残留 `0` |
| ARM 两个监控 timer | `active` |
该批次满足 Attempt 4 的构建关系、新鲜度、run id 配对、产物完整性和 schema 集合前置门禁;该结论仍不是恢复授权,也不回答最新备份是否可恢复。
恢复 runner 的持久化限制不予接受:截至本节时,修复后的 runner 与八类本地回归仍位于不提交的个人 workspace,仓库尚无可由其他执行者独立复跑的 runner 合同门禁。Attempt 4 实际恢复前必须先把 runner 的稳定来源和对应合同回归纳入 `scripts/qa/`,由合同脚本机械验证候选 SHA-256、三阶段诊断、源/副本身份、真实 `restore-db.sh` 双路径和失败清理;未完成前不安装 Attempt 4 恢复入口。
## 2026-08-25 runner 持久化合同结果
本节仅追加本地跟踪文件与合同回归结果;未连接 ARM、未渲染现场候选、未执行 Attempt 4。
新增跟踪文件:
- `scripts/qa/offhost-shadow-restore-runner.template.sh`
- `scripts/qa/validate-offhost-shadow-restore-attachments.py`
- `scripts/qa/run-offhost-shadow-restore-runner-contract.sh`
| 合同类别 | 结果 |
| --- | --- |
| 私有参数渲染、确定性候选 SHA-256、shell 注入拒绝 | `PASS` |
| 正式源身份与改名私有副本源侧重绑正例 | `PASS` |
| 源 basename/run id 错配、副本篡改、副本 run id 与源不同负例 | `PASS` |
| dynamic migration 集合指纹正/反例,无硬编码数量 | `PASS` |
| 附件本地路径/大小/SHA-256 正例,路径逃逸与 S3 负例 | `PASS` |
| 三阶段诊断、执行路径、两个退出码、后续 `NOT_RUN` | `PASS` |
| 真实 `scripts/restore-db.sh` Docker stdin 与 PostgreSQL 16 host-file 路径 | `PASS/PASS` |
| apply 非零失败关闭、诊断先于清理、清理后可读、无敏感自由文本 | `PASS` |
持久化模板不含远端地址、个人用户目录、远端账号、现场路径、实际容器/数据库名、生产构建值或 migration 指纹;这些值只能来自私有 JSON,并在候选写出前通过精确字段集、类型、字符集、绝对路径、shadow 命名和摘要格式校验。`check-sensitive-content.mjs` 对三个新增文件定向扫描结果为 `PASS`,扫描文件 `3`、finding `0`;本地测试容器残留 `0`。
Attempt 4 的 runner 产生过程已从个人 workspace 人工产物改为“跟踪模板 + 跟踪合同 + 私有参数确定性渲染 + 候选 SHA-256”。合同未通过不得生成或上传候选。该结果只补齐演练器可重复性,仍不构成最新备份可恢复性结论或 Attempt 4 恢复授权。
## 2026-08-25 私有渲染与阶段日志门禁补强
本节仅追加本地门禁修订与合成回归结果;未连接 ARM、未创建现场私有配置、未渲染或上传 Attempt 4 候选,也未执行实际恢复。
- `.gitignore` 已增加专用 `/.restore-drill-private/` 目录;合同分别注入仓库内未忽略的私有 JSON 和未忽略的候选输出路径,两者均在候选创建前按预期失败。ignored 目录内的合成输入/输出正例通过,测试文件在退出时清理。
- 私有原始日志仍为 `0600` 且只在初始化时截断一次;PostgreSQL 容器启动、dump list、目标确认、restore apply 和附件恢复使用固定阶段标记追加。合同静态拒绝后续截断,最终 runtime 清理与只输出 allowlist 诊断的边界不变。
- PostgreSQL 16 fixture 增加连续成功查询的稳定就绪门禁,避免镜像首次初始化临时服务关闭窗口造成合成回归假失败;真实 `scripts/restore-db.sh` 字节未修改,Docker stdin 和 host-file 两条路径仍直接执行仓库脚本并通过。
- 本轮新增门禁回归全部 `PASS`;完成完整质量门禁并形成可追溯 Git 提交前,仍不得准备或上传 Attempt 4 候选。
完整本地门禁随后通过:runner 合同全部 `PASS`,既有隔离备份/恢复合成演练 `PASS`,自动异机备份合同 `74/74 PASS`;task validate、Trellis governance、process baseline、默认敏感扫描和三个新增文件定向敏感扫描均 `PASS`。文档审计错误 `0`,仅保留既有未登记文件 warning;`shellcheck` 在本机不可用,已以 Bash 语法、真实双路径集成回归和仓库合同覆盖。测试容器、本地私有渲染目录及候选残留均为 `0`。