diff --git a/docs/ha-plan.md b/docs/ha-plan.md index 4ef740b..7c73be0 100644 --- a/docs/ha-plan.md +++ b/docs/ha-plan.md @@ -1,628 +1,84 @@ --- -document_id: ohmywod-ha-plan -schema_version: 1 +document_id: ohmywod-single-node-dr-plan +schema_version: 3 document_status: active -source_of_truth_for: "HA/DR direction, implementation gates, work item status, and wave changelog" +source_of_truth_for: "单机快速重建与节点替换:目标体验、必要步骤、当前下一步" language: zh-CN created_at: "2026-07-05" -last_updated: "2026-07-22" -review_commit: "app 161cc80; ops 3de16e6; production app d930c6a" -review_worktree: "app clean; ops has user-owned encrypted token update" -next_item_id: "HA-011" +last_updated: "2026-07-23" --- -# 高可用与灾难恢复计划 +# 单机 DR:快速重建与节点替换 -> 本文是 ohmywod 可用性、备份恢复和温备建设方向的工作计划,不是可以直接复制到生产执行的 runbook。具体生产真值和恢复步骤以私有仓库 `ohmywod-ops/docs/recovery.md` 为准,ops 整改细节以 `ohmywod-ops/docs/ops-review-plan.md` 为准。 -> -> **当前结论:生产仍是单机。HA-006 / OPS-004 已在隔离 Linode 完成空白机 prepare、完整 SQLite 恢复与 JuiceFS metadata load;SQLite 与生产关键表一致,本次观测 RPO=0,从实例创建到 metadata 就绪约 39 分钟。可信控制机也已用恢复出的 metadata 从真实对象端只读抽样 10 个战报文件。第三方演练机上的 Object Storage credential 传递被执行环境安全策略阻止,因此真实 VM FUSE/reboot gate、证书和切流仍未验证,HA-006 保持进行中,不建设或承诺双机温备。** +> 个人兴趣项目。目标不是工业级 HA,而是:**一条命令自动起一台装好数据的新机,做一次简单验证,再一条命令把生产切过去。** +> 不追求漂亮的 RPO/RTO 数字,只求少人肉、流程短到自己能完全掌控。 -## 1. 边界、现状与目标 +## 1. 为什么可以很简单 -### 1.1 本文负责什么 +- **战报在共享对象存储**(JuiceFS 对象桶)。新机 load metadata + 补凭据后读的是同一批文件,**不需要拷贝战报**。 +- **SQLite 由 Litestream 持续复制**到备份桶。新机 `litestream restore` 即可,数据落后以秒计。 +- **主机配置全在 Ansible**(`site.yml`)。空白 Ubuntu 一把幂等收敛;密钥在 sops,控制机直接用,不做二次加密传递。 +- **唯一硬约束:同一时刻只能有一个写者。** 两台机同时向同一备份桶 / 对象桶写会分叉。所以新机在切换前不启动 + Litestream、不接公网流量——没有流量就没有写入。守住这一条,其余安全门都可以省。 -- 记录可用性与恢复方向、已拍板架构约束、事项状态和完成证据。 -- 维护 `HA-NNN` 级跨仓事项;一个事项可以在 app 和 ops 两个仓库分别落地。 -- 不复制生产 IP、bucket、monitor ID、密钥字段和逐条恢复命令;这些真值只放在私有 ops 仓库。 -- 不替代 `ohmywod-ops/docs/ops-review-plan.md`。后者负责具体 ops 缺陷,本文负责把它们映射到整体 HA/DR 路线。 -- 应用安全、体验和性能改进归 [站点改进计划](improvement-plan-2026-07.md);维护成本支持归 [站点维护成本支持计划](maintenance-support-plan.md)。 +## 2. 明确不做(这是之前失控的来源,一律去掉) -### 1.2 当前生产真值(基线 2026-07-18,更新至 2026-07-21) +- 不建常驻温备、双机复制、自动 failover、NodeBalancer、多活。 +- 不做候选机只读演练挂载、独立 drill inventory、强制重启复验、逐层哈希证据、三步 age 凭据仪式、逐层 RPO/RTO 证明。 +- 不做请求排空、事务级一致切换、自动回滚、把新机写入合并回旧机。切换瞬间正在处理的请求可以失败。 -| 能力 | 当前状态 | 证据与边界 | -|---|---|---| -| 计算 | 单台 Linode 1 vCPU / 961 MiB,无私网 IPv4 | Redis `role:master`、`connected_slaves:0`;没有备机 inventory | -| 应用版本 | 生产 `d930c6a`,本仓 `eb2b8be` | HA-008 已合并到 `main`;后续 IMP-006 至 IMP-009 也已部署 | -| SQLite | WAL,完整性检查通过 | Litestream 0.3.13 active;2026-07-22 已在隔离空白机恢复完整库,关键表与生产只读对照一致,本次观测 RPO=0 | -| 战报文件 | 已全部归一到 `/mnt/jfs/reports` | JuiceFS 当前约 104 GiB;旧 `/data/ohmywod/report` 和 `/mnt/extra-report` 已不存在 | -| JuiceFS | Linode Object Storage + Redis db 2 metadata | 2026-07-22 已 load 最新 metadata,并在可信控制机读取真实对象抽样;第三方 VM FUSE/reboot 尚未验证;没有 Redis replica | -| 外部监控 | `/healthz` 经 Cloudflare 返回 200 | UptimeRobot 与两个 Healthchecks timer 已配置;仍需验证备份新鲜度和真实可恢复性 | -| 开机恢复 | JuiceFS/nginx gate 已收敛到生产 | nginx 由 `After` + `BindsTo` 卡在 JuiceFS 挂载后;真实 FUSE / reboot 仍待 HA-006 验证 | -| 身份 | SQLite 是唯一运行时账号密码真值 | 756 个账号已迁移;SQLite 认证与 Argon2id 惰性升级已上线;slapd 与 LDAP 配置/密钥已删除 | -| 切换 | Cloudflare 可作为流量切换面 | 占位 `switchover.sh` 已删除;真实 DNS 切换、防双主和回退仍未演练 | +接受的风险(与站点体量相称,真出事或成本明显下降再重估): -### 1.3 风险结论 +- 旧机彻底死时,SQLite 回到 Litestream 最新点、JuiceFS 回到最近一次 metadata dump,可能丢几秒到一次备份间隔的数据。 +- 战报没有第二桶或不可变副本,不防供应商账号级事故和超窗口误删。 +- 切换后旧机不再自动追平;需要回退时手动把 DNS 改回旧机。 -当前最大的风险已经从“身份真值分裂”收敛为“恢复链尚未被真实证明”: +## 3. 目标命令(待在私有 `ohmywod-ops` 仓实现) -1. OPS-001 至 OPS-003 已把 prepare / restore / serve 闸门和 JuiceFS/nginx 启动关系落地;真实 VM 上 SQLite 与 metadata 恢复已通过,但 FUSE、重启和入口 gate 仍待 OPS-004 验证。 -2. HA-008 / OPS-015 已把账号密码真值收敛到 SQLite 并删除 LDAP;身份不再扩大 DR 边界。 -3. 证书、DNS、Cloud Firewall、新机接入与控制节点信任链仍需由 OPS-007 / OPS-008 闭环并在真实演练中验证。 -4. 监控目前能证明进程和定时任务在运行,不能单独证明最近副本可恢复。 -5. 生产仍没有备机、Redis replica 或私网复制路径;在 HA-006 / HA-007 通过前,这只是具备持续复制基础的单机系统,不是 HA。 - -### 1.4 已拍板的原则 - -1. 先退役 LDAP、缩小恢复边界,再完成可靠的单机 DR,最后才决定是否建设双机温备;备机不能替代恢复演练。 -2. 目标架构是 active-passive,不做多活;SQLite 和 Redis 不允许双主写入。 -3. 故障切换由告警触发、人工确认、一条命令执行,不做自动 failover。 -4. 备机与主机优先同区域、不同宿主机,用私网 VLAN 复制;区域级故障靠对象存储和从零恢复兜底。 -5. 流量切换继续使用 Cloudflare 源站变更,不为这个体量引入 NodeBalancer。 -6. 配置管理使用 Ansible,密钥使用 sops + age;不引入 Vault/OpenBao 一类新的在线单点。 -7. 战报不做 restic 或第二 bucket 副本。接受暂时没有防误删版本化备份的风险;触发条件见 HA-007。 -8. LDAP 在单机 DR 演练前退役,避免为过渡组件设计恢复、复制和切换;迁移期间先保留可回滚材料,再按“切换、停用、删除”三步退出。 -9. 所有 RPO/RTO 只能来自演练记录,不能从组件宣传或脚本目标推导。 - -### 1.5 成功判断 - -计划分两层成立: - -- **DR 层成立**:LDAP 已退出运行时和恢复边界;从隔离的空白机器恢复时,不会创建并复制空库,不会向未挂载目录写数据;SQLite(含账号密码真值)、JuiceFS metadata、战报、证书、入口网络和监控均有可复核恢复证据,并记录实际 RPO/RTO。 -- **温备层成立**:主机与备机角色明确,只有一端可写;真实切换和回切都演练成功,旧主重新加入前经过防脑裂检查;日常升级能按同一套蓝绿流程完成。 - -## 2. 这份计划怎么维护 - -这是个人兴趣项目,不需要 owner、RACI 或复杂发布委员会。后续通常由一个 AI 工具推进,再由另一个 AI 工具独立检查。涉及生产写入、停机、DNS、云资源和持续成本时,由用户做最后决定。 - -每个事项有两个可选角色: - -- `Drive AI`:调查现状、提出选项、实施改动,并更新本文和 changelog。 -- `Review AI`:独立检查假设、diff、演练证据和剩余风险,不只复述 Drive AI 的结论。 - -角色可以写工具名,例如 `Codex` 或 `Claude Code`;尚未分配时写 `unassigned`。 - -### 2.1 状态枚举 - -工作项的 `状态` 只能使用以下值: - -- `todo`:方向已确认,尚未开始 -- `assessing`:仍需调查、成本确认或用户选择,尚不能直接实施 -- `in_progress`:正在调查或实施 -- `blocked`:存在明确阻塞;必须写明解除方式 -- `done`:完成判断已经满足,并有可复核证据 -- `cancelled`:明确决定不做;记录理由和重新考虑的触发条件 - -### 2.2 更新规则 - -1. 每个 `HA-NNN` ID 永久不变、不可复用。新增事项使用 front matter 的 `next_item_id`,并同步递增。 -2. 开始工作前先读本文、两个仓库的 `git status` 和 ops 审查计划,保留用户或其他工具的未提交改动。 -3. `状态` 是事项主真值。状态变化、实现改动和 changelog 放在同一波变更里。 -4. app 仓只维护跨仓目标和应用侧证据;具体 ops 子问题继续使用既有 `OPS-NNN`,不要在这里复制一份独立状态。 -5. `done` 必须满足完成判断。进程 active、Molecule 通过、脚本写完或监控为绿,都不能单独证明恢复/切换完成。 -6. 生产写入、停服务、重启、DNS、防火墙、密钥、实例创建或删除必须先获得用户明确授权。 -7. 证据不得包含 token、口令、SOPS 明文、完整 capability URL 或个人信息。 -8. RPO/RTO、费用和供应商能力容易变化;记录日期和来源,不把估算写成长期承诺。 -9. 每波改动在文末按时间正序追加 changelog。旧记录不重写,纠错时追加 correction wave。 -10. 每次变更更新 front matter 的 `last_updated`。 - -### 2.3 固定工作项格式 - -新增事项使用同一套结构: - -- 元信息:`状态`、`优先级`、`波次`、`Drive AI`、`Review AI`、`依赖`、`最后更新`、`结论置信度` -- 内容:`问题与影响`、`证据`、`方向与要点`、`完成判断`、`Review 关注`、`执行证据` - -优先级定义: - -- `P0`:可能造成数据损坏、空库进入备份链、恢复失败或恢复期间错误对外服务 -- `P1`:显著影响可恢复性、切换能力、安全性或变更成功率 -- `P2`:成本、治理和长期维护性问题 - -## 3. 建议推进顺序 - -### Wave 0:把现有单机恢复路径变安全(已完成实现切片) - -范围:HA-004、HA-005。 - -方向:先解决空库、服务启动顺序和未挂载目录写入风险。OPS-001 至 OPS-003 已落地;真实 FUSE / reboot 复核留给后续恢复演练。 - -### Wave 1:先退役 LDAP,再建立可验证的 DR 闭环 - -范围:HA-008、HA-006、HA-007。 - -方向:HA-008 已完成,账号和密码真值已迁入 SQLite,LDAP 已清理。当前以 HA-006 在隔离新机恢复更小的系统边界并测量 RPO/RTO,随后用 HA-007 收敛备份新鲜度与误删风险。 - -### Wave 2:建设并演练温备 - -范围:HA-009、HA-010。 - -方向:通过 DR gate 后再创建备机、复制 Redis metadata、实现切换与回切。第一轮使用临时或可按小时计费的实例验证,结果和成本都可接受后再决定长期保留。 - -## 4. 工作项 - -### HA-001 — 单机宿主加固与外部存活监控 - -- 状态:`done` -- 优先级:`P1` -- 波次:历史基线 -- Drive AI:`unassigned` -- Review AI:`unassigned` -- 依赖:无 -- 最后更新:2026-07-18 -- 结论置信度:`confirmed` - -问题与影响:旧生产机的 SSH、Redis 暴露、开机自启和外部存活监控都依赖手工状态,重启可能直接变成停机。 - -证据:ops 仓已经落地 SSH hardening、Cloud Firewall、systemd 受管服务、nginx、UptimeRobot `/healthz` 和 Healthchecks timer;2026-07-12 做过真实重启验证。2026-07-18 只读检查确认相关 unit enabled/active、公开 `/healthz` 返回 200。 - -方向与要点:保留现有能力;后续的恢复顺序和服务依赖缺口由 HA-004、HA-005 处理,不反向改写本项状态。 - -完成判断:现有主机重启后基础服务可恢复,公网入口只暴露预期端口,站点和备份巡检有外部告警。 - -Review 关注:本项 `done` 不等于冷恢复可用,也不证明 JuiceFS 先于 web 启动。 - -执行证据:`ohmywod-ops` roles:`ssh_hardening`、`supervisord`、`juicefs`、`monitoring`、`nginx`;生产只读复核日期 2026-07-18。 - -### HA-002 — 战报存储归一化并迁入 Linode Object Storage - -- 状态:`done` -- 优先级:`P1` -- 波次:历史基线 -- Drive AI:`unassigned` -- Review AI:`unassigned` -- 依赖:HA-001 -- 最后更新:2026-07-18 -- 结论置信度:`confirmed` - -问题与影响:战报曾分散在本地盘、Block Storage 和 JuiceFS,靠软链接拼接;任何切换都要同时搬运三种存储状态。 - -证据:app 的 `DATA_DIR` 已收敛为 `/mnt/jfs/reports`,usage、上传阈值和 `/healthz` 已删除旧存储语义;ops 的 JuiceFS 后端已切到 Linode Object Storage。生产只读检查确认旧本地 report 目录和 `/mnt/extra-report` 均不存在,约 104 GiB 战报位于 JuiceFS。 - -方向与要点:继续把 JuiceFS metadata 视为最高价值恢复对象;对象块存在不等于文件系统可恢复。 - -完成判断:生产读写只使用 JuiceFS 战报目录,旧 Block Storage 已释放,应用和运维配置不再依赖旧路径。 - -Review 关注:避免重新引入三路径容量显示、软链接或迁移脚本。 - -执行证据:app `0512c83`;ops `c5ee2b8`/`470ba2f`;2026-07-18 生产挂载与目录检查。 - -### HA-003 — SQLite WAL、持续复制与基础巡检 - -- 状态:`done` -- 优先级:`P1` -- 波次:历史基线 -- Drive AI:`unassigned` -- Review AI:`unassigned` -- 依赖:HA-001 -- 最后更新:2026-07-18 -- 结论置信度:`confirmed, replication only` - -问题与影响:单机 SQLite 没有持续副本时,主机损坏会同时丢应用数据。 - -证据:生产数据库为 WAL,`PRAGMA integrity_check` 返回 `ok`;Litestream 0.3.13 active,Healthchecks timer 最近执行成功,历史上做过一次 restore smoke。应用锁等待的生产漂移由 IMP-003 收敛(2026-07-21:生产有效 `SQLALCHEMY_ENGINE_OPTIONS` 已含 5 秒 busy timeout,`done`)。 - -方向与要点:本项只确认“持续复制链已建立”。恢复安全、备份新鲜度和冷恢复由 HA-004、HA-006、HA-007 验收。 - -完成判断:生产数据库稳定运行在 WAL,Litestream 连续复制,基础失败能被巡检发现。 - -Review 关注:不要用 service active 或能列出旧 generation 代替最新恢复验证。 - -执行证据:app `8fb058e`;ops Litestream role 与 monitoring role;2026-07-18 生产只读检查。 - -### HA-004 — 重排冷恢复流程,阻止空库上线或进入备份链 - -- 状态:`done` -- 优先级:`P0` -- 波次:Wave 0 -- Drive AI:`Claude Code` -- Review AI:`unassigned` -- 依赖:HA-003 -- 最后更新:2026-07-22 -- 结论置信度:`confirmed` - -问题与影响:旧恢复文档先运行完整 playbook,再恢复数据。Litestream role 的 SQLite 检查会在缺库时创建空文件,app、nginx 和 Litestream 也可能过早启动。 - -证据:`ohmywod-ops/docs/ops-review-plan.md` 的 OPS-001、OPS-002 已标记 `done`;当前 recovery runbook 已使用 prepare → restore / verify → serve;缺库检查使用不创建文件的 `stat` 与 SQLite `mode=rw`。 - -方向与要点:拆分“准备机器、恢复数据、验证、激活流量”;缺库检查必须使用不创建文件的只读打开;恢复完成前禁止 app 和 Litestream 写入/复制目标。 - -完成判断:空白机和常见失败路径都不会创建空业务库、污染最后恢复点或提前对外服务;`--check` 不产生业务数据副作用。 - -Review 关注:Litestream generation 选择、失败清理和重复执行是否仍有污染路径。 - -执行证据:OPS-001 / OPS-002(WAVE-20260720-01..03):恢复闸门、三阶段 runbook、缺库与 `--check` 回归测试、Ubuntu 24.04 prepare 幂等验证均已完成;真实空白 VM 数据恢复仍由 HA-006 / OPS-004 验收。 - -### HA-005 — 修正 Redis meta、JuiceFS、web 和 nginx 的服务依赖 - -- 状态:`done` -- 优先级:`P0` -- 波次:Wave 0 -- Drive AI:`Claude Code` -- Review AI:`unassigned` -- 依赖:HA-004 -- 最后更新:2026-07-21 -- 结论置信度:`confirmed` - -问题与影响:Redis store、Redis cache 和 web 同属一个 Supervisor unit。JuiceFS 必须等 Redis store,但 web 又必须等 JuiceFS,当前 unit 粒度形成错误依赖,重启时 web 可能写进挂载点的底层目录。 - -证据:生产 `systemctl show` 显示 `ohmywod-supervisord Before=juicefs.service`;app Supervisor 模板同时管理 web 和两个 Redis;OPS-003 已记录同一问题。 - -方向与要点:重建启动/停止关系,保证 Redis meta → JuiceFS ready → web → nginx;具体选择拆 unit、拆 Supervisor 或增加可靠 gate,实施时评估。 - -完成判断:正常重启、JuiceFS 挂载失败和短暂对象存储故障下,web 都不会向未挂载目录写入;依赖可由自动测试和真实重启复现。 - -Review 关注:循环依赖、停止顺序、挂载假活和 nginx 过早接流量。 - -执行证据:OPS-003(WAVE-20260720-04):JuiceFS `ExecStartPost` 等待真实挂载,nginx 使用 `After` + `BindsTo` 作为公网入口闸门;Ubuntu 24.04 systemd 容器以 tmpfs 验证未挂不放流量、挂载后启动、掉挂后停 nginx。真实 FUSE / reboot 留给 HA-006 / OPS-004 复核。 - -### HA-006 — 补齐裸机恢复前提并完成端到端演练 - -- 状态:`in_progress` -- 优先级:`P1` -- 波次:Wave 1 -- Drive AI:`Codex` -- Review AI:`unassigned` -- 依赖:HA-004、HA-005、HA-008(均已完成) -- 最后更新:2026-07-21 -- 结论置信度:`confirmed` - -问题与影响:bootstrap、运行目录和关键二进制已基本收敛,LDAP 也已退出恢复边界,但证书、DNS、Cloud Firewall、新 inventory 和信任链仍未在真实空白机闭环。现有 smoke 只恢复 SQLite 到临时目录,不能证明完整系统可恢复。 - -证据:HA-008 / OPS-015 已完成身份迁移与 LDAP 退役;OPS-002、OPS-003 已完成恢复前准备和服务 gate;剩余恢复演练由 OPS-004 跟踪,新机接入与信任链由 OPS-007 / OPS-008 跟踪。 - -方向与要点:当前前置已满足。在隔离新机从信任根开始,恢复 SQLite(含账号密码)、JuiceFS metadata/战报、证书、入口网络和监控;验证后再切测试流量。记录每一步耗时和恢复点时间,不再恢复 slapd / LDIF。 - -完成判断:一台空白 Ubuntu 能安全到达“数据已恢复但未上线”,再经显式激活对外服务;至少一次完整演练记录真实 RPO/RTO、回退和遗留手工步骤。 - -Review 关注:是否偷用了旧主机文件、个人 shell、已存在 DNS/证书或未入库凭据。 - -执行证据(准备切片,WAVE-20260721-08):ops 仓已加入独立 recovery inventory、拒绝生产 IP / 占位值 / -多目标的只读预检、显式 `-i` + `--limit` 命令和 RPO/RTO 证据模板;HA-006 因此正式开始。该切片只闭合 -离线准备和误连生产 gate,并把 certbot 签发/续期纳入 prepare/serve gate,避免 prepare 阶段调用云 API; -未创建或连接新机、未恢复数据、未改 DNS / Firewall / 生产;真实空白机、 -FUSE/reboot 与完整 RPO/RTO 仍由 OPS-004 主演练验收。详细子项跟踪 OPS-004、OPS-007、OPS-008、 -OPS-010、OPS-011、OPS-012。 - -补充(执行切片,WAVE-20260722-01):临时 Linode `101125220` 从空白 Ubuntu 24.04 完成 prepare,恢复完整 -生产 SQLite 与最新 JuiceFS metadata。SQLite `integrity_check=ok`,`user=756`、非空密码哈希 `=756`、 -`report=13,483`、`report_category=684`,与生产只读对照一致,本次观测 RPO=0;从实例创建到 metadata -就绪约 39m08s。可信控制机用同一 metadata 从真实对象端只读抽取 10 文件、976,379 bytes,逐文件 SHA-256 -成功;演练机专用 Firewall 仅允许控制端 `/32` SSH,未改 DNS、未启动公网服务。为 VM 创建的单桶 -`read_only` 临时 Object Storage key 因执行环境禁止向第三方 VM 传递任何 credential 而未下发,随后立即 -撤销并确认无残留。因此 VM FUSE/reboot、证书和切流仍是明确未覆盖项,HA-006 保持 `in_progress`。 - -补充(自动化边界,WAVE-20260722-02):本次演练确认 ops 的 JuiceFS role 只管理 binary、systemd unit 与 mount -生命周期,不负责 metadata dump load 或恢复后 `juicefs config`。metadata load、FUSE 对象读取、reboot gate 是三个 -独立验收层,不能互相替代。当前 runbook 的人工 volume credential 恢复是 OPS-008 secret-delivery 信任链缺口; -未来应实现显式 `prepare → restore → reboot gate → serve`,且先单独验证目标机取得 SOPS secret 的机制被执行环境 -允许。完整过程与后续 AI 禁止重试项记录在私有 ops `docs/ops-004-rehearsal.md`。 - -### HA-007 — 验证备份新鲜度、保留策略与误删风险边界 - -- 状态:`assessing` -- 优先级:`P1` -- 波次:Wave 1 -- Drive AI:`unassigned` -- Review AI:`unassigned` -- 依赖:HA-006 -- 最后更新:2026-07-18 -- 结论置信度:`confirmed gap, solution assessing` - -问题与影响:当前 timer 成功只证明脚本返回 0;战报对象和 metadata 也没有独立、不可变、可回到误删前的副本。 - -证据:OPS-005、OPS-006;当前设计明确不做 restic 和第二 bucket,JuiceFS `TrashDays` 为 1。 - -方向与要点:先让监控校验最新可恢复点的年龄,并用定期隔离恢复证明副本可用。是否增加对象版本化、第二 bucket 或更长 trash 保留,只按真实误删风险、供应商能力和成本评估。 - -完成判断:SQLite 和 JuiceFS metadata 的新鲜度阈值明确且会告警;恢复演练能读取最新副本;用户已明确接受或收窄战报误删风险。 - -Review 关注:复制与备份边界、同账号/同区域故障域、告警被静默禁用、恢复验证污染生产。 - -执行证据:尚无;详细子项跟踪 OPS-005、OPS-006。 - -### HA-008 — 退役 LDAP,把身份真值纳入 SQLite 恢复链 - -- 状态:`done` -- 优先级:`P1` -- 波次:Wave 1 -- Drive AI:`Claude Code` -- Review AI:`unassigned` -- 依赖:HA-003 -- 最后更新:2026-07-21 -- 结论置信度:`confirmed` - -问题与影响(已解决):OpenLDAP 曾是没有独立异地 dump 的本机单点,登录、注册、改密和 session 用户加载都依赖它。若把它保留进 DR,会为计划退役的组件额外建设恢复链;若直接删除,又会让既有用户失去认证能力。 - -证据:生产 SQLite 已包含并对账 756 个账号及密码哈希;应用认证、注册、资料、改密和 session 用户加载只使用 SQLite,登录会把兼容 `{SSHA}` 惰性升级为 Argon2id;`slapd`、LDAP 包、配置、密钥和 recovery 前提已由 OPS-015 删除。当前生产发布锚点为 `d930c6a`。 - -实施路径(已完成): - -- 先建立可在生产执行和回退的 SQLite schema upgrade 路径,为 `User` 增加密码哈希与必要的版本字段。 -- 只读盘点 LDAP / SQLite 账号,保存加密 LDIF 与 SQLite 回滚点;导入 SSHA 哈希并逐项对账,不要求全员重设密码。 -- 应用改用 SQLite `User` 认证;登录成功时把 SSHA 惰性升级到 Argon2id。注册、资料和改密只写 SQLite。 -- 切换时允许旧 LDAP DN session 一次性失效,避免长期维护两套身份对象;登录/注册限流与 IMP-006 同波完成。 -- 初始密码恢复使用受控的 operator CLI,不把 SMTP 变成新的 DR 硬依赖;用户自助邮件找回作为后续可选增强。 -- 生产切换并验证无 LDAP 调用后 stop / mask 并删除 slapd,再由 OPS-015 删除包、配置、密钥和恢复前提;加密 LDIF 与迁移前 SQLite 快照按已记录的短期回滚窗口处理。 - -完成判断:LDAP 与 SQLite 账号全部对账且现有用户无需统一重设密码;注册、登录、改密、operator 密码重置、一次性 session 失效和 admin 权限有测试与迁移/回退记录;生产观察期无 LDAP 调用;slapd 停止后账号可随 SQLite/Litestream 恢复,OPS-015 已清除运行时与 runbook 残留。 - -Review 关注:既有 SQLite schema 的真实 upgrade / downgrade、LDAP 与 SQLite 孤儿账号、SSHA 格式兼容、哈希升级原子性、用户枚举、旧 session 失效、切换后密码变更导致的回退边界,以及是否仍有隐式 LDAP 调用。 - -执行证据(第一切片 = schema upgrade + 迁移工具,WAVE-20260721-02,2026-07-21,Claude Code): - -- 生产只读盘点:LDAP 756 个 `inetOrgPerson` 全部 `{SSHA}`、都有 `mail` 与 `createTimestamp`,无重复 cn/mail;SQLite `user` 727 行,全部能对上 LDAP,**29 个仅在 LDAP、0 个仅在 SQLite**,邮箱零冲突;生产 alembic head 为 `95c19a94006d`。 -- 落地(app 分支 `ha-008-sqlite-user-schema`,2 个 commit,tip `ab64762`):新增 alembic 迁移 `48a0963e9b77`,为 `user` 增补可空 `password` / `password_updated_at`,用 `batch_alter_table` 保证 SQLite 兼容;`User` 模型加同名列并从 Flask-Admin 排除(不展示/编辑哈希);新增 `ohmywod/ldif_import.py` 与 `flask import-ldap-users` CLI(解析 `slapcat -o ldif-wrap=no` 的 LDIF、base64 感知、按用户名 upsert、只补 `password` 不覆盖既有 display_name/email、`createTimestamp` 回填 `joined_at`、支持 `--force`/`--dry-run`、幂等)。本切片**不改认证行为**:登录仍走 LDAP。 -- 验证:本地 `pytest` 48 项通过(新增 9 项);在合成的产线形状库上 `alembic upgrade`/`downgrade` 往返成功,现有行保留、`password` 迁移后为空、版本号在 `95c19a94006d` ↔ `48a0963e9b77` 正确进退。 -- 生产落地(已执行,2026-07-21,Claude Code;runbook 在 ops 仓 `docs/ha-008-apply-slice1.md`):备份 SQLite 一致快照 + 导出并 age 加密 LDIF 回滚材料 → 外科式 `git checkout` 部署新代码(不重启,configs/密钥未变)→ `alembic upgrade head`(`user` 加两列、727 行、integrity ok)→ `supervisorctl restart web`(新代码上线,healthz 200、LDAP 登录正常、无回溯)→ `import-ldap-users` 回填。**dry-run 拦下一个 bug**:工具 strip 掉 cn 尾随空格,导致 3 个「用户名带尾空格」账号(LDAP DN 用 `\20` 转义)匹配不到已有 SQLite 行、会建重复行并撞 `email UNIQUE`;改为原样保留 cn(commit `ab64762`)后修正。最终回填 **total 756 / created 29 / password_filled 727 / 0 冲突**;库现为 **756 行全部有 `{SSHA}` 口令、0 NULL、0 重复用户名/邮箱、integrity ok**;重跑 dry-run 为 no-op(幂等)。 -- 第二切片 + IMP-006(**已生产切换并收束**,2026-07-21,见 WAVE-20260721-04..07):应用改用 SQLite `User` 认证——`ohmywod/security.py`(Argon2id + 兼容 LDAP `{SSHA}`)、登录成功惰性升级 `{SSHA}`→Argon2id、`load_user` 按主键取(旧 LDAP DN session 一次性失效)、注册/资料/改密只写 SQLite、operator `flask set-password` CLI;IMP-006 全部写端点限流也已上线。应用已合并 `main`,OPS-015 已删除 slapd、LDAP 配置与密钥,生产发布锚点后续推进到 `d930c6a`。用户于 2026-07-21 确认 HA-008 可视为结束且成功;按既定窗口处理的短期回滚材料不再阻塞本项完成。 - -### HA-009 — 通过 DR gate 后建立同区域温备 - -- 状态:`assessing` -- 优先级:`P1` -- 波次:Wave 2 -- Drive AI:`unassigned` -- Review AI:`unassigned` -- 依赖:HA-006、HA-007、HA-008 -- 最后更新:2026-07-18 -- 结论置信度:`direction confirmed, sizing pending` - -问题与影响:单机仍会受宿主机故障和维护窗口影响;但当前 1 GiB 主机已有 swap 压力,直接复制现有形状会把资源和恢复问题一起复制。 - -证据:生产内存约 961 MiB,2026-07-18 已使用 swap;没有私网 IPv4、standby inventory 或 Redis replica。旧占位脚本已删除,尚无可执行的温备实现。 - -方向与要点:先用临时新机验证 Ansible 与 DR,再决定长期主备规格。候选是同区域两台至少 2 GiB、不同宿主机、私网 VLAN;Redis store 单向复制,备机 cache 独立,应用常驻但不接公网流量。 - -完成判断:DR gate 全部通过;实例规格由实测内存和成本确认;主备配置一致,角色和唯一写入端明确,复制延迟与故障行为有记录。 - -Review 关注:同宿主机放置、私网认证、Redis 全量同步内存峰值、备机误接生产流量和持续费用。 - -执行证据:尚无。创建实例和持续成本必须由用户确认。 - -### HA-010 — 实现并演练切换、回切和日常蓝绿发布 - -- 状态:`todo` -- 优先级:`P1` -- 波次:Wave 2 -- Drive AI:`unassigned` -- Review AI:`unassigned` -- 依赖:HA-009 -- 最后更新:2026-07-18 -- 结论置信度:`recommended` - -问题与影响:没有实现并演练过的切换流程不能降低故障恢复时间;旧占位脚本已经删除,当前没有可执行的切换能力。 - -证据:OPS-014 已删除只会 `exit 1` 的 `scripts/switchover.sh`,避免形成能力假象;当前仍没有回切实现、真实 Cloudflare 切流记录或防脑裂 gate。 - -方向与要点:脚本先 fencing/确认旧主不可写,再提升 Redis、恢复 SQLite、重挂 JuiceFS、运行本机健康与业务冒烟,最后显式切 Cloudflare;回切走相同的重新同步和唯一主校验。日常发布复用同一流程。 - -完成判断:低峰真实切换和回切各成功一次;记录告警到人工确认、脚本执行、流量恢复和数据新鲜度;任一步失败能安全停住并回退。 - -Review 关注:旧主复活、Cloudflare API 部分成功、SQLite 写入窗口、Redis replication offset、DNS/证书和脚本重复执行。 - -执行证据:尚无。 - -## 5. 目标拓扑与成本 gate - -### 5.1 目标拓扑 - -```text - Cloudflare(唯一公开入口) - │ 人工确认后切源站 - ┌─────────┴─────────┐ - ▼ ▼(平时不接流量) - ┌───────────┐ ┌───────────┐ - │ active │ │ standby │ - │ nginx/web │ │ nginx/web │ - │ SQLite │ │ SQLite 恢复副本 │ - │ Redis meta │──────▶│ Redis replica │ - │ JuiceFS │ │ JuiceFS │ - └─────┬─────┘ └─────┬─────┘ - └────────┬──────────┘ - ▼ - Linode Object Storage - 战报块 + meta dump + Litestream +```bash +./scripts/dr-node build # 自动起一台装好数据、已自检通过的新机(不接公网) +./scripts/dr-node cutover # 一次确认,把生产切到新机 +./scripts/dr-node cleanup # 稳定观察后,单独删旧机 ``` -只有 active 可以处理业务写入。standby 提升为 active 前必须确认旧主不可写;旧主恢复后不能自动重新加入。 - -### 5.2 成本规则 - -- 月预算上限仍为 100 美元,但不是“有预算就必须花完”。 -- 当前 Object Storage 计费和实例价格在实施时从 Cloud Manager / [Akamai Cloud 官方价格页](https://www.akamai.com/cloud/pricing) 复核;价格与区域可能变化,不在本文把约 29 美元的旧估算当作真值。 -- HA-009 第一轮优先使用按小时计费的临时实例完成恢复和容量验证,再决定是否长期保留两台。 -- 2 GiB 是当前候选下限,不是永久规格。判断依据是 Redis 全量同步、JuiceFS cache、gunicorn 和恢复过程的峰值内存,而不是稳定态平均值。 -- 不再为旧 `/mnt/extra-report` 支付 Block Storage;若账单仍存在该卷,需在云端确认数据已释放后单独清理。 - -## 6. 明确不做 - -- 不做多活、SQLite 多主、Redis 双主或自动 failover。 -- 不为现有体量引入 Kubernetes、NodeBalancer、Consul、etcd、Vault/OpenBao 或独立 PostgreSQL 集群。 -- 不把 service active、timer success、Molecule green 或脚本存在写成“可恢复”证据。 -- 不在 app 公共仓库复制生产 IP、bucket、monitor ID、密钥 schema 明文或完整恢复命令。 -- 不在 HA-004 至 HA-008 未通过前购买并长期运行备机。 -- 不承诺尚未演练出的 20 分钟冷恢复或 1 分钟切换目标。 -- 不默认增加 restic 或第二 bucket;只有 HA-007 的风险/能力评估给出明确收益时再重看。 - -## 7. 待决策清单 - -以下问题按对应事项处理,不阻塞 Wave 0: - -1. HA-007:是否继续接受战报无独立防误删备份,以及 JuiceFS trash 保留多久。 -2. HA-009:实测后选择长期 2 GiB ×2,还是保留单机 + 临时恢复机模式。 -3. HA-009:是否需要供应商级 placement group 来明确主备分散到不同宿主机。 -4. HA-010:可接受的人工响应窗口,以及真实切换演练的低峰时间。 - -## 8. Changelog(append-only,旧 -> 新) - -> 每波改动在本节末尾追加。不得改写旧记录;旧版文档在引入 changelog 前的历史仍可从 Git 追溯。 - -### Changelog 条目模板 - -```markdown -### WAVE-YYYYMMDD-NN — 简短标题 - -- 日期:YYYY-MM-DD -- Drive AI:工具名称;未使用则写“无” -- Review AI:工具名称;尚未 review 则写 `unassigned` -- 关联事项:HA-NNN, HA-NNN -- 状态变化:例如 HA-004 `todo` -> `in_progress` -> `done` -- 改动:文件、基础设施或运行手册摘要 -- 关键取舍:选择和理由;无则写“无” -- 验证:检查、测试或演练摘要,不含密钥和个人信息 -- 发生的问题:无则写“无” -- 剩余风险:本波未解决的内容 -- 下一步:下一事项或需要用户决定的问题 -``` - -### WAVE-20260718-01 — 按当前实现重建 HA/DR 计划真值 - -- 日期:2026-07-18 -- Drive AI:Codex -- Review AI:`unassigned` -- 关联事项:创建 HA-001 至 HA-010 -- 状态变化:确认 HA-001 至 HA-003 `done`;新增 HA-004 至 HA-006、HA-008、HA-010 `todo`;新增 HA-007、HA-009 `assessing` -- 改动:以维护支持计划的 front matter、稳定 ID、固定状态、完成证据和 append-only changelog 结构重写本文;把旧“阶段 0 已解决 80% 风险”的说法改为当前能力边界;将 ops 审查的 P0 恢复问题放在温备之前 -- 关键取舍:保留 active-passive + 人工切换的目标架构,但不再把备机建设当作下一步;先完成安全冷恢复和 LDAP 退役 -- 验证:核对 app `0f52bf9`、ops `5988138`、生产 app `0512c83`;2026-07-18 通过 SSH 只读检查服务、依赖、挂载、WAL、Redis replication、timer、内存、公开 `/healthz` 和有效非密钥 Flask 配置;本地 `pytest` 39 项通过;未修改生产 -- 发生的问题:生产没有系统级 `sqlite3`,改用 app venv 的 Python 以 SQLite URI `mode=ro` 读取 journal mode 和 integrity;Supervisor 控制端口拒绝连接,因此不把 supervisor 子进程状态作为本轮证据 -- 剩余风险:恢复顺序和服务依赖仍是 P0;LDAP、无 Redis replica、备份新鲜度和冷恢复均未闭环 -- 下一步:从 HA-004 / OPS-001 开始,先把数据库存在性检查改为不创建文件的只读模式,再拆分恢复阶段 - -### WAVE-20260721-01 — 主线改为先退役 LDAP,再演练单机 DR - -- 日期:2026-07-21 -- Drive AI:Codex -- Review AI:`unassigned` -- 关联事项:HA-004、HA-005、HA-006、HA-008 -- 状态变化:HA-004 `todo` -> `done`;HA-005 `todo` -> `done`;HA-006 保持 `todo`;HA-008 保持 `todo` 并成为下一主任务 -- 改动:同步 ops 已完成的恢复闸门与服务依赖真值;把 Wave 1 重排为 HA-008 → HA-006 → HA-007;让 HA-006 显式依赖 HA-008;补充 SQLite schema upgrade、LDAP/SQLite 对账、SSHA 迁移与惰性升级、一次性 session 失效、operator 密码重置、stop/mask 后再删除 LDAP 的完整方向;将 ops 清理映射到 OPS-015 -- 关键取舍:先删除不必要的身份组件、缩小恢复边界,再花成本证明单机 DR;不为计划退役的 slapd 新建长期恢复链,也不把 SMTP 引入为首版密码恢复硬依赖 -- 验证:只读核对 app `8174971` 的 `User` 模型、登录/注册/profile、Flask-Login loader、LDAP mock 与依赖;核对 ops `61f84e8` 的 base/app roles、SOPS schema 和 OPS-001..003 执行证据;本波只更新计划,没有修改应用、生产或密钥 -- 发生的问题:HA 计划滞后于 ops 状态,HA-004 / HA-005 仍为 `todo`;本波按 ops 唯一状态真值修正 -- 剩余风险:尚未盘点生产 LDAP / SQLite 账号,也没有生产可执行的 schema upgrade 与回退证据;LDAP 仍是当前运行时依赖,不能直接停服务或删包 -- 下一步:执行 HA-008,第一切片先建立生产可回滚的 SQLite 用户 schema upgrade,并完成 LDAP / SQLite 账号只读盘点与迁移工具设计 - -### WAVE-20260721-02 — HA-008 第一切片:SQLite 用户 schema upgrade 与 LDIF 导入工具 - -- 日期:2026-07-21 -- Drive AI:Claude Code -- Review AI:`unassigned` -- 关联事项:HA-008 -- 状态变化:HA-008 `todo` -> `in_progress` -- 改动:app 分支 `ha-008-sqlite-user-schema`(未提交、未部署)新增 alembic 迁移 `48a0963e9b77`(`user` +`password`/+`password_updated_at`,可空、`batch_alter_table`);`User` 模型加同名列并排除出 Flask-Admin;新增 `ohmywod/ldif_import.py` + `flask import-ldap-users` CLI;`tests/test_ldif_import.py`(8 项) -- 关键取舍:本切片不改认证行为、不触碰生产;密码用单个自描述哈希列(`{SSHA}` 导入、后续登录时惰性升级 Argon2id),不额外加 scheme 列;导入按“无损”把 756 全量纳入(含 29 个 LDAP-only),只补 `password`、不覆盖用户已改的 display_name/email;LDIF 文件既是导入源也是加密回滚材料 -- 验证:本地 `pytest` 47 通过;迁移 upgrade/downgrade 往返在合成的产线形状库验证(行保留、版本正确进退);生产只读盘点(756/727/29)与本模块解析逻辑在生产 slapcat 上只读复跑(抽取 756 全带 `{SSHA}`) -- 发生的问题:生产无系统级 `sqlite3`,改用产线 app venv 的 python 以 `mode=ro` 读取;产线 `awk` 为 mawk 不支持 gawk `match(,,arr)`,改用 python 做盘点;对生产只写过一个随即删除的 `/tmp` 只读校验脚本,未改任何数据 -- 剩余风险:生产 schema upgrade 与回填尚未执行;认证仍走 LDAP;旧 session 失效、Argon2id 惰性升级、operator 密码重置、登录/注册限流属后续切片 -- 下一步:用户授权后按 runbook 在生产执行(备份 → 加密 LDIF → `alembic upgrade` → 部署带列的 app → `import-ldap-users`);随后进入 HA-008 第二切片 - -### WAVE-20260721-03 — HA-008 第一切片生产落地:迁移 + 回填 756 账号口令 - -- 日期:2026-07-21 -- Drive AI:Claude Code -- Review AI:`unassigned` -- 关联事项:HA-008;ops 仓 runbook `docs/ha-008-apply-slice1.md` -- 状态变化:HA-008 保持 `in_progress`(第一切片已上生产,认证仍待第二切片切换) -- 改动:生产执行第一切片。分支加 fix commit `ab64762`(cn 原样保留、不 strip)。生产:SQLite 一致快照 + age 加密 LDIF 回滚材料;外科式 `git checkout` 部署(未走 app role、未 bump `app_ref`,避免重渲染 sops 密钥配置——configs/密钥/gen.py 输入均未变);`alembic upgrade head` 加两列;`supervisorctl restart web`;`import-ldap-users` 回填 -- 关键取舍:部署用手动 checkout 而非 ansible app role,把 code-only 变更的爆炸半径压到最小;回填按无损纳入全部 756(含 29 个 LDAP-only);先 dry-run 对账再真写 -- 验证:迁移后 727 行、两列就位、integrity ok;回填 total 756 / created 29 / password_filled 727 / 0 email 冲突;终态 756 行全 `{SSHA}`、0 NULL、0 重复;healthz 200、登录页 200、web 无回溯;重跑 dry-run no-op(幂等) -- 发生的问题:dry-run 报 created 32 而盘点为 29 → 定位为工具 strip 掉 cn 尾随空格(3 个账号 LDAP/SQLite 用户名都带尾空格、DN 用 `\20` 转义),会建重复行并撞 `email UNIQUE`;改为原样匹配后修正,dry-run 提前拦下、零脏写。GPG 签名无法从助手上下文完成,fix commit 由用户在终端签名提交 -- 剩余风险:认证仍走 LDAP(第二切片切换前 SQLite 口令列只写不读);生产以分支 checkout 部署,`app_ref` 未 bump,合并 `main` 后需同步;加密 LDIF 与迁移前快照为短期回滚材料,待第二切片观察通过后按窗口销毁 -- 下一步:HA-008 第二切片——SQLite 认证 + 惰性 Argon2id + 一次性 session 失效 + operator 密码重置 + IMP-006 登录/注册限流;通过观察 gate 后交 OPS-015 stop/mask 并清理 slapd - -### WAVE-20260721-04 — HA-008 第二切片实现:应用认证从 LDAP 切到 SQLite(未部署) - -- 日期:2026-07-21 -- Drive AI:Claude Code -- Review AI:`unassigned` -- 关联事项:HA-008;应用侧防滥用 IMP-006 -- 状态变化:HA-008 保持 `in_progress`(第二切片代码完成,尚未上生产) -- 改动(app 分支 `ha-008-sqlite-user-schema`,在第一切片之上叠加,未提交):新增 `ohmywod/security.py`(Argon2id 哈希 + 兼容 LDAP `{SSHA}` 校验 + `needs_rehash`);`controllers/user.py` 重写为 SQLite-only(`authenticate` 成功即把 `{SSHA}` 惰性升级为 Argon2id、`save`/`set_password`/`update_user` 写 Argon2id);`views/frontend.py` 用自有 `LoginForm` 取代 `LDAPLoginForm`,注册/资料/改密只走 SQLite;`app.py` `load_user` 按主键取(旧 LDAP DN session 一次性失效)、移除 `ldap_manager`/`save_user`、新增 operator `flask set-password`;`models/user.py` 删除 `LDAPUser`;`extensions.py` 删 `ldap_manager`;`ldap_mock.py`→`mocks.py`(仅 redis mock);`requirements.txt` 去掉 `flask-ldap3-login`/`ldap3`、加 `argon2-cffi`;`config.py` 删 LDAP 配置块 -- 关键取舍:单个自描述哈希列(`{SSHA}` 惰性升级、不强制改密);`get_id` 用主键实现一次性 session 失效(切换后所有在线用户需重新登录);用户名登录大小写不敏感(对齐 LDAP `caseIgnoreMatch`);operator 密码找回用 CLI,不把 SMTP 引入为 DR 硬依赖 -- 验证:本地 `pytest` **53 通过**(新增 `tests/test_auth.py` 7 项:惰性升级、session 失效、注册→登录、改密、旧密码校验、operator CLI、口令不明文);`create_app()` 干净构建;全仓无功能性 LDAP 引用(仅注释与 `import-ldap-users` 工具名) -- 发生的问题:`LDAPUser` 曾提供 `reader_theme`/`app_theme`/`theme_css`,核查确认模板未使用(死代码),`User` 无需复刻 -- 剩余风险:尚未做 IMP-006 登录/注册限流(切换验收 gate);未部署、未观察;旧 session 一次性失效对在线用户的影响需在切换公告/低峰执行;`flask-ldap3-login`/`ldap3` 仅从 requirements 移除,生产 venv 仍装有(OPS-015 清理) -- 下一步:补 IMP-006 登录/注册限流 → 出第二切片切换 runbook(部署、观察无 LDAP 调用、确认 slapd 可停)→ 用户授权后生产切换 +正常路径不需要 SSH 进新机、编辑 inventory、开云控制台或逐条跑 Ansible。排错时才看脚本生成的临时 inventory 和日志。 -### WAVE-20260721-05 — HA-008 第二切片 + IMP-006 生产切换(认证已切到 SQLite) +## 4. `build`:自动起一台可接管的新机 -- 日期:2026-07-21 -- Drive AI:Claude Code -- Review AI:`unassigned` -- 关联事项:HA-008、IMP-006;后续 OPS-015 -- 状态变化:HA-008 保持 `in_progress`(认证已切换并即时验证通过;观察期收尾 + OPS-015 删 slapd 后才可判 `done`) -- 改动:生产按 ops runbook `docs/ha-008-apply-slice2.md` 执行第二切片切换。venv 装 `argon2-cffi`+`Flask-Limiter`;外科式 checkout 到 tip `d6671a1`(含 slice-2 + IMP-006);`supervisorctl restart web` 完成认证从 LDAP→SQLite 的切换。未改 slapd。 -- 关键取舍:沿用外科式 checkout(不走 app role、不 bump `app_ref`)把 code-only 切换爆炸半径压到最小;限流存储复用运行中的 redis-cache 7379、fail-open -- 验证:切换前基线 756 行全 `{ssha}`、0 argon、redis-cache 活;新代码在生产配置下 `create_app()` 通过后再重启;重启后 4 worker 正常、无回溯、healthz 200、登录页 200;生产运行时用自清理临时账号验证 **SQLite 认证成功 + `{SSHA}`→Argon2id 惰性升级 + 错误密码拒绝**;`/login` 端到端(生产 DB + test client)正确密码跳 `/r/`、错误密码留登录并报错;`ss` 查**无到 :389 的连接** -- 发生的问题:用 urllib 手工复刻浏览器 CSRF+session 登录时得到 400(CSRF harness 假象,非回归——登录切换前后都需 CSRF),改用 test_client 对生产 DB 验证端点逻辑 -- 剩余风险:观察期(真实用户重新登录、`{ssha}`→argon 迁移趋势、稳定性)尚未走完;slapd 仍在跑(未停);旧 session 一次性失效已对在线用户生效;分支 checkout、`app_ref` 未 bump;短期回滚材料(`pre-ha008s2-20260721T162027Z.sqlite`、slice-1 快照与 age LDIF)待观察通过后按窗口销毁 -- 下一步:观察期确认无 LDAP 调用与认证稳定 → 交 OPS-015 stop/mask 并删除 slapd/LDAP 配置/密钥 → 合并 `main` 并 bump `app_ref` +一条命令顺序完成,任何一步失败都不碰生产 DNS / 旧机,只删掉本次新建的机器(`--keep-on-failure` 可保留现场): -### WAVE-20260721-06 — OPS-015:LDAP 从运行时与 ops 代码彻底退役 +1. 用 sops 里的 Linode token 建一台 Ubuntu 24.04,注入 SSH key、绑 Cloud Firewall,等 SSH 通。 +2. 生成临时 inventory,`site.yml -e deploy_phase=prepare`:装好全部服务但都不启动(OPS-001 闸门,数据没恢复不上线)。 +3. 恢复数据:`litestream restore` 拉回 SQLite;找对象桶里最新 metadata dump 做 `juicefs load`,由 Ansible `no_log` + 补回对象凭据,挂 `/mnt/jfs`。 +4. 启动服务用于自检,但**不启动 Litestream 和监控上报**(避免与旧机争同一备份桶)。 +5. **必要验证只有一项**:把域名临时解析到新机 IP(`curl --resolve`),`/healthz` 与一个真实战报页都返回 200。 + `/healthz` 本身已检查 SQLite、Redis、`/mnt/jfs`,战报页又实际读了一次对象存储——这两个绿就够。 +6. 打印新机 IP、SQLite 恢复点,和下一条 `cutover` 命令。 -- 日期:2026-07-21 -- Drive AI:Claude Code -- Review AI:`unassigned` -- 关联事项:HA-008、OPS-015 -- 状态变化:OPS-015 `todo` -> `done`;HA-008 保持 `in_progress`(仅剩观察窗口收尾与回滚材料销毁) -- 改动:切换验证通过后退役 LDAP。生产 `stop`+`mask` slapd → 停机下复验 `/login`/healthz/无 :389 → `apt purge slapd ldap-utils`(配置/数据/`/var/lib/ldap` 全移除)→ purge 后再复验登录。ops 代码(`ohmywod-ops`,未提交):base 去 `slapd`/`ldap-utils`、app assert 去 `ldap_bind_user_password`、app 模板删 LDAP 块、secrets example 去 LDAP 键、加密 `secrets.sops.yaml` 移除 LDAP bind 密钥、`recovery.md` 删 LDIF/olcRootPW 坑;`app_ref` bump 到 `c91c7f3` 并对齐生产 checkout。详见 ops `OPS-015` 执行证据。 -- 关键取舍:先 stop/mask 验证零依赖再 purge;保留加密 LDIF + SQLite 快照作短期回滚窗口,不立即销毁 -- 验证:slapd 停止与 purge 后 `/login` 端到端仍成功、healthz 200、无 :389;`slapd`/`slapcat`/`ldapsearch` 已不存在;sops 解密确认仅 `ldap_bind_user_password` 被移除、其余 12 键完好 -- 剩余风险:观察窗口未走满(真实用户重新登录趋势、稳定性);回滚材料尚未销毁;IMP-003 的 `SQLALCHEMY_ENGINE_OPTIONS` 生产收敛仍待做 -- 下一步:观察窗口通过后销毁回滚材料(加密 LDIF + 两个快照)→ 视情况把 HA-008 判 `done`;单独推进 IMP-003 与后续 DR(HA-006) +## 5. `cutover`:一次确认,四个动作 -### WAVE-20260721-07 — HA-008 验收完成,主线转入单机 DR 演练 +先显示旧机、新机、恢复点,只问一次 `Replace with ? [yes/no]`。确认后: -- 日期:2026-07-21 -- Drive AI:Codex -- Review AI:`unassigned`;最终验收由用户确认 -- 关联事项:HA-006、HA-008;ops OPS-004、OPS-007、OPS-008、OPS-015 -- 状态变化:HA-008 `in_progress` -> `done`;HA-006 保持 `todo`,身份前置依赖已全部满足 -- 改动:按用户“HA-008 可认为已结束且成功”的最终确认关闭 HA-008;把当前结论、生产现状、风险、Wave 1 顺序和 HA-006 依赖同步到 LDAP 已退役、生产发布锚点为 `d930c6a` 的真值;明确后续主线为 HA-006 / OPS-004,并行收束 OPS-007 / OPS-008 -- 关键取舍:已记录保留与销毁窗口的短期 LDIF / SQLite 回滚材料作为运维收尾继续按窗口处理,不再阻塞 HA-008 完成;HA-009 仍必须等待 HA-006、HA-007 的 DR gate,不因身份迁移成功而提前启动温备 -- 验证:复用 WAVE-20260721-02..06 的迁移、账号对账、SQLite 认证、Argon2id 惰性升级、生产登录、无 389 连接、slapd purge、配置与密钥清理证据;用户确认观察结果可接受并验收成功 -- 发生的问题:计划顶部、HA-008 现状和依赖说明仍停留在 LDAP 未退役阶段,本波一并纠正 -- 剩余风险:尚无真实空白机端到端恢复证据;OPS-007 / OPS-008 的新机接入和信任链仍未闭环;备份新鲜度、误删边界与 RPO/RTO 仍待 HA-006 / HA-007 -- 下一步:启动 HA-006 / OPS-004 的演练准备,并行推进 OPS-007 → OPS-008;三者汇合后在隔离空白机执行完整恢复演练 +1. **停旧机写者**:停旧机 nginx、web、Litestream(不等在途请求)。旧机已死则跳过。 +2. **追平一次**:旧机可达时,在新机再跑一次 `litestream restore` 补上最后几秒,然后在新机启动 Litestream + (此刻它是唯一写者)。旧机已死则沿用 `build` 的恢复点。 +3. **改入口**:把 Cloudflare A 记录从旧机 IP 改到新机,确认 Cloud Firewall 已绑新机。 +4. **看结果**:走公网请求 `/healthz` 和一个战报页,确认流量已在新机。 -### WAVE-20260721-08 — HA-006 / OPS-004 隔离演练准备启动 +不自动回滚、不合并数据、不自动删旧机。任一步失败就停下、保留现场,由你决定修新机还是回退旧机(把 A 记录改回去)。 -- 日期:2026-07-21 -- Drive AI:Codex -- Review AI:`unassigned` -- 关联事项:HA-006;ops OPS-004、OPS-007、OPS-008 -- 状态变化:HA-006 `todo` -> `in_progress` -- 改动:在 ops 仓加入仓库外 recovery inventory 模板、只读预检脚本、演练依赖图、授权 gate 与证据模板;恢复命令改为显式 inventory 和单目标 limit,脚本硬拒绝当前生产 IP;certbot 签发与续期 timer 纳入 deploy phase,prepare 不触云 API -- 关键取舍:先完成无成本、无外部写入的安全准备;OPS-007 / OPS-008 独立准备可并行,但真实证书、网络、SSH 与数据恢复在同一台隔离新机汇合。临时实例创建、生产 DNS/Firewall 变更和实例销毁都不由本状态变化自动授权 -- 验证:ops 脚本 shell syntax、Ansible syntax 与 diff check 通过;fixture 验证占位、生产 IP、多目标均 fail closed,TEST-NET 单目标通过并只输出后续命令;未连接生产或新机 -- 剩余风险:完整控制节点信任链、临时实例规格/成本、新机 SSH/Firewall/证书、真实 SQLite/JuiceFS 恢复、FUSE/reboot 与 RPO/RTO 尚未闭环 -- 下一步:获得临时 VM 创建授权后,在隔离 Ubuntu 24.04 新机运行 preflight → prepare dry-run/apply → restore/verify;生产切流继续留在 OPS-007 / HA-010 的独立授权 gate +## 6. 实现顺序 -### WAVE-20260722-01 — HA-006 真实恢复到 metadata/object 验证 +- **DR-1(下一步)**:实现 `dr-node build`,复用现有 roles / Litestream / JuiceFS,删掉正常路径里的人工 inventory、 + age 凭据仪式和只读 drill mount。完成标准:一条命令从空白云资源得到自检通过、不接公网的新机。 +- **DR-2**:实现 `dr-node cutover`(上面四步)+ `cleanup` 独立删机。完成标准:除一次 `yes` 外不需 dashboard / SSH。 +- **DR-3**:用临时 Linode 真跑一次 `build`,低峰真跑一次 `cutover`,记录实际耗时和恢复点,只修挡住正常路径的问题。 -- 日期:2026-07-22 -- Drive AI:Codex -- Review AI:`unassigned` -- 关联事项:HA-006;ops OPS-004、OPS-007、OPS-008 -- 状态变化:HA-006 保持 `in_progress` -- 改动:创建并 prepare 临时 Linode `101125220`;恢复完整生产 SQLite 与 JuiceFS metadata;新建并绑定只允许当前控制端 `/32` SSH 的专用 Firewall。真实空白镜像暴露的 apt check-mode、app clone 产物、deb postinst 自动启动、SOPS vars 插件工作目录、runtime dirs 和 monitoring prepare gate 问题均在 ops role/site 中修正。 -- 关键取舍:旧主保持唯一 writer,不改 DNS、不进入 serve;长期生产对象密钥不写入临时第三方 VM。曾创建单桶 `read_only` 临时 key,但执行环境仍禁止 credential 传递,故未下发并立即撤销。 -- 验证:SQLite 完整性与关键表对照通过,本次观测 RPO=0;JuiceFS metadata load 得 311,775 keys,可信控制机从真实对象端抽取 10 文件、976,379 bytes,哈希全部成功;prepare 二次 check `changed=0`,演练机仅 SSH 监听;ops syntax 与 hermetic recovery gate 测试通过。 -- 发生的问题:真实空白镜像揭示多处只在容器中未触发的 prepare 缺口,均已回修;Object Storage key 创建成功但不能传到第三方 VM,key ID 与本地响应均已清理。 -- 剩余风险:第三方 VM 真实 FUSE/reboot 启动 gate、证书、DNS、测试入口与切换/回退未验证;临时实例仍运行并计费;HA-006 不标 done。 -- 下一步:两仓证据与 ops 修正已提交并推送(app `161cc80`、ops `3de16e6`);随后由用户决定销毁临时实例,或在其自行执行 credential 注入与 FUSE/reboot 后补交证据。 +## 7. 现有可复用基础 -### WAVE-20260722-02 — 记录 JuiceFS 恢复自动化与 secret-delivery 断点 +Ansible 能把空白 Ubuntu 收敛到运行态;SQLite WAL + Litestream 在跑;JuiceFS metadata 已能从对象桶 load、 +对象端真实文件已读到;2026-07-22 已在临时 Linode 恢复出与生产一致的 SQLite;Cloudflare / Linode / 对象存储 / +应用密钥都有 sops 真值;nginx 已受 JuiceFS 挂载闸门保护。 -- 日期:2026-07-22 -- Drive AI:Codex -- Review AI:`unassigned` -- 关联事项:HA-006;ops OPS-004、OPS-008 -- 状态变化:HA-006 保持 `in_progress` -- 改动:把本次 drill 的关键结论同步到跨仓计划:Ansible 现只控制 JuiceFS mount 服务,不控制 dump load/volume credential;明确 metadata load、FUSE 文件读取、reboot gate 三层证据;未来拓扑调整为 `prepare → restore → reboot gate → serve`,secret delivery 归 OPS-008 独立闭环。 -- 关键取舍:不把 `no_log` 或“改写为 Ansible task”视为 tenant/runtime credential policy 的自动解法;目标机独立 age recipient 是推荐方向,但仍须先验证策略允许。 -- 验证:与 ops role/template 和 2026-07-22 真机状态交叉核对:drill node 有恢复后的 Redis metadata,但无 `/mnt/jfs` mount;可信控制机 10 文件抽样不冒充 VM FUSE/reboot 证据。 -- 发生的问题:现有高层计划过去把“Ansible 管理 juicefs.service”与“JuiceFS volume 可从零恢复”靠得太近,容易让后续 AI 误读;本波拆开说明。 -- 剩余风险:restore playbook、目标机 secret delivery、只读 drill mount 与 reboot gate 均未实现;临时 Linode 仍运行并计费。 -- 下一步:以 OPS-008 secret-delivery trust chain 为前置,之后实现并演练 restore phase;HA-006 在 VM FUSE/reboot 证据前不标 done。 +旧的 HA-001..010、OPS 波次、age secret-delivery 与演练细节留在 Git 历史与私有 ops 文档中,作为调查记录,不是当前待办。 diff --git a/docs/improvement-plan-2026-07.md b/docs/improvement-plan-2026-07.md index 763d77d..6caeee6 100644 --- a/docs/improvement-plan-2026-07.md +++ b/docs/improvement-plan-2026-07.md @@ -5,7 +5,7 @@ document_status: active source_of_truth_for: "application improvement direction, work item status, and wave changelog" language: zh-CN created_at: "2026-07-05" -last_updated: "2026-07-21" +last_updated: "2026-07-22" review_commit: "app c0ad327; ops 31105fe; production app d930c6a" review_worktree: "clean before this documentation wave" next_item_id: "IMP-010" @@ -13,7 +13,7 @@ next_item_id: "IMP-010" # 站点改进计划(2026-07) -> 本文是 ohmywod 应用安全、正确性、可发现性、性能和工程质量的工作计划。维护成本支持与 AdSense 去留以 [站点维护成本支持计划](maintenance-support-plan.md) 为准;备份、恢复、存储和温备以 [高可用与灾难恢复计划](ha-plan.md) 为准。 +> 本文是 ohmywod 应用安全、正确性、可发现性、性能和工程质量的工作计划。维护成本支持与 AdSense 去留以 [站点维护成本支持计划](maintenance-support-plan.md) 为准;备份、恢复和节点替换以 [单机 DR 与节点替换计划](ha-plan.md) 为准。 > > **当前结论:IMP-001 至 IMP-009 已全部完成。应用安全、正确性、SEO、健康检查、SQLite 锁等待、防滥用和最小 CI 都已有实现与验证;生产已启用共享 Redis sitemap cache,并为战报 HTML 增加安全的条件请求。搜索在 11,515 条公开战报下实测 p95 约 11 ms,因此继续使用简单 LIKE,不引入 FTS;后续只在达到已记录阈值时重新评估性能设计。跨仓主线继续由 HA/DR 计划维护。** @@ -35,7 +35,7 @@ next_item_id: "IMP-010" | SQLite 配套 | 已完成 | 生产数据库为 WAL 且 integrity check 为 `ok`;生产有效 `SQLALCHEMY_ENGINE_OPTIONS` 已为 `{'connect_args': {'timeout': 5}}`,5 秒锁等待生效(IMP-003,ops `30b4848`,2026-07-21 部署并只读复核) | | SEO | 已完成 | report meta/OG、页面 title、`sitemap.xml` 和 robots 引用已落地并有测试;sitemap 使用共享 Redis cache,内容变更会主动失效 | | 运维探针 | 已完成 | `/healthz` 检查 SQLite、Redis 和 `/mnt/jfs/reports`;生产经 Cloudflare 返回 200 | -| 存储语义 | 已收敛到 HA-002 | app 只把 `/mnt/jfs/reports` 视为战报持久层;本地只保留上传 staging | +| 存储语义 | 已完成 | app 只把 `/mnt/jfs/reports` 视为战报持久层;本地只保留上传 staging | | 防滥用 | 已完成 | 登录/注册/反馈/互动写端点均由 Flask-Limiter 限流(`client_ip_key` + redis-cache + fail-open)并已上线;注册有蜜罐(IMP-006,生产 `c8fc8cd`) | | CI | 已完成 | GitHub Actions 在 PR、main push 和手工触发时用 Python 3.12 执行 changed-lines whitespace check 与完整 pytest;首次两次 main run 均成功 | | 应用与战报缓存 | 已完成 | 生产 `CACHE_TYPE=RedisCache`;sitemap 首次生成 0.776 s、命中 0.084 s;`report_raw` 使用 weak ETag + `private, no-cache`,304 保留 CSP sandbox | @@ -46,10 +46,10 @@ next_item_id: "IMP-010" 1. 先修可被直接利用的安全问题和会破坏数据正确性的 bug,再做性能与代码整洁。 2. 用户上传 HTML 必须与主站身份隔离;任何兼容修复都不能重新加入 `allow-same-origin`。 3. 防滥用优先使用一个统一的限流设施,不为 login、register、feedback 各引入一套机制。 -4. `CF-Connecting-IP` 只有在源站入口被 Cloudflare/防火墙约束时才可作为可信限流 key;该边界由 HA/ops 计划维护。 +4. `CF-Connecting-IP` 只有在源站入口被 Cloudflare/防火墙约束时才可作为可信限流 key;该边界由单机 DR/ops 配置维护。 5. 搜索、缓存和 FTS 先用生产证据证明瓶颈,再增加索引、缓存失效或迁移复杂度。 6. `done` 必须有测试或可复核行为;“代码看起来改了”不算完成。 -7. 不在本文重复追踪 AdSense、爱发电、LDAP 退役、Litestream、JuiceFS 或温备。 +7. 不在本文重复追踪 AdSense、爱发电、LDAP 退役、Litestream、JuiceFS 或节点替换。 ### 1.4 成功判断 @@ -57,7 +57,7 @@ next_item_id: "IMP-010" - 公开写端点有与站点体量匹配、不会误伤正常用户的防滥用保护。 - 每个 PR/主分支变更能自动运行核心测试;失败不会被静默忽略。 - 性能事项只有在记录基线和触发阈值后才实施,避免长期维护无收益的复杂度。 -- 本文、维护支持计划和 HA/DR 计划之间没有重复状态真值。 +- 本文、维护支持计划和单机 DR 计划之间没有重复状态真值。 ## 2. 这份计划怎么维护 @@ -349,10 +349,10 @@ Review 关注:FTS tokenization 对中文内容的实际价值、索引与软 | 主题 | 唯一状态真值 | 本文处理方式 | |---|---|---| | AdSense 文案、脚本和支持入口 | `maintenance-support-plan.md` SUP-001 至 SUP-006 | 不重复建 IMP;线上 2026-07-18 仍存在,按 SUP 推进 | -| 存储归一化 | `ha-plan.md` HA-002 | 仅在 IMP-005 / IMP-008 记录应用依赖 | -| WAL、Litestream 和恢复 | `ha-plan.md` HA-003 至 HA-007 | IMP-003 只负责应用锁等待及配置同步 | -| LDAP 退役、SQLite 密码和 session | `ha-plan.md` HA-008;ops 清理由 `ops-review-plan.md` OPS-015 跟踪 | IMP-006 只维护限流状态;登录/注册切片与 HA-008 同波,不重复认证迁移状态 | -| 温备和切换 | `ha-plan.md` HA-009、HA-010 | `/healthz` 作为可复用探针 | +| 存储归一化 | 已完成;历史证据在 `ha-plan.md` Git 历史 | 仅在 IMP-005 / IMP-008 记录应用依赖 | +| WAL、Litestream 和恢复 | `ha-plan.md` 单机 DR 主线 | IMP-003 只负责应用锁等待及配置同步 | +| LDAP 退役、SQLite 密码和 session | 已完成;ops 清理由 `ops-review-plan.md` OPS-015 跟踪 | IMP-006 只维护限流状态,不重复认证迁移状态 | +| 节点替换 | `ha-plan.md` DR-1 至 DR-3 | `/healthz` 作为候选机与切换后的复用探针 | ## 6. 明确不做 @@ -362,7 +362,7 @@ Review 关注:FTS tokenization 对中文内容的实际价值、索引与软 - 不用 CAPTCHA 作为防滥用第一步;轻量限流和蜜罐不足时再评估。 - 不通过移除 CSP sandbox 来修复旧战报兼容问题。 - 不把备份新鲜度、云 API 检查等慢操作放进同步 `/healthz`。 -- 不在本计划重复维护 AdSense、爱发电、恢复 runbook 或 HA 拓扑状态。 +- 不在本计划重复维护 AdSense、爱发电、恢复 runbook 或节点替换状态。 ## 7. Changelog(append-only,旧 -> 新) @@ -468,4 +468,15 @@ Review 关注:FTS tokenization 对中文内容的实际价值、索引与软 - 验证:改动前生产基线:11,515 条公开 report、522 category;sitemap 1.17 MiB,p50 929 ms / 最大 1,451 ms;LIKE count+page 23 样本 p50 6.97 ms / p95 11.03 ms / 最大 12.86 ms;raw 40 样本读取 p50 18.4 ms / p95 62.9 ms。本地 `pytest` 67 项全过、coverage 83%、`git diff --check` 与 compileall 通过;ops `site.yml --syntax-check` 通过。部署 `--tags app` 仅 checkout、渲染 local config、reload web 三处 changed,`failed=0`。部署后生产 app=`d930c6a`、有效 `CACHE_TYPE=RedisCache`;sitemap 冷请求 0.776 s、热请求 0.084 s、TTL=3600;raw 200→304/0 bytes 且 ETag/Cache-Control/CSP 正确;healthz/search 200。GitHub Actions run 29889859526、29890051929 均成功。 - 发生的问题:首次用按 head SHA 的公开 Actions 查询过早返回 0,改查 workflow 自身 runs 后确认首个实现提交的 run 已成功,第二次 main push 同样成功;不影响 CI。 - 剩余风险:Redis cache 与 limiter 共用 7379/db0,但 key prefix 隔离且 cache fail-open;report/category 经 Flask-Admin 直接修改时不走 controller invalidation,最坏保留到 sitemap 1 小时 TTL,自愿接受。FTS 未实施是有基线的明确决定,不代表永不实施。 -- 下一步:应用计划 IMP-001 至 IMP-009 已全部完成;后续主线回到 HA-008 收尾与 HA-006 / OPS-004 单机 DR,应用侧仅在新证据或新事项出现时新增 IMP ID。 +- 下一步:应用计划 IMP-001 至 IMP-009 已全部完成;后续主线转为 `ha-plan.md` DR-1 单命令 build,应用侧仅在新证据或新事项出现时新增 IMP ID。 + +### WAVE-20260722-01 — 对齐精简后的单机 DR 主线 + +- 日期:2026-07-22 +- Drive AI:Codex +- Review AI:`unassigned` +- 关联事项:跨计划 DR-1 至 DR-3 +- 状态变化:IMP-001 至 IMP-009 均不变 +- 改动:把跨计划引用从已停止的 HA 编号、温备和复杂演练主线,改为单机 `build → cutover → cleanup` 节点替换主线 +- 验证:应用计划不再把 HA-006、HA-009、HA-010 或温备列为后续方向 +- 下一步:应用计划保持关闭;在 ops 仓实现 DR-1