文件
wangsli 46a70aa552 merge: 合入候选分支(09-17借款/ERP退役、09-18编号、09-19预算并发、完整10-05错误码迁移,均经确认)
# Conflicts:
#	.agents/skills/trellis-before-dev/SKILL.md
#	.agents/skills/trellis-brainstorm/SKILL.md
#	.agents/skills/trellis-continue/SKILL.md
#	.agents/skills/trellis-start/SKILL.md
#	.trellis/tasks/08-23-isolated-backup-restore-drill/design.md
#	.trellis/tasks/08-23-isolated-backup-restore-drill/prd.md
#	.trellis/tasks/09-05-error-explainability-payment-readiness/prd.md
#	.trellis/tasks/09-05-error-explainability-payment-readiness/task.json
#	.trellis/tasks/09-09-existing-deploy-seed-separation/implement.md
#	.trellis/tasks/archive/2026-09/09-05-trellis-upgrade-rule-cleanup/task.json
#	.trellis/workflow.md
#	scripts/qa/error-reason-legacy-baseline.json
#	server/src/routes/apOperations.js
#	server/src/routes/portal.js
#	server/src/routes/todos.js
#	server/tests/unit/fixtures/error-reason-messages.json
#	server/tests/unit/routes/apOperations.permissions.test.js
2026-09-26 07:54:10 +08:00

46 KiB

隔离备份恢复演练设计

2026-09-19 关联消费者边界(文档澄清,无执行)

K0 的7类输出仅验证加解密合同,不验证脱敏显示,也不等于所有 crypto 消费者的异常/null 分支均安全。Claude 的静态 PASS 按此范围接收;原合成运行和冻结包保持。

通知迁移脚本 server/src/scripts/migrate-notification-channel-secrets.js 顶层加载 dotenv/config,创建 Prisma 并连接库;--encrypt/--decrypt 都会逐条 update,后者还会将解密值写回旧明文字段。因此仅可只读源码盘点,禁止在 K0 或默认只读 K2 中 import/require/运行,也不能把其执行当作密钥验证。K1只证明保管可用性,不涉及该工具。

未来 K2 的字段绑定若包含通知渠道,需按来源 schema 明确 config_json 中的适用加密字段和只读样本上限,验证出口仍仅类别/数量/通过-失败-不适用;旧明文、空字段或不适用字段不能冒充解密通过。不修改通知渠道记录、不启动发送服务;真实迁移/轮换另立经审阅和批准的操作方案,不与只读恢复验收捆绑。

2026-09-17 K0/K1/K2:原密钥恢复最小设计(K0已批准,K1/K2另批)

源码依据:server/src/config.js:143字段密钥缺省空;server/src/services/crypto/index.js:8解析base64后要求32字节,原格式为AES-256-GCM的v1:载荷。此格式不含key id,不能由数据库推导原密钥;32字节只证明长度,不证明生成方式或随机性。沿用deploy/trialrun/env-recovery-classification.json的preserve_original及现有runbook,不改分类、生产密钥或产品加密协议。

K0:只加载生产加密函数与本地模板/分类,不加载真实.env。用临时合成密钥/密文覆盖正确、缺失、错误、篡改、空字段和不可解析输入;验证脚本捕获异常后只报固定类别,不调用可能带原始异常的日志分支。保管合同只记录双角色可用状态;本地模拟保管成功不能写成K1通过。不改已冻R3/app-check的“非空密钥拒绝”,新增验证只在新独立补件中实现并复核,禁止覆盖旧包使旧批准扩大。

本地落点:scripts/qa/check-sensitive-recovery-contract.cjs与server/tests/unit/services/sensitiveRecoveryContract.test.js。入口仅在内存生成合成材料,拒绝命令行参数,不读取环境密钥/配置/备份;直接复用生产resolveFieldEncryptionKey、encryptStringOrThrow与decryptStringOrThrow。生产解密返回null不等于成功:空字段是NOT_TESTED,畸形非空值是STOP;错误密钥或认证篡改异常只转固定DECRYPTION_FAILED,不使用带原始Error日志的safe分支。PASS要求合成明文精确匹配,报告保留真实来源UNKNOWN、custody/realDataDecryption为NOT_TESTED。此补件不是K2真实输入接口;不增加产品加密协议、严格编码或业务字段格式的覆盖声明。临时Buffer清零不代表JavaScript字符串或进程内存已获安全擦除保证,真实秘密载体仍须另审。

K1:实际保管渠道优先复用既有仓库外共享保管或加密封存,由管理员与公司责任人取用;若无渠道,只提交最小建立方案,不能先创建或导出Secret。原值如需在受控环境内比对,只输出match/mismatch/unavailable,不输出独立密钥摘要、密文示例、账户或保管路径。未批准前只整理空白检查清单,不搜索秘密值或历史命令。记录保管能力,不要求用户证明未知生成历史。

K2准入:K0通过、K1具备可用原密钥、安全注入方式经过独立只读审查,并得到具体一次操作批准后才执行。重新核最新合格源/镜像/平台/时效/容量;上次影子库已删除,不为省事复用已消费入口。若需要重建影子库,明确计入新批准范围,不称为只补一个无需恢复的按钮。

拟定隔离边界:不发布宿主端口,非root/只读根/无外网、禁后台任务和持久日志;不把Secret放进命令参数、Docker环境元数据、源码、输出或普通备份。经批准由管理员通过短时只读秘密挂载或等价受保护内存输入提供原密钥,最终选择及跨主机传输方式必须在K2绑定审查中明确,不能将这两种机制当作都已获批。关闭xtrace/core dump,失败也精确清理本轮秘密载体和影子资源,不删除原保管副本。

从恢复库只读选取批准字段类别/数量的密文;验证认证解密及既有格式约束,只输出类别级数量、通过/失败/不适用,不回显明文、尾号或原始异常。只读DB身份、禁止通知/OCR/外联与停止条件沿原合同;未纳入字段类别保持NOT_TESTED。历史缺件、真实密码登录及完整RTO边界与解密结果分开,不因K2成功把全部灾备改为PASS。

回退不是换密钥:本计划不改生产配置/密文,失败处置为停止、销毁本轮受控临时载体并保留脱敏失败原因;旧备份、旧钥和旧制品不动。若必须修改产品注入接口或密文协议才能安全执行,则暂停K2并另审该变更,不在演练现场临时补代码。

2026-09-17 K1操作提案与K2绑定清单(准备,未执行)

K1先由系统管理员和公司责任人在既有受控保管界面分别核验该保管项是否可取用;代理不接触密钥值,不打开真实配置、不搜索shell历史、不建立新传输通道。进入前列明两类操作者、既有渠道类别、核验时间窗口、仅可用性检查范围和暴露风险;取得该具体范围批准后才实际访问。每个角色只回传AVAILABLE/UNAVAILABLE及时间,双角色同一原值的匹配仍为NOT_TESTED,K1可用不等于备份可解密。若仅有一人/原主机保管或无法取得,则停止并提出仓库外共享保管或加密封存的另审建立方案,不能自行复制或换钥。

K2先填下列绑定项;目前均未取得新现场事实,不能将旧R3参数或过期源填入当作现成方案。

绑定项 必需事实与进入条件 当前状态
备份与制品 最新合格成对源、manifest/来源版本/实际目录、同源镜像及平台、新鲜度与容量 NOT_BOUND
适用字段 按来源schema列出实际存在的加密字段类别及只读样本上限;银行/身份/通知/核对材料分别说明,零适用样本为NOT_TESTED NOT_BOUND
读取身份 专属只读DB权限;不启动任务、OCR、通知或业务写入,无外网、无宿主端口 NOT_BOUND
秘密输入 管理员提供短时只读挂载或受保护内存输入,选择一种并审查传输;禁argv、Docker环境元数据、持久日志、core dump NOT_BOUND
验证出口 仅类别/数量/PASS-FAIL-NOT_TESTED;错钥/损坏停止且无原异常/密文/明文/密钥摘要 NOT_BOUND
资源与清理 一次性资源精确身份、异常清理、秘密载体销毁与原保管副本保留;清理失败保留未完成状态 NOT_BOUND
审阅与授权 候选摘要绑定独立只读意见、具体一次新恢复范围与批准;重建影子库计入范围 PENDING

本地源码显示存在银行账户、身份、通知与付款核对相关加密用途;具体源schema和实际非空字段尚未核实,不把当前HEAD字段集合当作未来备份覆盖证明。完整附件、历史缺件、原密码登录与完整RTO仍分别验收。

2026-09-17 b1新源匹配ARM64镜像准备(已获构建批准)

用户已明确批准本节固定b1源码的ARM64镜像构建。批准仅涵盖构建及输入/产物元数据验证,不启动应用、不部署、不恢复、不读生产密钥、不删除旧制品;不再重复索取同范围批准。08:45 CST构建及元数据回读已完成,详情见implement最新回执。

最新已核ARM自然源manifest d9a23d59…、来源提交b1d25d3c9939c0020e7b4f75868cd97c35c16ba8/树a9f1c8abd16b260d23ee38508b3ff3c51710371e;生产原image9a9d2dfd…为amd64,ARM没有对应可证明制品,不能以旧b7的arm64镜像替代。两源码的实际部署Dockerfile、依赖锁、BIFF8 requirements与71项迁移集合/校验和相同,应用源码仍不同。

本轮批准一次b1原生ARM64镜像构建:仅导出该固定提交的client/server/scripts/部署Dockerfile/.dockerignore,不含工作树差异、.env、密钥、真实备份或业务资料;沿前次已验方式,将唯一FROM固定到已在ARM的node@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32,其余Dockerfile不改。执行前再核base身份/平台和可用资源,不擅自拉另一base或启用仿真;构建所需依赖网络与后续恢复禁外联网络分开。

只创建本轮专属b1镜像/构建缓存及必要临时源码上下文,固定BUILD_SHA/VITE_BUILD_SHA与OCI revision;登记源码逐件/锁文件/构建上下文/镜像摘要,构建上限30分钟,失败保留脱敏回执、不自动重试。保留旧b7镜像/正式归档/共享缓存,不prune。产物只代表同源码跨平台重建,不是原amd64字节复原;不启动OA服务、不恢复数据库、不部署、不读生产字段密钥或修改业务状态。

成功后按新制品身份完成源/目录/应用配置重绑及补充验证;R3独立内容复核和执行时fresh preflight仍不可跳过。本节固定新版本构建已经另获批准;不把构建批准延伸为任意版本构建、应用启动或立即恢复,原一次受限恢复仍受复核/绑定/现场门禁约束。

2026-09-17 已批准:已确认历史缺件单列验收

当前R2实际恢复在附件引用检查停止,数据库/清理/后验PASS。现已找到09-11原处置证据和当前只读回读:精确一条manual归档发票,无七类业务关联;旧决定仅“文档标记并保留”。09-17用户已明确批准以下最小验收增量;不把当前生产扫描冒充冻结副本的最终归因,独立复核仍在执行前。

  1. R2包与本次NO_GO回执永久保留。新候选需重新冻结并做增量复核,不直接修改已审包或复用已消费入口;源/镜像/生产版本及24小时门禁仍执行时核验,源变化或过期停止重新绑定。
  2. 历史例外只绑定recordIdentity 363834359af7b655648a2562b80559fa132093ffefaabcddb8cc827a7505a3aa和pathIdentity b22a116257e8b2e3922ee32f366935260c16bebc7fd93dcd672febb7d1fdbc5f,以及原处置证据SHA256;仅承认原件不存在,不接受替换成不同大小/哈希的文件、外部存储、非法路径或重复目标。
  3. 在本轮恢复副本重新验证恰好一条目标、manual归档与归档时间、报销/明细/差旅关联为空,以及其他四类附件表均未引用同路径;任何条件不成立仍NO_GO。其他所有引用继续安全路径/存在/适用大小/SHA检查;不按archived泛化过滤、不剔除检查行、不修补数据库或伪造文件。
  4. 输出总引用数、原始不一致数、明确已接受缺件数与未知不一致数,并保留原校验结果;只有已接受缺件恰好为这一条且未知不一致为0才允许继续既有影子应用只读验收。已有哈希的其余文件必须实际核完,不以当前生产61条元数据正常替代。
  5. 附件结果记为“存在一条已接受历史缺件”,不得写完整性PASS;即使DB/其余附件/app均成功,整体只给包含历史缺件与解密未验的PARTIAL。不新增生产写入、真实密码登录、云调用或敏感解密范围;无hash字段及请假附件覆盖限制保留。
  6. 本地定向验证覆盖精确例外通过、身份/归档/关联漂移拒绝、新缺件/大小/摘要不一致拒绝、未知路径或多个目标拒绝、保留原始计数及失败停止、清理/后验;独立增量复核后才生成新的单次管理员入口。此提案批准可合并本地实施与上述一次受限恢复,不为相同范围反复索取批准;实际sudo仍需管理员终端交互。

2026-09-16 单次隔离恢复审批清单(待批准,非执行回执)

本节将P2收敛为有限范围的一次演练,不包含生产回滚/部署。来源为已核ARM自然批次manifest 0913782f84bf7253a8ea1a24e727f8c20a6a637c58737b5df767331809dd2f4e,build b7d2285d、树0ef8f7d5597b1fc425888596241d041585aaccab;应用固定ARM64 image 1d4d70f843976e327106d53d13d45118e22372ac6e71cbe7db96953315b4fed3,PostgreSQL固定已核PG_MAJOR=16的ARM64 image c05eced0bdb41ea9b95a656472a6aa4d50cad0d8a2e33d14eb1c53fd6204f2ae。两者启动前再核身份,不用tag代替digest。源创建于09-15T18:30:05Z,执行时超过24小时则停止并重新绑定最新源/镜像,不自动回退或套用旧批准。

一次批准的允许动作

  1. 由具备ARM归档读取权限的执行方式复核固定根身份、三文件集合/摘要、最新性、生产ready及ARM监控;读取归档头计算解压体积,不向开发机传输真实备份。允许本轮私有副本、影子DB/storage、两个隔离运行容器及必要的一次性验证工具;禁止访问其他业务目录或挂载Docker socket到容器。
  2. 创建带唯一runId标签的内部网络和专属空卷,均不发布宿主端口、不加现有网络。DB恢复使用原restore-db.sh的shadow/数据库精确名称/容器ID门禁;附件使用原restore-storage.sh和引用校验器,恢复失败或引用不一致即停,不排除“测试件”。来源迁移名称/校验和从固定提交生成,核固定12类表及附件聚合,不输出业务字段。
  3. 恢复完成后,仅在影子库创建临时只读LOGIN角色:非superuser/createdb/createrole/replication,授CONNECT、必要schema USAGE和SELECT,不授序列使用或DML;影子库撤销PUBLIC的CREATE/TEMP权限并设置角色默认只读事务。此处权限DDL属于明确允许的影子环境准备,不声称“恢复后全部零DDL”;不改任何员工、角色业务表、付款状态或原备份。
  4. 应用环境从空白allowlist组装,使用本轮随机且互不相同的JWT_SECRET、AUTH_CODE_PEPPER和影子DB口令;不读取生产.env/密钥。NODE_ENV=production、TRUST_PROXY_HOPS=1、JOBS_ENABLED=false、OCR_PROVIDER=off、STORAGE_DRIVER=local;EMAIL/WECOM/ERP_SYNC/TRAVEL_SYNC/IM_NOTIFIER/OIDC/OTEL_TRACES/AI_ASSIST等启用项均关闭,未列出的外部Secret/URL不传入。FIELD_ENCRYPTION_KEY保持缺省空值,不用随机密钥伪装可解历史数据。internal网络为主要禁外联防线,不改宿主防火墙/DNS。
  5. 原镜像默认入口启动应用;根文件系统只读、附件卷只读并预建storage目录,专属/tmp可写;不运行migrate/seed/worker。应用使用非root、cap-drop=ALL、no-new-privileges,DB按官方入口所需权限独立配置。日志不持久化真实请求/响应,docker log-driver=none,探针只输出状态/计数;若需诊断,仅脱敏错误类别,不回传原件、账户或完整payload。
  6. 在隔离容器内访问/ready和实际GET /api/reimbursements?scope=own&page=1&pageSize=1:用影子JWT签名秘密生成不超过120秒的测试token,subject/tokenVersion取恢复库中确有报销及expense.view_own资格的active身份,不改其密码/角色,不授管理员通配。只读比较响应数量/选中记录与同身份DB期望,不打印记录内容;匿名请求应401。此项明确是“影子签发凭证+真实认证中间件/权限+恢复库读取”,不是员工真实密码登录、SSO、浏览器验收或生产JWT链路。没有合格身份则APP_READ_BLOCKED,不造账号或扩权。
  7. 完成或失败均删除本轮精确ID/标签匹配的运行容器、临时凭证、私有副本/卷和网络,保留正式归档、构建镜像及共享缓存;不prune、不改sudoers、不长期安装runner。复核源摘要/元数据、生产ready和ARMtimer,清理失败记录cleanupPending,不出整体PASS。清理为逻辑移除,不宣称物理安全擦除。

资源和停止门禁

目标新测可用内存10.24GiB、磁盘87.91GiB、2 CPU,仅为规划参考。启动前要求MemAvailable至少6GiB、磁盘至少20GiB。采用专属容量受限tmpfs卷:DB数据2GiB、附件512MiB、归档副本128MiB;先核tar普通文件展开总量不超过附件限额,DB超容直接失败不自动扩容。DB容器memory=2560MiB、memory-swap同值、cpu=1、pids=256;应用memory=1024MiB、memory-swap同值、cpu=1、pids=256;临时工具顺序运行,memory=512MiB、cpu=1、pids=128。不能实现容量上限则前置停止,不把容量监测误写成硬限制。实际宿主swap及tmpfs支持须预检,既有宿主配置不改。

总时限30分钟(超时停止本轮任务并清理),DB恢复最多10分钟、应用就绪最多120秒、单个只读SQL最多20秒/锁等待5秒。资源达到上限、OOM、镜像/源身份变化、归档不安全、出现外部存储引用/缺件或任何未计划写入时停止;不为了通过而扩大范围。RTO分别记录复制/DB/附件/应用/清理耗时,RPO取恢复开始减备份createdAt,不以镜像构建耗时冒充恢复耗时。

结果边界与批准前本地验证

输出DB_RESTORE、STORAGE_REFERENCES、APP_HEALTH、APP_AUTHENTICATED_READ、SENSITIVE_DECRYPTION、cleanup分别状态。因本清单明确不使用生产字段密钥,SENSITIVE_DECRYPTION固定NOT_TESTED,整项完整应用灾备验收保留PARTIAL,不把其余阶段PASS合成为全量恢复通过;后续敏感字段恢复要独立设计受控密钥使用,不再次向用户索要密钥值。

原版本源码静态核实:认证只读取employee/roles并检查token_version,报销列表及委托读取为SELECT链;index→buildApp入口有storage mkdir、startJobs,JOBS_ENABLED=false才提前退出,tracing关闭才不启动SDK。已用原config源码和全新合成秘密执行纯配置验证,9项后台/外部开关关闭、OCR off、字段密钥空值PASS;此验证未初始化应用或数据库。实际网络隔离、资源/DB权限、匿名拒绝、带凭证读取与异常清理仍须先做本地合成合同验证,再冻结执行器摘要供已批准范围执行,不能将当前文档称为已可直接运行的恢复脚本。

2026-09-16 设计接续与前置决策

本节优先于旧单纯DB/storage阶段“不启动OA server”的约束。方案仍以ARM隔离为首选,不迁往个人开发机或生产宿主;应用容器是单次恢复申请中的第三阶段,不是当前已获授权的操作。

  1. P1先核源build/image/platform和ARM平台。镜像与源提交不匹配、缺少匹配平台镜像或ARM不能执行时停止应用阶段;不自动启用跨架构仿真。需要同提交匹配平台构建或其他隔离目标时另列来源/依赖/构建证据及批准范围。
  2. 用原backup-all/restore-db/restore-storage工具及现有校验器,不新建通用runner。固定私有临时副本、专属空卷/网络/容器名,无宿主端口、无生产数据挂载、不接现有业务网络;应用仅连本轮影子库与只读附件副本。采用专属internal网络及已核应用配置同时阻止OCR/邮件/企微/ERP/支付/队列/cron等出站或后台写入,不改宿主共享防火墙、DNS或生产服务。
  3. 隔离库恢复阶段可写,完成后应用验证使用最小权限只读连接;禁migrate/seed/任务启动。逐一确认启动入口没有隐式写入前再启动。需要身份/解密的读取检查先设计受控方式、纳入审批;缺条件时只报已验证阶段,不伪造APP_PASS。
  4. 资源预算基线为已有“批次总字节数5倍+2GiB”,再计镜像归档/解包空间、实际storage解压体积及DB膨胀估计;取保守较大值,不能把压缩包5倍当容量保证。申请时给出CPU/内存/磁盘/超时硬上限、停止水位,精确数值由目标只读测量后冻结,不套用旧89GiB。达到上限先停止本轮写入,再按精确资源清单处置,不清旧备份腾空间。
  5. 清理仅指本轮创建、标签/ID均匹配的容器、网络、卷和临时副本;禁止prune/通配删除、删除正式源归档、既有镜像或共享卷。失败也走清理,若清理未完成记录cleanupPending并停止,不把资源残留当演练通过。

无副作用只读核验与恢复写操作分开申请,最终源/目标身份仅进受控ignored操作材料;任务文档只存脱敏标识、摘要和结论。

2026-09-11 简化执行优先

附件失败诊断不改变准入:逐表保留原校验器,额外只输出缺文件、大小/摘要、路径/编码/行格式、非普通文件、外部存储和读取失败等白名单计数,并交叉核对原校验器的总数。不排除测试、归档、留痕或重复引用。所有表核对结束再执行同一失败门禁,避免只知道首张表失败;解包和普通文件数量验证、已完成 DB 验证须在附件失败前写入回执,不被笼统 NOT_TESTED 掩盖。无任何实际路径、记录 ID、文件摘要或内容进入诊断输出。

本轮遵循 PRD 最新收敛:固定摘要的一次性脚本内嵌已发布的 DB/附件恢复脚本和附件校验器,普通账号暂存,管理员在自己的终端输入一次 sudo 密码后执行;校验的是读入内存并将被执行的同一份字节,不采用先校验再以 root 重新读取文件的两次读取方式。没有长期安装器、sudoers 修改或通用 root shell。

仅本轮随机标签的无网络 PostgreSQL 容器、专属卷和私有目录可写;最终 TCP 查询连续成功再进入 restore,避免历史初始化临时服务竞态。来源提交动态冻结迁移名和 SQL 摘要;附件仍复用既有五类引用校验,base64 换行在 SQL 中去除。实际失败输出阶段、退出码和白名单错误类别,不回传原始 stderr。清理先校验本轮标签,身份不匹配不得删除;源归档前后摘要和监控/公网健康另行只读核验。

下文八段 RTO、历史 runner 部署/授权装置保留用于追溯,不作为本轮执行前提。本轮报告总恢复耗时、RPO、阶段和清理结果;应用与其他附件模型的端到端恢复不冒充已覆盖。

设计结论

采用“ARM 最新正式日归档 + 本轮私有临时副本 + 无宿主端口的一次性 PostgreSQL 16 + 空附件目录”的数据恢复方案。腾讯云生产只做演练前后公网 /ready 读取,不执行任何远端生产写操作;开发机不接收真实备份。

环境选择

方案 数据移动与风险 对生产影响 结论
ARM 一次性影子恢复 备份已在 ARM,只在同机复制到私有临时区;明文恢复范围最小 无生产容器操作 采用
开发机本地 Docker 需要把真实 DB/附件下载到个人开发机,扩大敏感数据副本 无运行影响,但数据治理风险高 不采用
腾讯云同机影子 Compose 不需跨机下载,但会在真实生产宿主创建容器、目录和资源负载 生产宿主存在误操作和资源竞争风险 不采用

ARM 在本设计中仍是备份与恢复演练节点,不是生产部署目标。一次性 PostgreSQL 不发布端口;本轮不启动 OA server。

数据流与信任边界

ARM 正式日归档(只读)
        │ 选择最新 complete 批次并校验
        ▼
本轮 0700 临时副本
        ├── database.dump ──> 一次性 PostgreSQL 16 影子库 ──> migration/表计数检查
        └── storage.tar.gz ─> 本轮空附件目录 ───────────────> 文件数/引用/哈希检查
                                                                    │
                                                                    ▼
                                                       仅数量与状态的脱敏证据

正式归档不被解包、改名、删除或直接用作写入目标。临时副本、影子数据库和恢复附件目录都由本轮唯一 run id 标识,清理只允许命中该标识。

恢复点合同

  1. 从正式日归档中按 manifest createdAt 选择最新批次,而不是按目录 mtime 猜测。
  2. 候选目录只能包含三个普通文件;manifest 必须为 schema v1、complete,并通过 run id、文件名、字节数和 SHA-256 自校验。
  3. buildSha 必须唯一解析为本仓库已知提交,并且是演练开始时公网 /ready 实测当前构建的祖先或相同;同时保留 source image digest 是否存在的布尔证据,不输出完整 digest。相等状态仅用于度量发布后何时形成同源恢复点,不再决定原始备份是否可恢复。
  4. 演练开始时批次年龄不得超过 24 小时。候选不合格直接 HOLD,不自动降级到旧批次。
  5. 临时副本复制完成后再次验证三文件和 manifest,证明恢复输入与正式归档一致。

目录身份校验只适用于 ARM 正式归档源目录:其 basename 必须与 manifest runId 配对。临时副本允许使用本轮固定私有名称;对临时副本必须继续验证 manifest 合同、run id 格式、三文件集合、字节数、SHA-256、构建和时间,并与源目录首次校验得到的脱敏元数据完全一致,但不得要求临时目录 basename 冒充正式归档名称。

隔离恢复合同

数据库

  • 使用随机一次性容器名和含 drill 的数据库名;容器无 ports,不加入现有业务网络,不挂载生产或 ARM 归档目录。
  • 使用 PostgreSQL 16,与现有恢复基线和脚本一致。
  • 调用 scripts/restore-db.sh,同时满足 RESTORE_SCOPE=shadow、精确数据库确认和显式 Docker 容器身份。
  • 恢复后只运行 SELECT:migration 成功/失败状态、固定关键表行数和附件引用聚合。不执行 Prisma migrate、seed、DDL 或修复语句。
  • 从 manifest 来源提交动态取得 migration 目录名称集合,与恢复库成功且未回滚的 _prisma_migrations.migration_name 做精确集合核对;不硬编码数量。再静态比较来源提交与当前构建的 migration 集合,记录“相同”或“当前有待升级 migration”。

固定 12 类表为:部门、员工、员工角色、采购申请、报销单、发票池、付款批次、合同、项目、采购附件、报销附件、付款回单附件。本次已知来源和当前构建现场均为 65 个 migration,但执行器必须使用动态名称集合,不能把 65 写成长期断言。

附件

  • 调用 scripts/restore-storage.sh 恢复到不存在或为空的本轮目录。
  • 恢复前验证 tar 可读、无绝对路径、无 ..、无软/硬链接;恢复后比较普通文件数。
  • 对使用本地存储的发票、采购、报销、付款回单和请假附件记录,只在影子库内读取 storage_path、大小和哈希用于机器校验,输出仅保留总数和不一致数。
  • s3: 引用不属于当前 tar 恢复范围;若最新备份中出现则 FAIL 并登记类别,不尝试访问外部对象存储。

RTO/RPO 口径

  • RPO = drill_started_at - manifest.createdAt。它表示使用该最新批次恢复时最多回退的数据时间窗口。
  • data_restore_RTO = all_data_checks_passed_at - restore_copy_started_at。起点位于全部只读前置门禁通过后、复制正式批次到临时工作区之前;终点为 DB、附件及关联检查全部通过。
  • data_restore_RTO 不只输出总秒数,还必须按 runner 的实际串行边界输出八个非负整数阶段:batch_copy、postgres_container_start、dump_list、target_confirmation、restore_apply、database_verify(migration 与 12 类表)、storage_attachment_verify(附件恢复、文件数与业务关联)和 final_integrity(源/副本末次身份复核)。总数应与八段之和一致;若因秒级取整存在误差,差值必须为 0,不另设无法归属的隐藏开销。
  • 阶段计时以 runner 内连续边界为准:每一段结束时立即关闭本段并开始下一段。最长 60 秒的 TCP 目标确认轮询只计入 target_confirmation;1 秒轮询粒度是当前演练器实现成本,不与实际 restore apply 混算。失败时已完成阶段输出实测秒数,当前失败阶段输出从其起点到失败的秒数,未运行阶段为 NOT_RUN。
  • 阶段分解用于区分批次复制、容器与演练器等待、数据库/附件实际恢复及一致性校验成本;它们仍共同属于“数据恢复演练 RTO”。本轮未启动 OA 应用、未验证登录或业务 smoke,因此总数和任何阶段都不得称为完整 OA 灾难恢复 RTO,也不形成 SLA。
  • same_build_recovery_point_lag 的正式口径需要可审计的发布完成时刻与 ARM 原子归档完成时刻;当前证据不具备后者,因此不得输出精确正式值。内部诊断可记录 manifest_creation_after_contract_dropin = manifest.createdAt - automatic-backup service 构建 drop-in 最大 mtime,但必须明确它既不是应用发布时刻,也不是 ARM 到达时刻。首次严格构建门禁失败仍证明发布后存在一段没有当前构建同源恢复点的窗口;该画像独立于数据 RPO 和数据恢复 RTO,即使恢复没有开始也保留。
  • 前置访问准备、等待人工输入和任务文档整理不计入 RTO;镜像拉取、批次复制、实际恢复和验证命令计入。
  • 本轮不启动应用,因此不得称为“完整 OA 恢复 RTO”;生产 /ready 只证明演练未影响在线服务。

状态判定

  • GO:AC1–AC7 全部满足,可证明最新成对数据备份按其来源 schema 可恢复;若来源与当前 migration 集合不同,必须同时标注当前 schema 升级未验证,不能宣称完整灾难恢复 GO。
  • PAUSE:访问权限、磁盘空间、Docker/PostgreSQL 16 运行条件或清理验证不足,但未发现备份内容损坏。
  • NO_GO:最新批次超龄/损坏、来源提交未知或不是当前构建祖先/相同、DB restore 失败、来源 migration 集合或关键表异常、附件缺失/哈希不一致、DB 与附件不配对,或生产受到影响。

重试不得覆盖首次失败证据。若修正环境性问题后重试,必须使用新 run id 并保留首次脱敏 finding。

Attempt 1 后的只读身份探针

首次恢复入口因最新 manifest build_mismatch 失败关闭后,不直接重装恢复入口。该失败证据保留,但严格相等门禁已被证明会在发布后自然备份到达前产生必然假阻断。后续先安装一个固定无参数、运行后自删除的 ARM root-owned 只读探针,权限仅用于读取 receiver 的路径配置和最新正式日归档:

  • 按 manifest createdAt 选择最新正式日批次,不自动改选旧批次。
  • 只输出 manifest 构建短标识、与当前生产短标识的相等状态、批次年龄、run id/目录配对状态、source image digest 是否存在,以及两份产物的文件类型、大小和 SHA-256 聚合校验状态;祖先关系在本地仓库只读验证,不要求 ARM 持有 Git 历史。
  • 不输出完整 run id、完整 digest、完整路径、字节数、文件名之外的业务信息或原始 manifest。
  • 不复制、不解包、不启动 Docker、不读取数据库内容或附件内容,不修改正式归档。
  • 成功或失败均删除探针自身和对应 sudoers;普通用户暂存文件在 SHA-256 精确匹配后另行清理。

探针结果不能替代恢复验收。最新 manifest 与当前生产不相等时,先判断其是否为当前构建祖先:是祖先且批次完整、新鲜时可进入原始恢复重试审批,同时记录同源恢复点滞后;未知或非祖先才维持 NO_GO 并转入备份链调查。手工触发生产备份始终是可撤回备选项,不是完成项。

Attempt 3 后的数据库恢复诊断合同

Attempt 3 只留下 database_restore_failed 聚合类别,失败清理同时删除了权限 0600 的私有原始日志。删除潜在敏感日志是正确安全边界,但下一轮 runner 必须在清理前生成只含 allowlist 状态的阶段诊断,不能再次盲重试。

  • 数据库恢复拆成 dump_list、target_confirmation、restore_apply 三个独立阶段;每个阶段只输出 PASS、FAIL 或 NOT_RUN,并保留稳定错误类别,不输出 pg_restore 原始 stderr、对象名、路径、凭据或 SQL。诊断必须在失败路径进入清理前写到标准输出,清理仍由 trap/finally 等价路径保证执行;输出先于清理不表示保留私有原始日志。
  • dump_list 使用与实际恢复相同的 PostgreSQL 16 容器和临时副本,只解析 custom dump 目录,不执行数据库写入。失败时分类为 dump_list_failed 并停止。
  • target_confirmation 只确认当前数据库名等于含 drill 标记的本轮目标,并确认容器连接可用;失败时分类为 restore_target_confirmation_failed。
  • 只有前两项通过才允许调用现有 restore-db.sh。调用必须记录执行路径枚举 DOCKER_STDIN 或 HOST_FILE、脚本退出码和真实 pg_restore apply 退出码;ARM runner 固定使用 DOCKER_STDIN,两条路径都由本地集成回归直接驱动仓库中的 scripts/restore-db.sh。此后非零分类为 restore_apply_failed_after_list_pass,证明 dump 可解析但实际应用失败;它仍不能自动等同于备份内容损坏。
  • 对 pg_restore 非零不设置“可忽略对象错误”白名单:即使私有日志末尾可脱敏提取到对象错误计数,任何非零仍为 FAIL/NO_GO。计数只输出非负整数或 UNKNOWN,不得据此把部分恢复升级为 PASS;未来若要允许部分成功,必须另行列出错误类别、证明对象集合完整并重新批准设计。
  • 私有原始日志继续保持 0600 且不得复制出 ARM;成功或失败都随本轮 runtime 删除。清理前将三项阶段状态写入最终标准输出,保证失败原因至少可定位到合同边界。
  • 合成回归必须分别制造 dump 列表失败、目标确认失败和列表通过但 apply 失败,并断言后续阶段保持 NOT_RUN、输出不含 fixture Secret/路径/SQL、容器与 runtime 清理为零;至少一条注入失败用例必须同时证明诊断在清理前已输出、清理完成后该诊断仍可从调用方捕获。成功回归直接以 PostgreSQL 16 custom dump 驱动真实 scripts/restore-db.sh 的 Docker stdin 与宿主文件两条路径,不重写等价恢复逻辑。
  • 该修复只改变演练器诊断能力,不放宽正式归档、源/副本身份、24 小时新鲜度、shadow 数据库、无端口、附件或清理门禁。任何 Attempt 4 实际恢复仍须使用新 run id、重新探测最新批次并另行批准。

Attempt 4 前的 runner 持久化门禁提案

个人 .trellis/workspace/ 中的 runner 和回归不会提交,不能作为 Attempt 4 的可重复证据。该限制不接受;实际恢复前应在另行批准的本地代码阶段完成以下持久化,不与 ARM 探针或恢复操作混在一起:

  • 将当前一次性恢复 runner 收敛为受版本控制、只含占位符和校验逻辑的 scripts/qa/offhost-shadow-restore-runner.template.sh,将附件关联校验器作为 scripts/qa/validate-offhost-shadow-restore-attachments.py。Attempt 4 候选必须由合同脚本使用私有 JSON 参数机械渲染,并与现有 scripts/restore-db.sh、scripts/restore-storage.sh 一起打包;不得从聊天或个人 workspace 重新生成等价实现。
  • 新增 scripts/qa/run-offhost-shadow-restore-runner-contract.sh,直接校验候选 runner 的 Bash 语法、依赖 SHA-256、固定无参数入口、源/副本身份重绑、动态 migration 集合、三阶段脱敏诊断、退出码、失败先诊断后清理及敏感输出边界。
  • 合同脚本直接以 PostgreSQL 16 custom dump 驱动真实 scripts/restore-db.sh 的 Docker stdin 和 host-file 两条路径,并覆盖 dump-list 失败、目标确认失败、apply 非零、后续阶段 NOT_RUN 和零残留。测试失败不得上传候选。
  • 远端地址、现场路径、远端账号、一次性容器/数据库命名、timer、构建前缀和 migration 集合指纹均不进入模板;由私有 JSON 提供并按字段类型、字符集和安全命名规则验证后替换。渲染器拒绝额外/缺失字段、换行、shell 语法、非绝对或逃逸路径和错误摘要格式,不从不受信环境变量覆盖。
  • 私有 JSON 和渲染后的现场候选只能位于仓库外,或位于仓库内专用的 /.restore-drill-private/ ignored 目录。渲染器必须对输入和输出分别执行 Git ignore 判定;任一路径在仓库内但未被忽略时,在创建候选前失败关闭,不能依赖人工避免 git add -A。
  • 权限 0600 的私有原始日志只允许初始化时截断一次;容器启动、dump list、目标确认、restore apply 和附件恢复各写固定阶段标记并使用追加模式。这样后续阶段失败时仍能在本轮 runtime 内交叉核对之前阶段,最终清理和脱敏输出边界不变。
  • 上传前由合同脚本对实际候选文件生成/校验 SHA-256;ARM 安装前再次校验同一摘要。证据记录合同脚本提交、候选摘要和 PASS,不记录地址、路径或业务数据。

Attempt 4 后的 PostgreSQL 稳定就绪修订提案

Attempt 4 在 dump_list=PASS/0 后、实际 restore apply 前以 restore_target_confirmation_failed 失败关闭。只读环境差异调查确认:runner 以第一次 pg_isready 成功作为容器就绪,而合同夹具因曾观察到 PostgreSQL 首次初始化临时服务关闭窗口,已改为连续两次真实查询成功;两者没有复用同一就绪实现。该问题按 local_contract_vs_arm_environment_difference_investigation 登记,高置信度根因为 postgres_initialization_readiness_race,不能归因于备份不可恢复。

修订只允许改变跟踪 runner 与其可复跑合同,不修改 scripts/restore-db.sh,不放宽目标身份、归档、shadow、无端口、恢复非零失败关闭或清理门禁:

  1. 删除 runner 的“首次 pg_isready 成功即通过”判定。pg_isready 最多可作为诊断,不能成为允许 restore 的充分条件。
  2. dump_list 保持不连接目标数据库的独立阶段;其成功只证明 custom dump 目录可解析,不计入数据库稳定就绪次数。
  3. target_confirmation 在容器内显式通过 TCP 回环 127.0.0.1 执行真实 SELECT current_database(),不用默认 Unix socket。官方 PostgreSQL 镜像的 initdb 临时服务器不监听 TCP,而正式服务器监听容器内回环;因此 TCP 成功是排除临时初始化服务器的结构性判据。--network none 不影响容器自己的 loopback,实际 restore 仍可继续沿现有 socket 路径,不修改 scripts/restore-db.sh。
  4. TCP 目标确认使用最多 60 次、每次间隔 1 秒的有界窗口作为额外保险。每次查询退出码非零时把连续成功计数重置为 0;查询成功且数据库名精确等于本轮 shadow 目标时累计,只有连续两次成功才 PASS;查询成功但名称不匹配立即失败,不继续重试。连续成功不是排除临时服务器的唯一依据,不能移除 TCP 回环判据后单独保留时序启发式。
  5. 脱敏输出增加 target_confirmation_exit_code、target_name_match=yes|no|NOT_RUN、target_confirmation_attempt_count 和 target_confirmation_success_streak。退出码取最后一次实际查询的非负整数;未执行为 NOT_RUN。错误类别区分 TARGET_QUERY_NONZERO 与 TARGET_NAME_MISMATCH,不得输出查询文本、数据库名、路径、凭据或原始 stderr。
  6. 稳定就绪函数必须定义在被渲染 runner 中;合同从 rendered runner 提取或直接驱动该同一实现,不得在 fixture 外围另写一套更宽松或更稳定的等待逻辑。fixture 自身如需等待,也必须通过同一实现进入后续真实脚本双路径回归。
  7. 实施前审计 runner 合同的每个覆盖机制,区分“直接执行跟踪脚本/校验器”“从 rendered runner 提取同一实现”“只做静态结构断言”和“合同自行重写等价逻辑”。最后一类必须消除或登记为明确限制,不能以组件测试通过推断未执行的 runner 编排行为。
  8. 回归至少覆盖:Unix socket 可用但 TCP 仍失败时不得通过;首次 TCP 查询失败后连续两次成功可继续;一次成功后失败会重置 streak;数据库名错配立即失败;查询持续非零在第 60 次后失败;每条失败路径的 apply 保持 NOT_RUN、诊断先于清理且残留为 0;原有 dump、restore 双路径、身份、migration、附件和敏感输出合同继续全绿。
  9. 禁止用固定长 sleep、增加 ARM 特例、降低连续成功次数、跳过 TCP 回环或名称精确比较、只修改合同夹具来修复。若相同实现的本地合同通过而 ARM 仍在该阶段失败,保留环境差异 finding,依据新退出码和尝试计数继续调查,不现场放宽。

本节已于 2026-08-25 获准仅在本地实施和回归;授权不包含连接 ARM 或执行 Attempt 5。修复通过后仍须提交可追溯变更、重新选择当时最新批次、重跑只读身份探针、重新渲染并核对候选 SHA-256,再单独申请 Attempt 5 实际恢复。

清理与回滚

  • 生产无变更,无生产回滚步骤。
  • 成功或失败都只删除本轮 run id 对应的一次性容器、临时附件目录和临时批次副本;归档目录、receiver 管理目录和现有监控容器不在清理匹配范围。
  • 清理前再次解析容器名和临时根目录;任何对象不满足本轮标识、类型或父目录约束时停止自动清理。
  • 清理后确认无本轮容器、宿主端口、临时网络和临时目录残留;失败则结论至少为 PAUSE,交由管理员精确处置。

证据边界

执行证据写入当前 task 的 research/restore-drill-evidence.md,随后只在现有 OPS7 Runbook 或人工试运行报告中补一条脱敏状态摘要;不新建 docs/ 平行文档。不得保存主机地址、SSH 别名、完整远端路径、Secret、连接串、真实账号、表内容、附件名或原始日志。

2026-09-09 最小人工恢复路径

与旧装置的关系

本阶段直接调用已跟踪的恢复脚本,不新增执行 runner、安装器、确定性渲染、sudoers 规则或 RTO 分段平台。现有 shadow/目标名称确认是恢复脚本内置安全条件,不删除、不改参数绕过。原正式演练指标继续保留未验,本阶段不改写五次历史结果。

本地已固定脚本身份:

文件 SHA-256
scripts/restore-db.sh 3707d81db02364c5a4917f230ddde721731c04aa66ab746bd23a7bdcd475b95d
scripts/restore-storage.sh de9e9340e16383d05b9821cdc73b65a0c28fbb2784a5ecb93d70c2e456ca651b
scripts/qa/validate-offhost-shadow-restore-attachments.py 2c2981d0e3f522145ffa1770f5ff519b3ffdfea74267e4d8ea9e30c130f7f0c2

手工操作清单(实际执行另批)

  1. 在 ARM 获准管理员会话确认权限、镜像可用性、内存与磁盘余量。读取当时最新正式批次,而非复用 09-08 的摘要;核对 manifest 身份、PG/来源版本、DB/附件配对、字节数/摘要和24小时年龄。批次有歧义、超龄或损坏停止;现阶段没有选定备份,不声称上述前置已通过。
  2. 生成本轮唯一标识,登记本轮容器与临时卷/目录;容器无宿主端口、network=none、明确 shadow 库名,使用临时凭据且不回显。只读使用归档数据或通过 stdin 输入,不挂生产数据卷、不向容器注入生产数据库连接,不启动 OA/worker,也不执行 migration/seed。
  3. 容器内 TCP 回环查询 current_database,名称必须匹配本轮影子库;采用已有稳定就绪合同的连续两次成功和有界等待,不能以首次 pg_isready/socket 就绪直接开始。这里只是手工操作的就绪确认,不新增等待框架。失败保留阶段与安全错误后停止。
  4. 明确指定 DB_TOOL_MODE=docker、DB_CONTAINER、POSTGRES_DB/USER、临时密码、RESTORE_SCOPE=shadow、RESTORE_CONFIRM_DATABASE,调用上述版本的 restore-db.sh。参数从本轮受控会话生成,不从生产 env 复制;私有原始输出仅留受控临时位置。脚本任何非零不忽略、不自动重试,是否源备份损坏需进一步依据,不能由“恢复失败”一句话判定。
  5. 确认恢复整个命令退出 0;查询来源 migration 集合、失败状态以及原任务规定的12类关键表可读与数量摘要,不输出业务行。数量不是与实时生产一致的证明;恢复成功结论限于该具体批次。不要为了核对计数新增生产表数据读取。
  6. 数据库通过后可按另获准的范围继续以 restore-storage.sh 恢复配对附件到本轮空目录,复用既有附件关联校验器;检查归档路径安全、文件集合和适用引用/摘要。若只批准数据库,附件明确 NOT_TESTED,不顺手解包。
  7. 清理前生成短脱敏结果:批次指纹、开始/结束、退出码、DB/附件/应用验证状态、失败阶段和安全错误类别。原始敏感日志按既有受控清理规则处理,不能先删掉唯一诊断再留下“原因未知”。
  8. 按本轮登记身份精确删除临时容器、临时卷/目录和临时凭据并回查;不删正式备份、不清理旧回滚镜像、不变更监控/timer。目标不明确立即停止清理并报告,不宽泛 prune。未清理完的结果不得写成完全闭环。

结果字典

  • DB_RESTORE_PASS / STORAGE_NOT_TESTED / APP_NOT_TESTED:本批次数据库恢复成功,附件和应用仍未验;不是原正式演练整体 PASS。
  • DB_RESTORE_PASS / STORAGE_RESTORE_PASS / APP_NOT_TESTED:成对数据恢复及既定一致性检查成功,不证明应用启动、正式切换或完整恢复耗时。
  • STOPPED + 实际失败阶段 + 安全错误类别:不自动认定备份损坏;不继续业务发布来掩盖失败。若备份完整性或真正恢复数据步骤报错,优先单独报告并保全恢复点,不将其降为工具债。
  • CLEANUP_PENDING:保留本轮目标标识与待清对象,不声明环境已恢复原状。