From 4e909b2118ae836c50eb675f3ad5c11ab5f35bea Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 30 Jun 2026 19:00:02 +0800 Subject: [PATCH] fix(recovery): capture reboot lessons and gitea backup coverage --- docs/LOGBOOK.md | 23 +++++++ .../runbooks/REBOOT-POST-START-QUICK-CHECK.md | 63 +++++++++++++++++-- ...oot-cold-start-backup-recovery-workplan.md | 22 +++++++ ops/monitoring/alerts-unified.yml | 20 +++++- .../ops/backup-health-textfile-exporter.py | 42 +++++++++++++ .../test_reboot_p0_operational_contract.py | 10 +++ 6 files changed, 174 insertions(+), 6 deletions(-) diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 01ed3f86..3eed6f5c 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -50328,3 +50328,26 @@ production browser smoke: **仍需驗證**: - 跑 focused API tests、py_compile、ruff、Gitea runner pressure guard、Gitea secret step guard 與 `git diff --check`。 - 推送後等待 deploy marker,再讀回 priority endpoint 與新 preflight endpoint,確認 production 顯示 recovery preflight 而非 manual/default-human 終局。 + +## 2026-06-30 — 19:15 全主機重啟後版本 / freshness / Gitea 備份完整性 readback + +**完成內容**: +- 重新執行 full-stack cold-start readback:`PASS=64 WARN=6 BLOCKED=6`。目前不可宣稱全服務已在 10 分鐘內恢復;主要 blocker 包含 110 registry / SSH、K3s registry pull refused、Harbor / Registry / SigNoz public 502/TLS。 +- 驗證 AWOOOI production 版本不是最新:source / Gitea main 為 `adc0d8816`,production runtime readback 仍為 `7890778b83`,且新 Stock recovery preflight endpoint 在 production 回 404。 +- 修復 Tsenyang public 502:188 current release `/home/ollama/tsenyang-website-releases/b369ed8` 缺 runtime image / container;以現有 release bounded build `tsenyang-website:latest` 並 `docker compose up -d --no-build`,local `127.0.0.1:3000`、`www.tsenyang.com`、`tsenyang.com` 均回 200。 +- 驗證 StockPlatform 不是資料最新:public `/healthz` / `/api/healthz` 為 200,但 `/api/v1/system/freshness` 與 `/api/v1/system/ingestion` 回 `status=not_configured`、blocker `postgres_not_ready`。 +- 驗證 Gitea「repo 消失」需分層判讀:Gitea version API 回 `1.25.5`,public repo search 僅列 4 個 public repos;`stockplatform-v2` public page/API 回 404,但 internal `git ls-remote http://192.168.0.110:3001/wooo/stockplatform-v2.git` 成功,表示不能直接宣稱 Git objects 全消失。 +- 發現重大備份缺口:188 `/home/ollama/backup/110/gitea` 為空;既有 `backup_from_110` freshness 只證明 rsync 心跳,不證明 Gitea 子樹 / dump / repo count 完整。 +- 更新 `REBOOT-POST-START-QUICK-CHECK.md` v1.19 與 reboot workplan,把本次事故修復順序、Tsenyang 502 經驗、Stock `postgres_not_ready`、Gitea 分層查核、Gitea backup completeness 缺口沉澱為正式 SOP。 +- 補 `backup-health-textfile-exporter.py`:188 新增 `gitea_repo_mirror_from_110` subtree freshness / sample count 與 service coverage domain metric。 +- 已將新版 exporter 受控套用到 188 `/home/ollama/scripts/backup-health-textfile-exporter.py` 並刷新 `/home/ollama/node_exporter_textfiles/backup_health.prom`;live metric 顯示 `gitea_repo_mirror_from_110` timestamp `0`、snapshot_count `0`、`awoooi_backup_coverage_domain_fresh{host="188",domain="service"} 0`。 +- 補 `alerts-unified.yml`:新增 188 backup coverage metric missing alert,並讓 `BackupCoverageDomainStale` 對所有 host 的 coverage domain 生效。 + +**仍維持**: +- 沒有讀 secret / token / `.env` / raw sessions / SQLite / auth。 +- 沒有使用 GitHub / gh / GitHub API / GitHub Actions。 +- 沒有重啟主機,沒有 Docker daemon / Nginx / K3s / DB restart,沒有 DB write / restore / prune。 + +**下一步**: +- 先恢復 110 SSH read-only command path,才能驗證 Stock DB/schema、Gitea dump、110 backup completeness。 +- 將 188 Gitea subtree backup metric 部署到 runtime textfile exporter 後,確認 `awoooi_backup_coverage_domain_fresh{host="188",domain="service"}` 能在 Gitea 子樹空目錄時告警。 diff --git a/docs/runbooks/REBOOT-POST-START-QUICK-CHECK.md b/docs/runbooks/REBOOT-POST-START-QUICK-CHECK.md index 9ded05d9..b37d536e 100644 --- a/docs/runbooks/REBOOT-POST-START-QUICK-CHECK.md +++ b/docs/runbooks/REBOOT-POST-START-QUICK-CHECK.md @@ -1,8 +1,8 @@ # 主機重啟後一頁式總檢查 -> Version: v1.18 -> Last updated: 2026-06-27 Asia/Taipei -> Scope: 110 / 120 / 121 / 188 post-reboot service recovery. 112 Kali / Wazuh / active scan 不屬於本流程。 +> Version: v1.19 +> Last updated: 2026-06-30 Asia/Taipei +> Scope: 99 / 110 / 111 / 112 / 120 / 121 / 188 post-reboot service recovery. 112 Kali / Wazuh / active scan 只做可達性與託管狀態 evidence,不做 active scan。 --- @@ -19,6 +19,28 @@ 3. 資料與備份是否新鮮。 4. 有哪些不能宣稱完成。 +### 2026-06-30 全主機重啟事故沉澱 + +本次重啟後不得再用單一路由或單一備份心跳宣稱恢復,必須固定讀回下列證據。 + +| 類別 | 2026-06-30 讀回事實 | SOP 判讀 | +|------|----------------------|----------| +| AWOOOI production 版本 | Gitea `main=adc0d8816`,production readback 仍為 `7890778b83`;新 `stockplatform-public-api-controlled-recovery-preflight` route 在 production 回 404。 | 「產品都是最新版本」不可成立;必須先完成 deploy marker / runtime SHA / endpoint readback 三者一致。 | +| Full-stack cold-start | `full-stack-cold-start-check.sh --monitor-read-only --no-color` 回 `PASS=64 WARN=6 BLOCKED=6`;110 registry / SSH、K3s registry pull refused、Harbor / Registry / SigNoz 502/TLS 為 blocker。 | 不可宣稱 10 分鐘全服務恢復;先修第一個 cold-start blocker,再重跑同一命令。 | +| Tsenyang 502 | `www.tsenyang.com` / `tsenyang.com` 起初 HTTP 502;188 release `/home/ollama/tsenyang-website-releases/b369ed8` 有 source,但 `tsenyang-website:latest` image 缺失、container 未啟動。 | 使用者可見 502 是 P0;可用現有 release 做 bounded `docker build --quiet -t tsenyang-website:latest .` + `docker compose up -d --no-build`,再以 local 3000 與 public 200 驗證。 | +| Tsenyang 修復結果 | image `sha256:e25e0acb...` 建成,container `tsenyang-website` running,local `127.0.0.1:3000`、`www.tsenyang.com`、`tsenyang.com` 均回 200。 | 下次若同樣 502,先查 image/container/release symlink,不先動 Nginx、DNS、DB 或 host reboot。 | +| StockPlatform freshness | public `/healthz`、`/api/healthz` 回 200;`/api/v1/system/freshness` 與 `/api/v1/system/ingestion` 回 `status=not_configured`、blocker `postgres_not_ready`。 | service up 不等於資料最新;Stock 必須修 PostgreSQL schema/readiness 或資料 source readback,禁止 fake freshness、manual DB row、restore/prune。 | +| 110 control path | ping / SSH port 可達,但 `wooo@192.168.0.110` SSH command timeout。 | 110 control path 是 P0 blocker;未恢復前不可宣稱 Stock DB/schema、Gitea dump、110 backup completeness 已現場驗證。 | +| Gitea visibility | `https://gitea.wooo.work/api/v1/version` 回 200 / `1.25.5`;public repo search 只列 4 個 public repos;`stockplatform-v2` public page/API 404,但 direct internal Git remote `http://192.168.0.110:3001/wooo/stockplatform-v2.git` 可 `ls-remote`。 | 不能直接說 repo 全消失;要區分 public visibility、private/auth visibility、內網 Git remote、server-side storage 四層。 | +| Gitea backup | 188 `/home/ollama/backup/110/gitea` 目前為空;舊 `backup_from_110` freshness 只證明 rsync 心跳,不證明 Gitea 子樹或 dump 完整。 | 備份監控必須新增 Gitea subtree freshness / sample count;空目錄是 P0 backup completeness blocker。 | + +本次事故後新增固定判讀: + +- `public API 200` 只能代表 route / process alive;要宣稱資料最新,必須讀各產品 freshness / ingestion / deploy marker。 +- `backup_from_110 fresh=1` 只能代表整體 rsync 成功;Gitea、Harbor、DB、網站、logs 等 domain 必須有獨立 freshness 或 sample count。 +- Gitea repo 疑似消失時,順序固定為 `public search` → `repo page/API` → `GIT_TERMINAL_PROMPT=0 git ls-remote ` → server-side storage / dump / backup readback;不得先 restore 或 delete。 +- 使用者可見 502 先啟用 L0 maintenance fallback 或恢復單站 container;若 edge/99/188 整段不可達,才進 L1 external fallback / CDN。 + --- ## 2. 絕對判定規則 @@ -196,7 +218,14 @@ curl -k -sS https://stock.wooo.work/api/v1/system/freshness - `core.price_daily`、`core.chips_daily`、`core.market_index_daily.tw` 必須是 `ok`。 - `blockers` 不可有 `core_margin_short_daily_missing`、`ai_recommendations_stale` 或其他資料閘門阻擋。 -`stock.wooo.work`、`/healthz`、`/api/healthz` 皆為 200 只代表服務活著;`/api/v1/system/freshness` 回 `blocked` 時,不可宣稱 StockPlatform 資料最新。 +`stock.wooo.work`、`/healthz`、`/api/healthz` 皆為 200 只代表服務活著;`/api/v1/system/freshness` 回 `blocked` 或 `not_configured` 時,不可宣稱 StockPlatform 資料最新。 + +若 freshness / ingestion 回 `status=not_configured` 且 blocker 為 `postgres_not_ready`: + +- 先確認 `/api/healthz` 是否為 `200`;若是,分類為 API alive + data/schema not ready。 +- 再確認 110 control path:`ssh -o BatchMode=yes -o ConnectTimeout=10 wooo@192.168.0.110 "hostname; uptime"`。 +- 若 SSH timeout,只能記錄 `stockplatform_postgres_readiness_blocked_by_110_control_path`,不得用 DB restore、manual rows 或重啟主機處理。 +- 110 control path 恢復後,才在正式 `/home/wooo/stockplatform-v2` 做 read-only compose / DB schema / migration status readback,再決定是否進 controlled apply。 若 freshness blocker 是 `core_margin_short_daily_missing` / `ai_recommendations_stale`,先分層判斷: @@ -226,6 +255,32 @@ curl -k -sS https://stock.wooo.work/api/v1/system/freshness `escrow_missing>0` 時,服務可 green,但 DR 不可 green。 +### Step 5A - Gitea repo / backup completeness gate + +Gitea UI 或 public route 看不到 repo 時,先用分層 readback,不可直接判定 repo 消失: + +```bash +curl -ksS https://gitea.wooo.work/api/v1/version +curl -ksS 'https://gitea.wooo.work/api/v1/repos/search?limit=50' +GIT_TERMINAL_PROMPT=0 timeout 20 git ls-remote --heads http://192.168.0.110:3001/wooo/stockplatform-v2.git +``` + +判定: + +- `api/v1/version=200` 代表 Gitea process alive。 +- public search 只列 public repo,不代表 private/internal repo 不存在。 +- public page/API `404` 加上 internal `git ls-remote` 成功,代表 public visibility / auth / route 層問題,不是 Git object 全消失。 +- internal `git ls-remote` 也失敗時,才升級 server-side storage / Gitea DB / repo path readback;仍不得直接 restore 或 delete。 + +Gitea backup completeness 必須同時看: + +```bash +ssh ollama@192.168.0.188 'find /home/ollama/backup/110/gitea -maxdepth 3 -type f | head' +ssh ollama@192.168.0.188 'grep -E "gitea_repo_mirror_from_110|awoooi_backup_coverage_domain" /home/ollama/node_exporter_textfiles/backup_health.prom' +``` + +`backup_from_110` fresh 但 `/home/ollama/backup/110/gitea` 空,必須視為 `gitea_backup_completeness_blocked`;只能先修 backup coverage / dump / rsync include,再做 restore drill。不得用「每日備份心跳正常」覆蓋 Gitea 子樹空目錄。 + ### Step 6 - Public routes 只作輔助證據 ```bash diff --git a/docs/workplans/2026-06-04-reboot-cold-start-backup-recovery-workplan.md b/docs/workplans/2026-06-04-reboot-cold-start-backup-recovery-workplan.md index fd674697..7cbddd8e 100644 --- a/docs/workplans/2026-06-04-reboot-cold-start-backup-recovery-workplan.md +++ b/docs/workplans/2026-06-04-reboot-cold-start-backup-recovery-workplan.md @@ -9,6 +9,28 @@ ## 1. Current Verdict +### 2026-06-30 全主機重啟後 P0 工作隊列 + +本段覆蓋舊的「單次重啟後人工排查」做法。所有後續狀態回報必須依此順序推進;噪音若會遮蔽 P0,就掛回同一列,不另開支線。 + +| 優先 | 狀態 | 工作項 | 2026-06-30 證據 | 下一步 / 完成條件 | +|------|------|--------|------------------|-------------------| +| P0-1 | BLOCKED | 全主機 cold-start / 10 分鐘自動恢復 SLO | `full-stack-cold-start-check.sh --monitor-read-only --no-color` 回 `PASS=64 WARN=6 BLOCKED=6`;110 registry / SSH、K3s registry pull refused、Harbor / Registry / SigNoz 502/TLS 仍 blocked。 | 先修第一個 cold-start blocker,重跑同一 scorecard 到 `BLOCKED=0`;不可只用 route 200 宣稱恢復。 | +| P0-2 | DONE_THIS_INCIDENT | 使用者可見 502:Tsenyang | `www.tsenyang.com` / `tsenyang.com` 由 502 恢復為 200;188 `tsenyang-website` container running;local `127.0.0.1:3000` 回 200。 | 下次同類 502 先查 release symlink / image / container;不先動 Nginx、DNS、DB、主機重啟。 | +| P0-3 | BLOCKED | StockPlatform data freshness | public `/healthz`、`/api/healthz` 回 200;freshness / ingestion 回 `not_configured`、`postgres_not_ready`。 | 恢復 110 control path 後,read-only 查 `/home/wooo/stockplatform-v2` compose / DB schema / migration status;禁止 fake freshness、manual DB rows、restore/prune。 | +| P0-4 | BLOCKED | AWOOOI production 版本最新性 | source/Gitea `main=adc0d8816`;production readback 仍為 `7890778b83`;新 Stock preflight endpoint production 404。 | 補 deploy marker / runtime SHA / endpoint readback 一致;未一致前不可宣稱 AWOOOI 最新。 | +| P0-5 | BLOCKED | 110 control path | ping / SSH port 可達,但 SSH command timeout。 | 恢復 SSH read-only command path;完成後才能驗證 Stock DB、Gitea dump、110 backup completeness。 | +| P0-6 | BLOCKED_BACKUP_COMPLETENESS | Gitea repo visibility 與完整備份 | Gitea version API 200;public repo search 只列 4 個 public repo;`stockplatform-v2` public page/API 404,但 internal `git ls-remote` 成功;188 `/home/ollama/backup/110/gitea` 為空。 | 新增 188 `gitea_repo_mirror_from_110` subtree freshness metric 與 188 coverage alert;再補實際 Gitea dump / mirror / restore drill readback。 | +| P0-7 | SOURCE_READY_RUNTIME_BLOCKED | 99 VMware / VM autostart | repo 已有 `windows99-vmware-autostart.ps1`,但 99 SSH/WinRM 尚未可用;VM host 111 仍不可達。 | 恢復 99 可控通道或由 console 套用腳本;完成後讀回 111/188/120/121/112 boot evidence。 | +| P0-8 | SOURCE_READY_RUNTIME_BLOCKED | 502 maintenance fallback / Telegram / backup alert | L0/L1 fallback runbook、Nginx snippet、reboot / backup alert rules 已在 source;runtime 尚需部署與外部 L1 provider readback。 | L0 以測試 vhost 驗證 `X-AWOOOI-Fallback`;L1 需外部雲端/CDN probe;Telegram 以脫敏 alert receipt 驗證。 | + +本次核心經驗: + +- Gitea public UI/API 只代表 public visibility;private/internal repo 必須用 authenticated inventory 或 internal `git ls-remote` 分層驗證。 +- 備份心跳 fresh 不等於完整備份;Gitea 子樹 / dump / repo count / restore drill 必須單獨告警。 +- 使用者可見 502 優先於資料 freshness;先恢復靜態/容器服務,再回到資料層與版本一致性。 +- 版本最新性要同時看 source SHA、deploy marker、runtime SHA 與 public endpoint;不能只看 Gitea main。 + | Area | Status | Completion | Evidence | |------|--------|------------|----------| | Overall recovery readiness | SERVICE_BLOCKED_MOMO_SOURCE_ABSENCE | 96% | 2026-06-27 11:51 live revalidation 覆蓋 02:42 舊綠燈判讀。`post-reboot-readiness-summary.sh --no-color` artifact `/tmp/awoooi-post-reboot-readiness-20260627-115046/summary.txt` 回傳 `SERVICE_GREEN=0`、`POST_START_RESULT=BLOCKED`、`POST_START_BLOCKED=2`、`BACKUP_CORE_GREEN=1`、`HOST_188_HYGIENE_BLOCKED=0`、`STOCK_FRESHNESS_STATUS=ok`、`STOCK_LATEST_TRADING_DATE=2026-06-26`、`ESCROW_MISSING_COUNT=5`、`WAZUH_MANAGER_REGISTRY_ACCEPTED=0`。本輪 188 `momo_pg_daily` configured drift 已再次修復;`backup-status` 回 `core_blockers=0`、`configured_missing_188=0`。K3s / ArgoCD readback:120 / 121 皆 `Ready`,`awoooi-prod Synced / Healthy`,api/web/worker pods Running。MOMO 服務健康但資料 freshness blocked:正式補跑 import 回 `file_count=0`,Drive metadata 回 `DRIVE_INTAKE_COUNT=0`、archive / global latest `2026-06-25T04:21:47Z`,latest import job `57` clean completed 且資料範圍只到 `2026-06-24`。不可手寫 DB 或用舊 archive 假裝更新;需等新來源檔進正式 intake 後再匯入。DR 仍因 credential escrow 缺 5 不能宣稱 complete;Wazuh manager registry accepted 仍為 0。 | diff --git a/ops/monitoring/alerts-unified.yml b/ops/monitoring/alerts-unified.yml index 42948740..9f09da12 100644 --- a/ops/monitoring/alerts-unified.yml +++ b/ops/monitoring/alerts-unified.yml @@ -1487,14 +1487,30 @@ groups: description: "backup-health exporter 沒有輸出 host/database/website/service/package/tool/log 聚合覆蓋指標,無法快速判斷完整備份是否還在運作。" runbook: "部署新版 scripts/ops/backup-health-textfile-exporter.py,刷新 /home/wooo/node_exporter_textfiles/backup_health.prom。" + - alert: BackupCoverageDomainMetricMissing188 + expr: absent(awoooi_backup_coverage_domain_expected_info{host="188"}) + for: 20m + labels: + severity: warning + layer: host-backup + component: backup-coverage + host: "188" + team: ops + alert_category: infrastructure + notification_type: TYPE-1 + auto_repair: "false" + annotations: + summary: "188 備份覆蓋 domain 指標缺失" + description: "backup-health exporter 沒有輸出 188 的 service 備份覆蓋指標,無法確認 110 rsync 後 Gitea 等關鍵子樹是否真的存在。" + runbook: "部署新版 scripts/ops/backup-health-textfile-exporter.py 到 188,刷新 /home/ollama/node_exporter_textfiles/backup_health.prom。" + - alert: BackupCoverageDomainStale - expr: awoooi_backup_coverage_domain_fresh{host="110"} == 0 + expr: awoooi_backup_coverage_domain_fresh == 0 for: 15m labels: severity: critical layer: host-backup component: backup-coverage - host: "110" team: ops alert_category: infrastructure notification_type: TYPE-3 diff --git a/scripts/ops/backup-health-textfile-exporter.py b/scripts/ops/backup-health-textfile-exporter.py index 98a0f9eb..8cec4ac5 100755 --- a/scripts/ops/backup-health-textfile-exporter.py +++ b/scripts/ops/backup-health-textfile-exporter.py @@ -191,6 +191,24 @@ def _newest_file_timestamp(patterns: list[str]) -> int: return newest +def _newest_tree_timestamp(root: Path, max_entries: int = 5000) -> tuple[int, int]: + if not root.exists(): + return 0, 0 + newest = 0 + count = 0 + for path in root.rglob("*"): + try: + if not path.is_file(): + continue + count += 1 + newest = max(newest, int(path.stat().st_mtime)) + except OSError: + continue + if count >= max_entries: + break + return newest, count + + def _read_backup_110_timestamp() -> int: candidates = [ Path("/home/ollama/node_exporter_textfiles/backup.prom"), @@ -907,6 +925,30 @@ def _collect_188(host: str) -> list[str]: sample_count=1, ) ) + gitea_mirror_ts, gitea_mirror_count = _newest_tree_timestamp(Path("/home/ollama/backup/110/gitea")) + gitea_mirror_fresh = 1 if gitea_mirror_ts and int(time.time()) - gitea_mirror_ts <= 25 * 3600 else 0 + lines.extend( + _metric_lines_for_job( + host=host, + job="gitea_repo_mirror_from_110", + source="188-rsync-subtree", + target="/home/ollama/backup/110/gitea", + backup_type="rsync_subtree", + last_success=gitea_mirror_ts, + max_age_hours=25, + sample_count=gitea_mirror_count, + ) + ) + coverage_labels = ( + f'host="{_escape_label(host)}",' + 'domain="service",' + 'required_jobs="backup_from_110,gitea_repo_mirror_from_110"' + ) + lines.append(f"awoooi_backup_coverage_domain_expected_info{{{coverage_labels}}} 1") + lines.append( + "awoooi_backup_coverage_domain_fresh" + f"{{{coverage_labels}}} {1 if gitea_mirror_fresh else 0}" + ) momo_ts = _newest_file_timestamp([ "/home/ollama/momo_backups/*.sql.gz", "/home/ollama/momo-pro/backups/*.sql.gz", diff --git a/scripts/reboot-recovery/tests/test_reboot_p0_operational_contract.py b/scripts/reboot-recovery/tests/test_reboot_p0_operational_contract.py index b969a79d..510bf6f9 100644 --- a/scripts/reboot-recovery/tests/test_reboot_p0_operational_contract.py +++ b/scripts/reboot-recovery/tests/test_reboot_p0_operational_contract.py @@ -49,5 +49,15 @@ def test_backup_exporter_emits_domain_level_backup_coverage() -> None: assert "awoooi_backup_coverage_domain_expected_info" in exporter assert "awoooi_backup_coverage_domain_fresh" in exporter + assert "gitea_repo_mirror_from_110" in exporter + assert "/home/ollama/backup/110/gitea" in exporter for domain in ["host", "database", "website", "service", "package", "tool", "log"]: assert f'"{domain}"' in exporter + + +def test_backup_alerts_cover_188_gitea_mirror_subtree() -> None: + alerts = read("ops/monitoring/alerts-unified.yml") + + assert 'awoooi_backup_coverage_domain_expected_info{host="188"}' in alerts + assert "BackupCoverageDomainMetricMissing188" in alerts + assert "awoooi_backup_coverage_domain_fresh == 0" in alerts